Daylight bar chrome + embedded launcher, v0.7.5 pins #5
6 changed files with 512 additions and 10 deletions
24
Cargo.lock
generated
24
Cargo.lock
generated
|
|
@ -150,13 +150,22 @@ version = "2.13.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bread-launcher"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"bread-utils 0.7.4",
|
||||
"gtk4",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-screenshots"
|
||||
version = "0.7.4"
|
||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bread-utils",
|
||||
"bread-utils 0.7.4 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4)",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
|
|
@ -196,6 +205,16 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-utils"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"bread-shared 0.7.0",
|
||||
"dirs 5.0.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-utils"
|
||||
version = "0.7.4"
|
||||
|
|
@ -212,10 +231,11 @@ name = "breadbar"
|
|||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bread-launcher",
|
||||
"bread-screenshots",
|
||||
"bread-shared 0.8.0",
|
||||
"bread-theme",
|
||||
"bread-utils",
|
||||
"bread-utils 0.7.4 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4)",
|
||||
"clap",
|
||||
"futures-lite",
|
||||
"gtk4",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag =
|
|||
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" }
|
||||
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
|
||||
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" }
|
||||
# Headless app-launcher core + GTK4 results-list widget (plan §3/§7): theme
|
||||
# 04/spotlight embeds the SAME `ResultsList` breadbox's overlay wraps, via
|
||||
# `bar::launcher_results` — one implementation, two hosts. `gtk` feature for
|
||||
# the results widget itself.
|
||||
bread-launcher = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||
gtk4-layer-shell = "0.8"
|
||||
relm4 = { version = "0.11", features = ["macros"] }
|
||||
|
|
@ -46,3 +51,4 @@ strip = "symbols"
|
|||
# (feature/shell-theme-manifest) before it is tagged as a release.
|
||||
[patch."https://git.breadway.dev/Breadway/bread-ecosystem"]
|
||||
bread-theme = { path = "../bread-ecosystem/bread-theme" }
|
||||
bread-launcher = { path = "../bread-ecosystem/bread-launcher" }
|
||||
|
|
|
|||
|
|
@ -155,6 +155,45 @@ pub fn make_button(
|
|||
btn
|
||||
}
|
||||
|
||||
/// `style = "dots"` (theme 04/spotlight): a label-less pill whose WIDTH
|
||||
/// encodes `windows` (0/1/2/3-or-more open) via `dot_widths` — see
|
||||
/// `bread_theme::shell::WorkspacesModule::dot_widths`. Distinct from
|
||||
/// [`make_button`] (Trail/Pill) rather than a variant of it because dots
|
||||
/// carry no text at all (`04-spotlight.html`'s `.dots button` has no label);
|
||||
/// reusing `Button::with_label("")` would still measure/lay out an empty
|
||||
/// label box that a genuinely childless button doesn't. Width is a hard
|
||||
/// `set_size_request` snap, not animated — GTK CSS min-width transitions
|
||||
/// don't participate in a directly-set size request the way an opacity/
|
||||
/// background-color transition does, and the plan only calls out the
|
||||
/// capsule's own expand/collapse as worth the `anim::spring_to` treatment.
|
||||
pub fn make_dot_button(
|
||||
id: WorkspaceId,
|
||||
active: WorkspaceId,
|
||||
windows: i32,
|
||||
dot_widths: bread_theme::shell::DotWidths,
|
||||
) -> gtk4::Button {
|
||||
let btn = gtk4::Button::new();
|
||||
btn.add_css_class("workspace-dot");
|
||||
if windows > 0 {
|
||||
btn.add_css_class("occupied");
|
||||
}
|
||||
if id == active {
|
||||
btn.add_css_class("active");
|
||||
}
|
||||
btn.set_valign(gtk4::Align::Center);
|
||||
btn.set_halign(gtk4::Align::Center);
|
||||
btn.set_vexpand(false);
|
||||
btn.set_hexpand(false);
|
||||
let idx = (windows.max(0) as usize).min(3);
|
||||
btn.set_size_request(dot_widths[idx], 6);
|
||||
btn.connect_clicked(move |_| {
|
||||
relm4::spawn(async move {
|
||||
switch_workspace(id).await;
|
||||
});
|
||||
});
|
||||
btn
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Geom {
|
||||
x: f64,
|
||||
|
|
|
|||
322
src/main.rs
322
src/main.rs
|
|
@ -13,7 +13,8 @@ mod surface;
|
|||
mod theme;
|
||||
mod widgets;
|
||||
|
||||
use bread_theme::shell::{ClockStyle, Exclusive, Keyboard, WorkspaceStyle};
|
||||
use bread_launcher::gtk::ResultsList;
|
||||
use bread_theme::shell::{ClockStyle, Exclusive, Keyboard, Width, WorkspaceStyle};
|
||||
use gtk4::prelude::*;
|
||||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||
use hyprland::data::Workspace;
|
||||
|
|
@ -21,8 +22,21 @@ use hyprland::shared::WorkspaceId;
|
|||
use relm4::prelude::*;
|
||||
use relm4::{Component, ComponentController, Controller};
|
||||
use std::cell::Cell;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// The launched-app event's publisher id and event name — deliberately
|
||||
/// matching breadbox's OWN constants (`breadbox/src/main.rs`: `APP_ID =
|
||||
/// "box"`, `LAUNCHED_EVENT = "bread.box.launched"`), NOT breadbar's own
|
||||
/// `widgets::client::APP_ID` ("bar"). Theme 04/spotlight's capsule IS the
|
||||
/// launcher wearing a different shell (plan §7), sharing breadbox's cache
|
||||
/// and history via `bread_launcher::LAUNCHER_APP` — it publishes under that
|
||||
/// same launcher identity too, so anything downstream listening for "an app
|
||||
/// was launched via the launcher" sees one event stream regardless of which
|
||||
/// surface launched it.
|
||||
const LAUNCHER_APP_ID: &str = "box";
|
||||
const LAUNCHER_LAUNCHED_EVENT: &str = "bread.box.launched";
|
||||
|
||||
pub struct BarInit {
|
||||
pub screenshot: Option<screenshot::ScreenshotRequest>,
|
||||
pub monitor: Option<String>,
|
||||
|
|
@ -51,6 +65,15 @@ pub struct App {
|
|||
// recompile; only one of the two ever lands in a `[bar.slots]` module
|
||||
// registration (see the "Assemble" section).
|
||||
clock_plain_lbl: gtk4::Label,
|
||||
// `modules.clock.placeholder_clock` (spotlight, theme 04): when set,
|
||||
// `AppInput::ClockTick` writes the time into this entry's placeholder
|
||||
// text instead of (or alongside) any clock label — see that handler.
|
||||
launcher_entry: gtk4::Entry,
|
||||
// Whether the capsule's drawer is currently expanded — read by
|
||||
// `ClockTick` so a live search in progress never has its placeholder
|
||||
// text stomped (it wouldn't be visible anyway once there's real text,
|
||||
// but matches the demo's own `if (!open) q.placeholder = t;` guard).
|
||||
launcher_open: Rc<Cell<bool>>,
|
||||
|
||||
// ── Stats bar ─────────────────────────────────────────────────────────
|
||||
// Island chrome matches the Liquid Motion demo: volume / wifi / battery
|
||||
|
|
@ -165,8 +188,27 @@ impl SimpleComponent for App {
|
|||
set_title: Some("breadbar"),
|
||||
set_default_height: bar_height,
|
||||
|
||||
// Root is a vbox (bar row + drawer), not a bare CenterBox, per
|
||||
// plan §2/§11: `drawer` is the only structural thing Capsule/
|
||||
// theme-04 adds over Island/Edge, and it's a slot below the bar
|
||||
// row, not a separate layout code path. `drawer_box` starts
|
||||
// empty and zero-height for every theme that never names a
|
||||
// module in `[bar.slots].drawer` (liquid-motion, glass-
|
||||
// workbench) — see main.rs's "Assemble" section and
|
||||
// `theme.rs`'s `window.breadbar > box > centerbox` selector
|
||||
// update for why this is a no-op for both.
|
||||
#[name = "root_vbox"]
|
||||
gtk::Box {
|
||||
set_orientation: gtk4::Orientation::Vertical,
|
||||
|
||||
#[name = "center_box"]
|
||||
gtk::CenterBox {
|
||||
},
|
||||
|
||||
#[name = "drawer_box"]
|
||||
gtk::Box {
|
||||
set_orientation: gtk4::Orientation::Vertical,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -211,6 +253,17 @@ impl SimpleComponent for App {
|
|||
root.set_margin(Edge::Top, window_spec.margin.top);
|
||||
root.set_margin(Edge::Left, window_spec.margin.left);
|
||||
root.set_margin(Edge::Right, window_spec.margin.right);
|
||||
// `Width::Fill` (Island/Edge): unset, exactly as before this
|
||||
// change — the surface stretches to the anchored left/right edges
|
||||
// on its own, with no explicit width request needed. `Width::Px`
|
||||
// (the capsule, anchored top-only): gtk4-layer-shell has nothing to
|
||||
// stretch it TO, so without this it would size to its natural
|
||||
// content width instead of the theme's requested 480px — this was
|
||||
// a schema key declared but never consumed before theme 04 needed
|
||||
// a real value out of it.
|
||||
if let Width::Px(px) = window_spec.width {
|
||||
root.set_default_width(px);
|
||||
}
|
||||
// "auto" reserves height + top margin so tiled clients sit below the
|
||||
// gap — see WindowSpec::exclusive's doc comment (bread-theme).
|
||||
let exclusive_zone = match window_spec.exclusive {
|
||||
|
|
@ -418,6 +471,66 @@ impl SimpleComponent for App {
|
|||
clock_plain_box.append(&date_lbl);
|
||||
clock_plain_box.append(&clock_plain_lbl);
|
||||
|
||||
// ── Launcher entry + results (theme 04/spotlight, plan §7) ───────
|
||||
// Built unconditionally, exactly like `clock_plain_box` above —
|
||||
// placed in a slot only by a theme that names "launcher_entry"/
|
||||
// "launcher_results" (spotlight today; see "Assemble" below).
|
||||
// `bread_launcher::LAUNCHER_APP` ("breadbox") is the launcher's
|
||||
// shared identity: this reads/writes the SAME icon cache and
|
||||
// launch history breadbox's own overlay window does, so the
|
||||
// capsule and breadbox rank a user's apps identically instead of
|
||||
// forking into two histories just because a different theme
|
||||
// happens to be active (see that constant's own doc comment).
|
||||
let launcher_cfg = theme::shell_theme().launcher().clone();
|
||||
let launcher_manifest: std::collections::HashMap<String, std::path::PathBuf> =
|
||||
std::fs::read_to_string(bread_launcher::IconCache::manifest_path(
|
||||
bread_launcher::LAUNCHER_APP,
|
||||
))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<std::collections::HashMap<String, String>>(&s).ok())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, std::path::PathBuf::from(v)))
|
||||
.collect();
|
||||
let launcher_history = Rc::new(RefCell::new(bread_launcher::LaunchHistory::load(
|
||||
bread_launcher::LAUNCHER_APP,
|
||||
)));
|
||||
// No per-workspace priority context here — that's breadbox's own
|
||||
// `Config`/`Context` format (breadbox-shared), not launcher
|
||||
// substance, and breadbar has no equivalent concept. The capsule
|
||||
// sorts by launch history then alphabetically, same fallback
|
||||
// ordering breadbox itself uses once a workspace has no configured
|
||||
// priority list at all.
|
||||
let launcher_entries = bread_launcher::load_sorted_entries(
|
||||
&launcher_manifest,
|
||||
&[],
|
||||
&launcher_history.borrow(),
|
||||
);
|
||||
let launcher_results = ResultsList::new(
|
||||
&launcher_entries,
|
||||
launcher_cfg.icon_px,
|
||||
Rc::clone(&launcher_history),
|
||||
);
|
||||
launcher_results.scroller.add_css_class("bread-drawer-scroller");
|
||||
|
||||
let launcher_entry = gtk4::Entry::new();
|
||||
launcher_entry.add_css_class("launcher-entry");
|
||||
launcher_entry.set_has_frame(false);
|
||||
launcher_entry.set_hexpand(true);
|
||||
gtk4::prelude::EntryExt::set_alignment(&launcher_entry, 0.5);
|
||||
// `modules.clock.placeholder_clock` (spotlight): the entry's idle
|
||||
// placeholder IS the clock — no separate clock module renders at
|
||||
// all under `style = "none"`. Any other theme gets a plain "Search"
|
||||
// placeholder (never shown today: no other builtin slots
|
||||
// "launcher_entry" anywhere), so this still degrades sanely if a
|
||||
// future/user theme places it without also setting the flag.
|
||||
let placeholder_clock = theme::shell_theme().modules().clock.placeholder_clock;
|
||||
launcher_entry.set_placeholder_text(Some(if placeholder_clock {
|
||||
bar::clock::time()
|
||||
} else {
|
||||
"Search".to_string()
|
||||
}.as_str()));
|
||||
|
||||
// Center area: [media_widget · widgets · clock · widgets]
|
||||
let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||
center_area.add_css_class("center-area");
|
||||
|
|
@ -789,6 +902,11 @@ impl SimpleComponent for App {
|
|||
// glass-workbench (which omits "media" entirely).
|
||||
bar_modules.register("cpu", &bar_cpu_pair);
|
||||
bar_modules.register("ram", &bar_ram_pair);
|
||||
// Theme 04/spotlight (plan §7): unconditional, same reasoning —
|
||||
// liquid-motion/glass-workbench never name either in a slot, so
|
||||
// both stay built but unparented for them.
|
||||
bar_modules.register("launcher_entry", &launcher_entry);
|
||||
bar_modules.register("launcher_results", &launcher_results.scroller);
|
||||
|
||||
// `tray` never appears in a bar slot — it stays inside the
|
||||
// control-panel popover (built above, next to the SNI tray) — but
|
||||
|
|
@ -820,6 +938,143 @@ impl SimpleComponent for App {
|
|||
widgets.center_box.set_center_widget(Some(¢er_area));
|
||||
widgets.center_box.set_end_widget(Some(&stats_box));
|
||||
|
||||
// `drawer` slot (plan §2/§7/§11 Phase 6): the only slot list that
|
||||
// isn't left/centre/right of the CenterBox — appended into the vbox
|
||||
// row below it instead. Empty for every theme but spotlight, so
|
||||
// `drawer_box` stays a childless, zero-height box for them (see the
|
||||
// `window.breadbar > box > centerbox` selector note in theme.rs for
|
||||
// why the vbox wrapper itself is safe for those two themes too).
|
||||
widgets.drawer_box.add_css_class("bread-drawer");
|
||||
bar_modules.for_each_in_slot(
|
||||
&bar_slots.drawer,
|
||||
|_, widget| widgets.drawer_box.append(widget),
|
||||
|key| {
|
||||
widgets
|
||||
.drawer_box
|
||||
.append(&bar::slots::widget_slot_container(&mut widget_containers, key))
|
||||
},
|
||||
);
|
||||
// Collapsed by default; `Overflow::Hidden` clips the results list
|
||||
// while its allocated height is below its natural content height,
|
||||
// same as the demo's `.results { max-height: 0; overflow: hidden }`.
|
||||
widgets.drawer_box.set_overflow(gtk4::Overflow::Hidden);
|
||||
widgets.drawer_box.set_size_request(-1, 0);
|
||||
|
||||
// ── Capsule expand/collapse + search wiring (theme 04/spotlight) ──
|
||||
// Effectively a no-op under every other theme: `launcher_entry`
|
||||
// never receives focus if it's never in a slot, so `open_fn` is
|
||||
// simply never invoked. `results.set_query`/select_next`/`select_prev`
|
||||
// and launching all come straight from `bread-launcher`; only the
|
||||
// capsule shell (drawer height, entry placeholder/alignment,
|
||||
// keyboard focus) is this file's own.
|
||||
let anim: Rc<RefCell<Option<gtk4::TickCallbackId>>> = Rc::new(RefCell::new(None));
|
||||
let launcher_open: Rc<Cell<bool>> = Rc::new(Cell::new(false));
|
||||
|
||||
let open_fn: Rc<dyn Fn()> = Rc::new({
|
||||
let drawer_box = widgets.drawer_box.clone();
|
||||
let anim = Rc::clone(&anim);
|
||||
let launcher_open = Rc::clone(&launcher_open);
|
||||
let entry = launcher_entry.clone();
|
||||
move || {
|
||||
if !launcher_open.get() {
|
||||
launcher_open.set(true);
|
||||
entry.add_css_class("searching");
|
||||
gtk4::prelude::EntryExt::set_alignment(&entry, 0.0);
|
||||
}
|
||||
let target = drawer_target_height(&drawer_box);
|
||||
let current = drawer_box.size_request().1;
|
||||
animate_drawer_height(&drawer_box, &anim, current, target);
|
||||
}
|
||||
});
|
||||
let close_fn: Rc<dyn Fn()> = Rc::new({
|
||||
let drawer_box = widgets.drawer_box.clone();
|
||||
let anim = Rc::clone(&anim);
|
||||
let launcher_open = Rc::clone(&launcher_open);
|
||||
let entry = launcher_entry.clone();
|
||||
let root_for_focus = root.clone();
|
||||
move || {
|
||||
if !launcher_open.get() {
|
||||
return;
|
||||
}
|
||||
launcher_open.set(false);
|
||||
entry.remove_css_class("searching");
|
||||
gtk4::prelude::EntryExt::set_alignment(&entry, 0.5);
|
||||
entry.set_text("");
|
||||
if placeholder_clock {
|
||||
entry.set_placeholder_text(Some(&bar::clock::time()));
|
||||
}
|
||||
let current = drawer_box.size_request().1;
|
||||
animate_drawer_height(&drawer_box, &anim, current, 0);
|
||||
// `keyboard = "on_demand"` (plan §7) ties the layer-shell
|
||||
// surface's keyboard grab to GTK's own focus-widget state —
|
||||
// releasing focus here is what hands the keyboard back.
|
||||
gtk4::prelude::GtkWindowExt::set_focus(&root_for_focus, None::<>k4::Widget>);
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
let results = launcher_results.clone();
|
||||
let open_fn = Rc::clone(&open_fn);
|
||||
launcher_entry.connect_changed(move |entry| {
|
||||
results.set_query(entry.text().as_str());
|
||||
open_fn();
|
||||
});
|
||||
}
|
||||
{
|
||||
let open_fn = Rc::clone(&open_fn);
|
||||
let focus_ctrl = gtk4::EventControllerFocus::new();
|
||||
focus_ctrl.connect_enter(move |_| open_fn());
|
||||
launcher_entry.add_controller(focus_ctrl);
|
||||
}
|
||||
{
|
||||
let results = launcher_results.clone();
|
||||
let close_fn = Rc::clone(&close_fn);
|
||||
let key_ctrl = gtk4::EventControllerKey::new();
|
||||
key_ctrl.connect_key_pressed(move |_, key, _, _| {
|
||||
use gtk4::gdk::Key;
|
||||
match key {
|
||||
Key::Escape => {
|
||||
close_fn();
|
||||
gtk4::glib::Propagation::Stop
|
||||
}
|
||||
Key::Down => {
|
||||
results.select_next();
|
||||
gtk4::glib::Propagation::Stop
|
||||
}
|
||||
Key::Up => {
|
||||
results.select_prev();
|
||||
gtk4::glib::Propagation::Stop
|
||||
}
|
||||
Key::Return | Key::KP_Enter => {
|
||||
if let Some(entry) = results.selected_entry() {
|
||||
results.record_launch(&entry);
|
||||
bread_launcher::do_launch(
|
||||
&entry,
|
||||
LAUNCHER_APP_ID,
|
||||
LAUNCHER_LAUNCHED_EVENT,
|
||||
);
|
||||
}
|
||||
close_fn();
|
||||
gtk4::glib::Propagation::Stop
|
||||
}
|
||||
_ => gtk4::glib::Propagation::Proceed,
|
||||
}
|
||||
});
|
||||
launcher_entry.add_controller(key_ctrl);
|
||||
}
|
||||
// Row click launches too, same as breadbox's own overlay.
|
||||
{
|
||||
let results = launcher_results.clone();
|
||||
let close_fn = Rc::clone(&close_fn);
|
||||
launcher_results.list.connect_row_activated(move |_, row| {
|
||||
if let Some(entry) = bread_launcher::gtk::row_entry(row) {
|
||||
results.record_launch(&entry);
|
||||
bread_launcher::do_launch(&entry, LAUNCHER_APP_ID, LAUNCHER_LAUNCHED_EVENT);
|
||||
}
|
||||
close_fn();
|
||||
});
|
||||
}
|
||||
|
||||
// Captured before these move into `model` (or are otherwise dropped
|
||||
// as bare locals, never stored on `App` at all) — needed by the
|
||||
// screenshot dispatch just before this function returns.
|
||||
|
|
@ -830,6 +1085,12 @@ impl SimpleComponent for App {
|
|||
let media_panel_for_screenshot = panels.media.clone();
|
||||
let media_widget_for_screenshot = media_widget.clone();
|
||||
let media_track_lbl_for_screenshot = media_track_lbl.clone();
|
||||
// Theme 04/spotlight's capsule (plan §6b): `launcher_entry` moves
|
||||
// into `model` below, `drawer_box` lives only in `widgets` — both
|
||||
// need a clone out here for the same reason every other
|
||||
// `_for_screenshot` handle above does.
|
||||
let launcher_entry_for_screenshot = launcher_entry.clone();
|
||||
let drawer_box_for_screenshot = widgets.drawer_box.clone();
|
||||
|
||||
// Never launch sibling App windows from inside this init — RelmApp
|
||||
// is still in GApplication activate, and a same-type launch here
|
||||
|
|
@ -855,6 +1116,8 @@ impl SimpleComponent for App {
|
|||
clock_digits,
|
||||
date_lbl,
|
||||
clock_plain_lbl,
|
||||
launcher_entry,
|
||||
launcher_open,
|
||||
system_stats_box,
|
||||
system_sep,
|
||||
cpu_pair,
|
||||
|
|
@ -963,6 +1226,8 @@ impl SimpleComponent for App {
|
|||
media_track_lbl: media_track_lbl_for_screenshot,
|
||||
notification_window,
|
||||
osd_window,
|
||||
launcher_entry: launcher_entry_for_screenshot,
|
||||
drawer_box: drawer_box_for_screenshot,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -1050,6 +1315,15 @@ impl SimpleComponent for App {
|
|||
flip_clock_digits(&self.clock_digits, &bar::clock::time());
|
||||
}
|
||||
}
|
||||
// `modules.clock.placeholder_clock` (spotlight): the
|
||||
// capsule's entry IS the clock until focused — matches the
|
||||
// demo's own `if (!open) q.placeholder = t;` guard so a
|
||||
// live search in progress never has its placeholder text
|
||||
// (invisibly, since real text covers it) stomped mid-type.
|
||||
if clock_module.placeholder_clock && !self.launcher_open.get() {
|
||||
self.launcher_entry
|
||||
.set_placeholder_text(Some(&bar::clock::time()));
|
||||
}
|
||||
}
|
||||
AppInput::StatsUpdate(stats) => {
|
||||
let cpu = match stats.cpu_temp {
|
||||
|
|
@ -1429,7 +1703,17 @@ impl App {
|
|||
}
|
||||
}
|
||||
}
|
||||
let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0);
|
||||
let btn = match ws_style {
|
||||
WorkspaceStyle::Dots => bar::workspaces::make_dot_button(
|
||||
ws.id,
|
||||
self.active_ws,
|
||||
ws.windows as i32,
|
||||
modules.workspaces.dot_widths,
|
||||
),
|
||||
WorkspaceStyle::Trail | WorkspaceStyle::Pill => {
|
||||
bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0)
|
||||
}
|
||||
};
|
||||
if !prev.contains(&ws.id) {
|
||||
play_once(&btn, "ws-in", 360);
|
||||
}
|
||||
|
|
@ -1925,6 +2209,40 @@ fn reveal_media(widget: >k4::Box, show: bool) {
|
|||
widget.set_visible(show);
|
||||
}
|
||||
|
||||
/// Drives `drawer_box`'s height from `from` to `to` over 360ms via
|
||||
/// `bread_theme::anim::spring_to` (plan §7: GTK4 has no CSS height
|
||||
/// transition on a widget, so the capsule's `.results { max-height: 0 →
|
||||
/// 420px }` becomes a `set_size_request` interpolation on the frame clock
|
||||
/// instead). Cancels any run already in flight first — reopening mid-close
|
||||
/// (or vice versa) must restart from the CURRENT height, not fight a
|
||||
/// leftover callback still walking toward the old target.
|
||||
fn animate_drawer_height(
|
||||
drawer_box: >k4::Box,
|
||||
anim: &Rc<std::cell::RefCell<Option<gtk4::TickCallbackId>>>,
|
||||
from: i32,
|
||||
to: i32,
|
||||
) {
|
||||
if let Some(id) = anim.borrow_mut().take() {
|
||||
id.remove();
|
||||
}
|
||||
let target = drawer_box.clone();
|
||||
let id = bread_theme::anim::spring_to(drawer_box, from, to, 360.0, move |h| {
|
||||
target.set_size_request(-1, h);
|
||||
});
|
||||
*anim.borrow_mut() = Some(id);
|
||||
}
|
||||
|
||||
/// The drawer's natural content height right now, capped at the demo's own
|
||||
/// 420px (`04-spotlight.html`: `.searching .results { max-height: 420px }`)
|
||||
/// — `ResultsList`'s scroller already self-caps at 480px
|
||||
/// (`max_content_height`), shared with breadbox, so this is a tighter,
|
||||
/// spotlight-specific ceiling on top of that shared one, not a replacement
|
||||
/// for it.
|
||||
fn drawer_target_height(drawer_box: >k4::Box) -> i32 {
|
||||
let (_, natural, _, _) = drawer_box.measure(gtk4::Orientation::Vertical, -1);
|
||||
natural.min(420)
|
||||
}
|
||||
|
||||
fn popover_tab(label: &str) -> gtk4::ToggleButton {
|
||||
let btn = gtk4::ToggleButton::with_label(label);
|
||||
btn.add_css_class("popover-tab");
|
||||
|
|
|
|||
|
|
@ -45,6 +45,14 @@ const KNOWN_VIEWS: &[&str] = &[
|
|||
"osd-volume",
|
||||
"osd-brightness",
|
||||
"wifi-add-dialog",
|
||||
// Theme 04/spotlight's capsule (plan §6b) — see `dispatch`'s two new
|
||||
// match arms. "capsule-collapsed" captures the same region "bar"
|
||||
// always has (bar_capture_height already reflects the active theme's
|
||||
// own window height/margin); it exists as its own name purely so a
|
||||
// verification script doesn't have to already know "bar" means "the
|
||||
// capsule, collapsed" under spotlight specifically.
|
||||
"capsule-collapsed",
|
||||
"capsule-expanded",
|
||||
];
|
||||
|
||||
#[derive(Parser)]
|
||||
|
|
@ -118,6 +126,15 @@ pub struct Handles {
|
|||
pub notification_window: Option<gtk4::Window>,
|
||||
/// Same deal as `notification_window`, via `osd::spawn(Some(kind))`.
|
||||
pub osd_window: Option<gtk4::Window>,
|
||||
/// Theme 04/spotlight's capsule centre module — see the
|
||||
/// "capsule-expanded" view, which focuses it and types a query to
|
||||
/// drive the drawer open before capturing.
|
||||
pub launcher_entry: gtk4::Entry,
|
||||
/// The `drawer` slot's own container — read back after the open
|
||||
/// animation settles so "capsule-expanded"'s capture height matches
|
||||
/// however tall the results actually grew, rather than a guessed
|
||||
/// constant.
|
||||
pub drawer_box: gtk4::Box,
|
||||
}
|
||||
|
||||
/// Capture height for the `bar` view: layer-shell top margin + widget
|
||||
|
|
@ -177,6 +194,48 @@ pub fn dispatch(root: >k4::ApplicationWindow, req: ScreenshotRequest, handles:
|
|||
};
|
||||
capture_standalone_window(window, output, width, height);
|
||||
}
|
||||
"capsule-collapsed" => {
|
||||
// Identical to "bar" — see KNOWN_VIEWS's doc comment on why
|
||||
// this has its own name anyway.
|
||||
root.connect_map(move |_| {
|
||||
let output = output.clone();
|
||||
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
|
||||
finish(bread_screenshots::capture_region(0, 0, width, bar_height, &output));
|
||||
});
|
||||
});
|
||||
}
|
||||
"capsule-expanded" => {
|
||||
// Focus + a real query, the same way a person opens the
|
||||
// capsule (`connect_changed`/`EventControllerFocus::enter` in
|
||||
// main.rs's capsule wiring do the rest: `open_fn` runs,
|
||||
// `results.set_query` filters, and `animate_drawer_height`
|
||||
// grows `drawer_box`). Capture height is bar height + however
|
||||
// tall the drawer actually settled, not a hardcoded guess —
|
||||
// this stays correct even if `dot_widths`/font/entry-count
|
||||
// numbers change later.
|
||||
let entry = handles.launcher_entry;
|
||||
let drawer_box = handles.drawer_box;
|
||||
root.connect_map(move |_| {
|
||||
let output = output.clone();
|
||||
let entry = entry.clone();
|
||||
let drawer_box = drawer_box.clone();
|
||||
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
|
||||
entry.grab_focus();
|
||||
entry.set_text("f");
|
||||
let output = output.clone();
|
||||
let drawer_box = drawer_box.clone();
|
||||
// Past the 360ms spring_to run plus a normal capture
|
||||
// settle, so the drawer's size_request (and the actual
|
||||
// on-screen layer-shell surface it grows) has reached
|
||||
// its final height before grabbing pixels.
|
||||
gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || {
|
||||
let drawer_h = drawer_box.size_request().1.max(0);
|
||||
let capture_h = bar_height + drawer_h;
|
||||
finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
"wifi-add-dialog" => {
|
||||
let anchor = handles.wifi_tab_btn;
|
||||
root.connect_map(move |_| {
|
||||
|
|
|
|||
68
src/theme.rs
68
src/theme.rs
|
|
@ -68,6 +68,13 @@ fn load_css() -> String {
|
|||
// sites below (see the Phase 0 constant inventory).
|
||||
let spring = tokens.spring();
|
||||
let spring_settle = tokens.spring_settle();
|
||||
let bg_alpha = tokens.bg_alpha();
|
||||
// Palette token NAME (never hex — see every builtin theme.toml's own
|
||||
// comment on this), used below by the dots/launcher-entry/drawer rules.
|
||||
// liquid-motion/glass-workbench never render those (see the match arms
|
||||
// and unconditional-but-unused block below), so this being "accent" vs
|
||||
// "green" vs "pink" per theme has no visible effect on them.
|
||||
let accent_from = tokens.accent_from();
|
||||
|
||||
// `tokens.bar_border()` (plan §11 Phase 5): "full" (default, liquid-
|
||||
// motion's floating island) draws a border on all four edges; "bottom"
|
||||
|
|
@ -110,7 +117,7 @@ fn load_css() -> String {
|
|||
.workspace-btn.active:hover {{ background: transparent; }}\
|
||||
.workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}",
|
||||
),
|
||||
_ => {
|
||||
bread_theme::shell::WorkspaceStyle::Pill => {
|
||||
let accent = theme.tokens().accent_from();
|
||||
format!(
|
||||
".workspace-btn {{ background: transparent; opacity: 1; color: alpha(@on-bg, 0.4);\
|
||||
|
|
@ -127,6 +134,30 @@ fn load_css() -> String {
|
|||
.workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}",
|
||||
)
|
||||
}
|
||||
// "dots" (theme 04/spotlight): a label-less pill whose WIDTH comes
|
||||
// from `modules.workspaces.dot_widths` and is set directly via
|
||||
// `Widget::set_size_request` in `bar::workspaces::make_dot_button`
|
||||
// — GTK CSS has no per-instance variable width, so unlike the demo's
|
||||
// `.dots button[data-n="N"]` rules this class only supplies colour/
|
||||
// opacity/radius, never a width. `04-spotlight.html`'s own base
|
||||
// rule (`background: #5a4a54`) is a *dim, desaturated* grey, not the
|
||||
// bar's ink colour — approximated here as a low-alpha `@on-bg` fill
|
||||
// so it still tracks pywal instead of hardcoding a hex that would
|
||||
// clash with a light palette.
|
||||
bread_theme::shell::WorkspaceStyle::Dots => {
|
||||
let accent = theme.tokens().accent_from();
|
||||
format!(
|
||||
".workspace-dot {{ background-color: alpha(@on-bg, 0.35); color: transparent;\
|
||||
border-radius: {radius_pill}; border: none; outline: none; box-shadow: none;\
|
||||
min-height: 6px; margin: 0; padding: 0;\
|
||||
transition: background-color 0.25s {spring_settle},\
|
||||
opacity 0.25s {spring_settle}; }}\
|
||||
.workspace-dot:hover {{ background-color: alpha(@on-bg, 0.55); }}\
|
||||
.workspace-dot:not(.occupied):not(.active) {{ opacity: 0.35; }}\
|
||||
.workspace-dot.active {{ background-color: @{accent}; opacity: 1; }}\
|
||||
.workspace-dot.active:hover {{ background-color: @{accent}; }}",
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
format!(
|
||||
|
|
@ -138,9 +169,15 @@ fn load_css() -> String {
|
|||
@keyframes row-in {{ from {{ opacity: 0; margin-top: 8px; }} to {{ opacity: 1; margin-top: 0; }} }}\
|
||||
@keyframes digit-flip {{ from {{ opacity: 0; margin-top: 7px; }} to {{ opacity: 1; margin-top: 0; }} }}\
|
||||
@keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\
|
||||
window.breadbar {{ background-color: alpha(@bg, 0.72); color: @on-bg;\
|
||||
window.breadbar {{ background-color: alpha(@bg, {bg_alpha}); color: @on-bg;\
|
||||
border-radius: {radius_bar}; {window_border} }}\
|
||||
window.breadbar > centerbox {{ padding: {centerbox_padding}; }}\
|
||||
/* `> box > centerbox`, not `> centerbox`: the root is a vbox (bar\
|
||||
row + drawer, plan §2) as of the `drawer` slot wiring — every\
|
||||
theme's centerbox is now one level deeper than before, this\
|
||||
selector just follows it there. Since `padding` doesn't depend\
|
||||
on nesting depth, liquid-motion/glass-workbench render byte-\
|
||||
identical CSS either way. */\
|
||||
window.breadbar > box > centerbox {{ padding: {centerbox_padding}; }}\
|
||||
window.breadbar button {{ min-height: 0; min-width: 0; }}\
|
||||
{workspace_css}\
|
||||
.clock-box {{ padding: 0 4px; }}\
|
||||
|
|
@ -363,7 +400,28 @@ fn load_css() -> String {
|
|||
.bread-padding-none {{ padding: 0; }}\
|
||||
.bread-padding-xs {{ padding: 4px; }}\
|
||||
.bread-padding-sm {{ padding: 8px; }}\
|
||||
.bread-padding-md {{ padding: 12px; }}",
|
||||
.bread-padding-md {{ padding: 12px; }}\
|
||||
/* Theme 04/spotlight's embedded launcher (plan §7). Unconditional,\
|
||||
like `.clock-plain-time` above: `launcher_entry`/`launcher_results`\
|
||||
are built regardless of the active theme (see main.rs's \"Assemble\"\
|
||||
section), just never placed in a slot outside spotlight, so these\
|
||||
rules render nothing on liquid-motion/glass-workbench. */\
|
||||
.launcher-entry {{ background: transparent; color: @on-bg; border: none;\
|
||||
outline: none; box-shadow: none; caret-color: @{accent_from};\
|
||||
font-size: 13px; font-weight: 500; letter-spacing: 0.06em;\
|
||||
padding: 0; margin: 0; min-height: 0; }}\
|
||||
.launcher-entry.searching {{ font-size: 15px; letter-spacing: 0; }}\
|
||||
.bread-drawer {{ min-height: 0; }}\
|
||||
.bread-drawer.open {{ border-top: 1px solid alpha(@on-bg, 0.08);\
|
||||
margin-top: 6px; padding-top: 2px; }}\
|
||||
.bread-drawer listbox {{ background: transparent; padding: 2px 0; }}\
|
||||
.bread-drawer row {{ padding: 8px 14px; border-radius: {radius_sm};\
|
||||
color: @on-bg; background-color: transparent; }}\
|
||||
.bread-drawer row:hover {{ background-color: alpha(@on-bg, 0.08); }}\
|
||||
.bread-drawer row:selected {{ background-color: alpha(@{accent_from}, 0.18);\
|
||||
color: @on-bg; }}\
|
||||
.bread-drawer .app-name {{ font-size: 14px; font-weight: 500; }}\
|
||||
.bread-drawer .app-muted {{ opacity: 0.45; font-size: 11px; }}",
|
||||
radius = radius,
|
||||
radius_bar = radius_bar,
|
||||
radius_sm = radius_sm,
|
||||
|
|
@ -374,6 +432,8 @@ fn load_css() -> String {
|
|||
window_border = window_border,
|
||||
centerbox_padding = centerbox_padding,
|
||||
workspace_css = workspace_css,
|
||||
bg_alpha = bg_alpha,
|
||||
accent_from = accent_from,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue