Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5169901b95 | |||
|
|
09530e236d | ||
|
|
7c55ea4756 | ||
| b52bf77ed3 | |||
|
|
28ff4bc982 | ||
|
|
b9abafe572 | ||
|
|
06015b2627 |
11 changed files with 930 additions and 836 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -25,3 +25,6 @@ CLAUDE.md
|
||||||
|
|
||||||
# graphify knowledge-graph output (local tool cache, not for commit)
|
# graphify knowledge-graph output (local tool cache, not for commit)
|
||||||
graphify-out/
|
graphify-out/
|
||||||
|
|
||||||
|
# Local-only source overrides (see .cargo/config.toml).
|
||||||
|
.cargo/
|
||||||
|
|
|
||||||
561
Cargo.lock
generated
561
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
57
EVENTS.md
57
EVENTS.md
|
|
@ -14,13 +14,21 @@ connection. Command verbs are only received while `breadbox listen` is
|
||||||
running — that process holds the `bread.command.box.**` subscription
|
running — that process holds the `bread.command.box.**` subscription
|
||||||
open.
|
open.
|
||||||
|
|
||||||
|
Under an `[launcher] mode = "embedded"` theme (spotlight), breadbar's
|
||||||
|
own bar-drawer capsule *is* the launcher UI, and breadbox redirects
|
||||||
|
instead of mapping its own overlay window — see the "Embedded launcher
|
||||||
|
theme" section below. Everything else on this page describes the
|
||||||
|
default (`overlay`-mode) behavior.
|
||||||
|
|
||||||
## Events published (`bread.box.*`)
|
## Events published (`bread.box.*`)
|
||||||
|
|
||||||
| Event | Data | When |
|
| Event | Data | When |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| `bread.box.launched` | `{ "id": "<desktop id or exec>", "name": "<display name>" }` | The user launched an app (Enter / keypad Enter on the selected row, or activating a row) **and** the spawn succeeded. Not emitted if `Command::spawn` fails (missing terminal, `exec` that cannot start). `id` is the desktop-file id (the `.desktop` filename, e.g. `firefox.desktop`), falling back to the stripped `Exec=` line when that id is empty. `name` is the desktop-entry display name. |
|
| `bread.box.launched` | `{ "id": "<desktop id or exec>", "name": "<display name>" }` | The user launched an app (Enter / keypad Enter on the selected row, or activating a row) **and** the spawn succeeded. Not emitted if `Command::spawn` fails (missing terminal, `exec` that cannot start). `id` is the desktop-file id (the `.desktop` filename, e.g. `firefox.desktop`), falling back to the stripped `Exec=` line when that id is empty. `name` is the desktop-entry display name. |
|
||||||
| `bread.box.open.done` | `{}` | `bread.command.box.open` was received and `breadbox` was spawned. This is the command confirmation, not proof the overlay mapped — the spawned process is the same toggle as a keybind. |
|
| `bread.box.open.done` | `{}` | `bread.command.box.open` was received (via `breadbox listen`) **and this binary was spawned** — non-embedded themes only. Not proof the overlay mapped, just that the process was started (same toggle as a keybind). Never emitted under an embedded theme — see below. |
|
||||||
| `bread.box.open.failed` | `{ "error": "<message>" }` | `bread.command.box.open` was received but this binary could not be started. |
|
| `bread.box.open.failed` | `{ "error": "<message>" }` | `bread.command.box.open` was received but this binary could not be started — non-embedded themes only. Unreachable under an embedded theme, since that branch returns before ever attempting to spawn. |
|
||||||
|
| `bread.box.open_requested` | `{}` | Emitted by the plain `breadbox` binary itself (not `breadbox listen`) instead of mapping its own overlay window, only when the active theme's `[launcher].mode` is `embedded`. breadbar's capsule (`launcher_command.rs`) subscribes to this and opens itself. Own-namespace event, not a command — see `dispatch_embedded_open`'s doc comment for why `bread.command.box.open` would be the wrong shape here. |
|
||||||
|
| `bread.box.open.redirected` | `{}` | Emitted by `breadbox listen`'s `handle_open`, only when `bread.command.box.open` is received while an embedded theme is active. Replaces `.done`/`.failed` in that case: this process never spawns anything (breadbar's capsule is the intended handler) and has no way to confirm breadbar actually received or handled the event — pub/sub here is one-way with no ack. This event means "redirect happened, outcome unknown", not "succeeded". |
|
||||||
|
|
||||||
Launch history is local to breadbox (`~/.cache/breadbox/history.json`);
|
Launch history is local to breadbox (`~/.cache/breadbox/history.json`);
|
||||||
the event bus is a notification that a launch happened, not a channel
|
the event bus is a notification that a launch happened, not a channel
|
||||||
|
|
@ -34,15 +42,21 @@ bread convention, not a breadbox bug.
|
||||||
|
|
||||||
| Verb | Data | Effect |
|
| Verb | Data | Effect |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| `open` | none | Same as running `breadbox` (toggle the launcher overlay via the existing singleton). Emits `bread.box.open.done` / `.failed`. |
|
| `open` | none | Under a non-embedded theme: same as running `breadbox` (toggle the launcher overlay via the existing singleton). Emits `bread.box.open.done` / `.failed`. Under an embedded theme: never spawns; emits `bread.box.open.redirected` instead — see "Embedded launcher theme" below. |
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
|
-- Non-embedded themes: `.done` is a real completion signal.
|
||||||
bread.spawn(function()
|
bread.spawn(function()
|
||||||
bread.emit("bread.command.box.open")
|
bread.emit("bread.command.box.open")
|
||||||
bread.wait("bread.box.open.done", { timeout = 5000 })
|
bread.wait("bread.box.open.done", { timeout = 5000 })
|
||||||
end)
|
end)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Under an embedded theme this `.done` wait will time out — `.done` is
|
||||||
|
never emitted there. A workflow that needs to work under every theme
|
||||||
|
should wait on `bread.box.open.redirected` too (or treat a timeout as
|
||||||
|
"probably fine, embedded themes have no ack" rather than a failure).
|
||||||
|
|
||||||
### Not implemented: extra verbs
|
### Not implemented: extra verbs
|
||||||
|
|
||||||
There is no `launch` / `close` / `query` command verb. Picking a desktop
|
There is no `launch` / `close` / `query` command verb. Picking a desktop
|
||||||
|
|
@ -50,12 +64,44 @@ id from the bus would be a new product surface. If/when that exists, add
|
||||||
the corresponding `bread.command.box.*` verb at the same time, not
|
the corresponding `bread.command.box.*` verb at the same time, not
|
||||||
stubbed as a no-op ahead of it.
|
stubbed as a no-op ahead of it.
|
||||||
|
|
||||||
|
## Embedded launcher theme (spotlight)
|
||||||
|
|
||||||
|
When the active shell theme's `[launcher].mode` is `embedded`, running
|
||||||
|
`breadbox` directly (e.g. from a keybind) does **not** map this binary's
|
||||||
|
overlay window — that would stack a second launcher on top of
|
||||||
|
breadbar's own capsule. Instead:
|
||||||
|
|
||||||
|
1. `breadbox`'s `main` checks `BreadClient::health()` — a real,
|
||||||
|
bounded-timeout round trip to breadd, not just "did the socket
|
||||||
|
exist".
|
||||||
|
2. If breadd **is** reachable, it emits `bread.box.open_requested` and
|
||||||
|
returns. breadbar's capsule (subscribed only while its own active
|
||||||
|
theme is also `embedded`) is expected to open itself in response.
|
||||||
|
There is no ack for this — if breadbar isn't running, or is running
|
||||||
|
under a different theme and therefore never subscribed, this is
|
||||||
|
still a silent no-op on the bus with nothing further to fall back
|
||||||
|
to. (`BreadClient` has no "is anyone subscribed" query.)
|
||||||
|
3. If breadd is **not** reachable, `breadbox` logs that to stderr and
|
||||||
|
falls back to mapping its own overlay window anyway — a keybind
|
||||||
|
press always opens *something*, rather than the historical fully
|
||||||
|
silent no-op when the bus was down.
|
||||||
|
|
||||||
|
`breadbox listen`'s `handle_open` (the `bread.command.box.open`
|
||||||
|
handler) applies the same "never map a second launcher" rule but from
|
||||||
|
the command side: under an embedded theme it never spawns, logs to
|
||||||
|
stderr, and emits `bread.box.open.redirected` instead of `.done` — see
|
||||||
|
the events table above for why `.done` would be a false claim here.
|
||||||
|
|
||||||
## Fail-safe behavior
|
## Fail-safe behavior
|
||||||
|
|
||||||
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
||||||
(`BreadClient::emit` never blocks or errors the caller) and the
|
(`BreadClient::emit` never blocks or errors the caller) and the
|
||||||
command subscription simply never receives anything — launching,
|
command subscription simply never receives anything — launching,
|
||||||
history, theming, and the singleton toggle are entirely unaffected.
|
history, theming, and the singleton toggle are entirely unaffected.
|
||||||
|
Under an embedded theme specifically, a direct `breadbox` invocation
|
||||||
|
additionally checks reachability up front and falls back to mapping
|
||||||
|
its own overlay window when breadd is down (see above) — so this
|
||||||
|
invariant now holds there too, not just for every other theme.
|
||||||
- If breadd restarts, the command subscription reconnects automatically
|
- If breadd restarts, the command subscription reconnects automatically
|
||||||
(`BreadClient::subscribe`'s background thread has its own backoff
|
(`BreadClient::subscribe`'s background thread has its own backoff
|
||||||
loop); no restart of `breadbox listen` is needed.
|
loop); no restart of `breadbox listen` is needed.
|
||||||
|
|
@ -63,3 +109,8 @@ stubbed as a no-op ahead of it.
|
||||||
the bus (no subscriber). The CLI still works, and one-shot invocations
|
the bus (no subscriber). The CLI still works, and one-shot invocations
|
||||||
still emit `bread.box.launched` on their own short-lived connection.
|
still emit `bread.box.launched` on their own short-lived connection.
|
||||||
- Closing the launcher without launching anything emits nothing.
|
- Closing the launcher without launching anything emits nothing.
|
||||||
|
- What is **not** covered: under an embedded theme, if breadd is
|
||||||
|
reachable but breadbar itself isn't running (or isn't subscribed),
|
||||||
|
neither the direct-keybind path nor the command path can detect that
|
||||||
|
or fall back further — both are fire-and-forget with no ack. This is
|
||||||
|
a known limitation of the current transport, not an oversight.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadbox-shared"
|
name = "breadbox-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|
@ -8,3 +8,8 @@ license = "MIT"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
# The desktop-entry/icon-cache/launch-history substance this crate used to
|
||||||
|
# own has moved to bread-ecosystem's bread-launcher (feature/launcher-core)
|
||||||
|
# so breadbar can share it too — see src/lib.rs. No `gtk` feature: this
|
||||||
|
# shim only re-exports the headless half.
|
||||||
|
bread-launcher = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5" }
|
||||||
|
|
|
||||||
|
|
@ -1,264 +1,59 @@
|
||||||
use std::{
|
//! **Compatibility shim.** This crate used to own desktop-entry parsing,
|
||||||
collections::HashMap,
|
//! icon caching, and launch history; that substance now lives in
|
||||||
env,
|
//! `bread-launcher` (`bread-ecosystem`, feature/launcher-core) so breadbar's
|
||||||
fs::{self, File},
|
//! future embedded capsule can share it with breadbox's overlay window —
|
||||||
io::{BufRead, BufReader},
|
//! one launcher implementation, two hosts (see
|
||||||
path::{Path, PathBuf},
|
//! `bos-ui-demos/THEME_SYSTEM_PLAN.md` §3, §7).
|
||||||
};
|
//!
|
||||||
|
//! Everything below is either a direct re-export of `bread_launcher`, or a
|
||||||
|
//! thin wrapper that just bakes in `"breadbox"` as the app name
|
||||||
|
//! `bread_launcher`'s path/cache functions now take explicitly (so more
|
||||||
|
//! than one host can use that crate without colliding on
|
||||||
|
//! `~/.cache/<app>`). This exists purely so `breadbox` and `breadbox-sync`
|
||||||
|
//! keep compiling with minimal churn — plan to remove it once both callers
|
||||||
|
//! depend on `bread-launcher` directly instead.
|
||||||
|
//!
|
||||||
|
//! `Config`/`Context` are NOT part of that move: they're breadbox's own
|
||||||
|
//! per-workspace launch-priority config format, not launcher substance, so
|
||||||
|
//! they stay here.
|
||||||
|
|
||||||
|
use std::{fs, path::PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ---- XDG path helpers -------------------------------------------------------
|
pub use bread_launcher::{
|
||||||
|
app_dirs, home_dir, load_all_desktop_entries, parse_desktop, strip_exec_codes, DesktopEntry,
|
||||||
|
IconCache, LaunchHistory,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn home_dir() -> PathBuf {
|
/// The launcher's shared identity (`bread_launcher::LAUNCHER_APP`, currently
|
||||||
PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into()))
|
/// `"breadbox"`) — NOT a breadbox-specific literal. breadbar's embedded
|
||||||
}
|
/// capsule (theme 04/spotlight) reads/writes this exact same on-disk cache
|
||||||
|
/// and history, so the two surfaces stay one launcher with one ranking
|
||||||
|
/// instead of forking into two (see `LAUNCHER_APP`'s own doc comment).
|
||||||
|
const APP: &str = bread_launcher::LAUNCHER_APP;
|
||||||
|
|
||||||
pub fn cache_dir() -> PathBuf {
|
pub fn cache_dir() -> PathBuf {
|
||||||
env::var("XDG_CACHE_HOME")
|
bread_launcher::cache_dir(APP)
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| home_dir().join(".cache"))
|
|
||||||
.join("breadbox")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn config_dir() -> PathBuf {
|
pub fn config_dir() -> PathBuf {
|
||||||
env::var("XDG_CONFIG_HOME")
|
bread_launcher::config_dir(APP)
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| home_dir().join(".config"))
|
|
||||||
.join("breadbox")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn app_dirs() -> Vec<PathBuf> {
|
pub fn icon_cache() -> IconCache {
|
||||||
let home = home_dir();
|
IconCache::new(APP)
|
||||||
let mut dirs = vec![PathBuf::from("/usr/share/applications")];
|
|
||||||
|
|
||||||
let xdg_data_dirs = env::var("XDG_DATA_DIRS")
|
|
||||||
.unwrap_or_else(|_| "/usr/local/share:/usr/share".into());
|
|
||||||
for d in xdg_data_dirs.split(':') {
|
|
||||||
let p = PathBuf::from(d).join("applications");
|
|
||||||
if p != dirs[0] {
|
|
||||||
dirs.push(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dirs.push(
|
|
||||||
env::var("XDG_DATA_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| home.join(".local/share"))
|
|
||||||
.join("applications"),
|
|
||||||
);
|
|
||||||
dirs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Desktop entry ----------------------------------------------------------
|
pub fn icon_manifest_path() -> PathBuf {
|
||||||
|
IconCache::manifest_path(APP)
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct DesktopEntry {
|
|
||||||
/// Desktop file id (the `.desktop` filename, e.g. `firefox.desktop`).
|
|
||||||
/// Empty only if the path had no file name; callers fall back to `exec`.
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub exec: String,
|
|
||||||
pub icon_name: String,
|
|
||||||
pub icon_path: Option<PathBuf>, // resolved by caller from manifest
|
|
||||||
pub categories: Vec<String>,
|
|
||||||
pub wm_class: Option<String>,
|
|
||||||
pub terminal: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn strip_exec_codes(exec: &str) -> String {
|
pub fn launch_history() -> LaunchHistory {
|
||||||
let mut out = String::with_capacity(exec.len());
|
LaunchHistory::load(APP)
|
||||||
let mut chars = exec.chars().peekable();
|
|
||||||
while let Some(c) = chars.next() {
|
|
||||||
if c == '%' {
|
|
||||||
match chars.peek().copied() {
|
|
||||||
Some('%') => {
|
|
||||||
chars.next();
|
|
||||||
out.push('%');
|
|
||||||
}
|
|
||||||
Some(n) if n.is_ascii_alphabetic() => {
|
|
||||||
chars.next();
|
|
||||||
}
|
|
||||||
_ => out.push(c),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out.push(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `None` for entries that should not be shown (hidden, NoDisplay, non-Application type).
|
// ---- Config (breadbox-specific, not launcher substance) --------------------
|
||||||
pub fn parse_desktop(path: &Path) -> Option<DesktopEntry> {
|
|
||||||
let file = File::open(path).ok()?;
|
|
||||||
let mut in_entry = false;
|
|
||||||
let mut name: Option<String> = None;
|
|
||||||
let mut exec: Option<String> = None;
|
|
||||||
let mut icon: Option<String> = None;
|
|
||||||
let mut categories: Option<String> = None;
|
|
||||||
let mut wm_class: Option<String> = None;
|
|
||||||
let mut app_type: Option<String> = None;
|
|
||||||
let mut no_display = false;
|
|
||||||
let mut hidden = false;
|
|
||||||
let mut terminal = false;
|
|
||||||
|
|
||||||
for line in BufReader::new(file).lines() {
|
|
||||||
let Ok(raw) = line else { continue };
|
|
||||||
let s = raw.trim();
|
|
||||||
if s.starts_with('#') || s.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if s.starts_with('[') {
|
|
||||||
in_entry = s == "[Desktop Entry]";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !in_entry {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(v) = s.strip_prefix("Name=") {
|
|
||||||
name.get_or_insert_with(|| v.to_string());
|
|
||||||
} else if let Some(v) = s.strip_prefix("Exec=") {
|
|
||||||
exec.get_or_insert_with(|| v.to_string());
|
|
||||||
} else if let Some(v) = s.strip_prefix("Icon=") {
|
|
||||||
icon.get_or_insert_with(|| v.to_string());
|
|
||||||
} else if let Some(v) = s.strip_prefix("Categories=") {
|
|
||||||
categories.get_or_insert_with(|| v.to_string());
|
|
||||||
} else if let Some(v) = s.strip_prefix("StartupWMClass=") {
|
|
||||||
wm_class.get_or_insert_with(|| v.to_string());
|
|
||||||
} else if let Some(v) = s.strip_prefix("Type=") {
|
|
||||||
app_type.get_or_insert_with(|| v.to_string());
|
|
||||||
} else if let Some(v) = s.strip_prefix("NoDisplay=") {
|
|
||||||
no_display = v == "true";
|
|
||||||
} else if let Some(v) = s.strip_prefix("Hidden=") {
|
|
||||||
hidden = v == "true";
|
|
||||||
} else if let Some(v) = s.strip_prefix("Terminal=") {
|
|
||||||
terminal = v == "true" || v == "1";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if no_display || hidden {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if app_type.as_deref() != Some("Application") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = name?.trim().to_string();
|
|
||||||
let exec = strip_exec_codes(exec?.trim()).trim().to_string();
|
|
||||||
if name.is_empty() || exec.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let icon_name = icon.unwrap_or_default().trim().to_string();
|
|
||||||
let cats = categories
|
|
||||||
.unwrap_or_default()
|
|
||||||
.split(';')
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let id = path
|
|
||||||
.file_name()
|
|
||||||
.map(|n| n.to_string_lossy().into_owned())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Some(DesktopEntry {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
exec,
|
|
||||||
icon_name,
|
|
||||||
icon_path: None,
|
|
||||||
categories: cats,
|
|
||||||
wm_class: wm_class.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
|
|
||||||
terminal,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walk all configured application directories and return deduplicated entries.
|
|
||||||
/// Entries from later directories (user-local) override those from earlier ones.
|
|
||||||
pub fn load_all_desktop_entries() -> Vec<DesktopEntry> {
|
|
||||||
let mut seen: std::collections::HashMap<String, DesktopEntry> = std::collections::HashMap::new();
|
|
||||||
for dir in app_dirs() {
|
|
||||||
let Ok(entries) = fs::read_dir(&dir) else { continue };
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let key = entry.file_name().to_string_lossy().into_owned();
|
|
||||||
if let Some(app) = parse_desktop(&path) {
|
|
||||||
seen.insert(key, app);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen.into_values().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Icon cache -------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct IconCache {
|
|
||||||
pub dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IconCache {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
IconCache { dir: cache_dir().join("icons") }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn path_for(&self, icon_name: &str) -> PathBuf {
|
|
||||||
self.dir.join(format!("{}.png", icon_name))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn manifest_path() -> PathBuf {
|
|
||||||
cache_dir().join("manifest.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ensure_dir(&self) -> std::io::Result<()> {
|
|
||||||
fs::create_dir_all(&self.dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for IconCache {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Launch history ---------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct LaunchHistory {
|
|
||||||
counts: HashMap<String, u32>,
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LaunchHistory {
|
|
||||||
pub fn load() -> Self {
|
|
||||||
let path = cache_dir().join("history.json");
|
|
||||||
let counts = fs::read_to_string(&path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| serde_json::from_str(&s).ok())
|
|
||||||
.unwrap_or_default();
|
|
||||||
LaunchHistory { counts, path }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn count(&self, name: &str) -> u32 {
|
|
||||||
self.counts.get(name).copied().unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn increment(&mut self, name: &str) {
|
|
||||||
*self.counts.entry(name.to_string()).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save(&self) {
|
|
||||||
if let Ok(json) = serde_json::to_string(&self.counts) {
|
|
||||||
let _ = fs::write(&self.path, json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadbox-sync"
|
name = "breadbox-sync"
|
||||||
version = "0.3.1"
|
version = "0.3.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ fn main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let icon_cache = IconCache::new();
|
let icon_cache = breadbox_shared::icon_cache();
|
||||||
icon_cache.ensure_dir()?;
|
icon_cache.ensure_dir()?;
|
||||||
|
|
||||||
let theme = current_icon_theme();
|
let theme = current_icon_theme();
|
||||||
|
|
@ -266,7 +266,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let manifest_path = IconCache::manifest_path();
|
let manifest_path = breadbox_shared::icon_manifest_path();
|
||||||
let json = serde_json::to_string_pretty(&manifest)?;
|
let json = serde_json::to_string_pretty(&manifest)?;
|
||||||
let tmp = manifest_path.with_extension("tmp");
|
let tmp = manifest_path.with_extension("tmp");
|
||||||
fs::write(&tmp, &json)?;
|
fs::write(&tmp, &json)?;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadbox"
|
name = "breadbox"
|
||||||
version = "0.3.1"
|
version = "0.3.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|
@ -9,10 +9,13 @@ name = "breadbox"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] }
|
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["gtk"] }
|
||||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["bread-client", "gtk"] }
|
||||||
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
|
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
|
||||||
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5" }
|
||||||
|
# Matching/ranking/launching plus the results-list widget (see src/main.rs
|
||||||
|
# run_ui) — the same core breadbar's embedded capsule will use next phase.
|
||||||
|
bread-launcher = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["gtk"] }
|
||||||
breadbox-shared = { path = "../breadbox-shared" }
|
breadbox-shared = { path = "../breadbox-shared" }
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||||
gtk4-layer-shell = "0.8"
|
gtk4-layer-shell = "0.8"
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,48 @@ fn handle_command(event: &BreadEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_open() {
|
fn handle_open() {
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
|
||||||
|
// Under an embedded-launcher theme (spotlight), breadbar's own capsule
|
||||||
|
// subscribes to both this command and `bread.box.open_requested` (see
|
||||||
|
// `main.rs`'s `dispatch_embedded_open` doc comment) — it, not this
|
||||||
|
// process, is the thing that should react. Spawning `breadbox` here
|
||||||
|
// would just re-run that same binary's own embedded-mode redirect
|
||||||
|
// (`main`'s `dispatch_embedded_open`), emitting
|
||||||
|
// `bread.box.open_requested` back onto the bus; breadbar would open the
|
||||||
|
// capsule again and this still-running subscription would keep spawning
|
||||||
|
// on every command it sees. So this branch never spawns — that part is
|
||||||
|
// correct and stays.
|
||||||
|
//
|
||||||
|
// It used to also emit `bread.box.open.done` here unconditionally, which
|
||||||
|
// was a false-positive success report: `.done`'s documented meaning
|
||||||
|
// (EVENTS.md) is "breadbox was spawned", which is literally untrue in
|
||||||
|
// this branch, and there is no ack from breadbar on this one-way
|
||||||
|
// pub/sub bus — `BreadClient` has no way to ask "did anything actually
|
||||||
|
// pick this up" (same limitation `main.rs`'s `dispatch_embedded_open`
|
||||||
|
// documents). Claiming `.done` reported a completion this process
|
||||||
|
// cannot observe — the same shape of bug as bug #6 (a namespace
|
||||||
|
// violation making a false claim), just an over-eager "done" instead of
|
||||||
|
// an under-eager warning. Since confirming the handoff isn't possible
|
||||||
|
// with the current transport, this does the honest thing instead: log
|
||||||
|
// locally (so the failure mode is diagnosable from `breadbox listen`'s
|
||||||
|
// own output) and emit a distinct, explicitly-unconfirmed event so a bus
|
||||||
|
// observer can tell "redirected, outcome unknown" apart from "breadbox
|
||||||
|
// spawned" rather than being told a specific untrue thing.
|
||||||
|
if crate::theme::shell_theme().launcher().mode == bread_theme::shell::LauncherMode::Embedded {
|
||||||
|
eprintln!(
|
||||||
|
"breadbox: bread.command.box.open received under an embedded launcher \
|
||||||
|
theme; breadbar's capsule is the intended handler and this process \
|
||||||
|
cannot confirm it received the event"
|
||||||
|
);
|
||||||
|
client.emit("bread.box.open.redirected", serde_json::json!({}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Same as running `breadbox` from a keybind: toggle the overlay via the
|
// Same as running `breadbox` from a keybind: toggle the overlay via the
|
||||||
// existing singleton. Spawn success is the command confirmation — we do
|
// existing singleton. Spawn success is the command confirmation — we do
|
||||||
// not wait for the GTK window to map.
|
// not wait for the GTK window to map.
|
||||||
let result = spawn_self();
|
let result = spawn_self();
|
||||||
let client = BreadClient::connect(APP_ID);
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => client.emit("bread.box.open.done", serde_json::json!({})),
|
Ok(_) => client.emit("bread.box.open.done", serde_json::json!({})),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
|
use bread_launcher::gtk::{row_entry, ResultsList};
|
||||||
use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette};
|
use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette};
|
||||||
use bread_utils::bread_client::BreadClient;
|
|
||||||
use std::{
|
use std::{
|
||||||
cell::RefCell,
|
cell::RefCell,
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
env,
|
env, fs,
|
||||||
fs,
|
|
||||||
io::{Read, Write},
|
io::{Read, Write},
|
||||||
os::unix::net::UnixStream,
|
os::unix::net::UnixStream,
|
||||||
path::{Path, PathBuf},
|
path::PathBuf,
|
||||||
process::{Command, Stdio},
|
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -16,20 +14,24 @@ use std::{
|
||||||
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.box.*`.
|
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.box.*`.
|
||||||
const APP_ID: &str = "box";
|
const APP_ID: &str = "box";
|
||||||
|
|
||||||
use breadbox_shared::{
|
/// Emitted instead of mapping breadbox's own window when the active shell
|
||||||
config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, LaunchHistory,
|
/// theme's launcher is embedded (theme 04's capsule). Must stay inside
|
||||||
};
|
/// `bread.box.*` — see [`dispatch_embedded_open`] for why.
|
||||||
|
pub const EMBEDDED_OPEN_EVENT: &str = "bread.box.open_requested";
|
||||||
|
|
||||||
|
/// Published via `bread_launcher::do_launch` after a successful launch —
|
||||||
|
/// see `EVENTS.md`.
|
||||||
|
const LAUNCHED_EVENT: &str = "bread.box.launched";
|
||||||
|
|
||||||
|
use breadbox_shared::{config_dir, Config, DesktopEntry, LaunchHistory};
|
||||||
use gtk4::{
|
use gtk4::{
|
||||||
glib,
|
glib, prelude::*, Application, Box as GBox, CssProvider,
|
||||||
pango::EllipsizeMode,
|
EventControllerKey, Label, Orientation, SearchEntry,
|
||||||
prelude::*,
|
|
||||||
Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label,
|
|
||||||
ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode,
|
|
||||||
};
|
};
|
||||||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
|
||||||
|
|
||||||
mod listen;
|
mod listen;
|
||||||
mod screenshot;
|
mod screenshot;
|
||||||
|
mod theme;
|
||||||
|
|
||||||
// ---- Hyprland IPC -----------------------------------------------------------
|
// ---- Hyprland IPC -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -52,7 +54,7 @@ fn get_active_workspace() -> Option<String> {
|
||||||
// ---- Manifest ---------------------------------------------------------------
|
// ---- Manifest ---------------------------------------------------------------
|
||||||
|
|
||||||
fn load_manifest() -> HashMap<String, PathBuf> {
|
fn load_manifest() -> HashMap<String, PathBuf> {
|
||||||
let path = IconCache::manifest_path();
|
let path = breadbox_shared::icon_manifest_path();
|
||||||
let content = fs::read_to_string(&path).unwrap_or_default();
|
let content = fs::read_to_string(&path).unwrap_or_default();
|
||||||
serde_json::from_str::<HashMap<String, String>>(&content)
|
serde_json::from_str::<HashMap<String, String>>(&content)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
|
|
@ -61,232 +63,154 @@ fn load_manifest() -> HashMap<String, PathBuf> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Entry loading and sorting ----------------------------------------------
|
|
||||||
|
|
||||||
fn load_sorted_entries(
|
|
||||||
manifest: &HashMap<String, PathBuf>,
|
|
||||||
priority: &[String],
|
|
||||||
history: &LaunchHistory,
|
|
||||||
) -> Vec<DesktopEntry> {
|
|
||||||
let mut entries = load_all_desktop_entries();
|
|
||||||
|
|
||||||
// Populate icon_path from manifest
|
|
||||||
for entry in &mut entries {
|
|
||||||
if let Some(path) = manifest.get(&entry.icon_name) {
|
|
||||||
if path.exists() {
|
|
||||||
entry.icon_path = Some(path.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let priority_lower: Vec<String> = priority.iter().map(|s| s.to_lowercase()).collect();
|
|
||||||
|
|
||||||
entries.sort_by(|a, b| {
|
|
||||||
let ai = priority_rank(a, &priority_lower);
|
|
||||||
let bi = priority_rank(b, &priority_lower);
|
|
||||||
match (ai, bi) {
|
|
||||||
(Some(i), Some(j)) => i.cmp(&j),
|
|
||||||
(Some(_), None) => std::cmp::Ordering::Less,
|
|
||||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
||||||
(None, None) => {
|
|
||||||
// Most-launched first, then alphabetical
|
|
||||||
history.count(&b.name).cmp(&history.count(&a.name))
|
|
||||||
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
entries
|
|
||||||
}
|
|
||||||
|
|
||||||
fn priority_rank(entry: &DesktopEntry, priority_lower: &[String]) -> Option<usize> {
|
|
||||||
let name_l = entry.name.to_lowercase();
|
|
||||||
let wm_l = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
|
|
||||||
priority_lower
|
|
||||||
.iter()
|
|
||||||
.position(|p| matches_term(&name_l, p) || matches_term(&wm_l, p))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whole-word / exact match of `term` within `field` (both lowercase). Avoids
|
|
||||||
/// "code" matching "vscodium" while still matching "Code", "code-oss", and
|
|
||||||
/// "Visual Studio Code".
|
|
||||||
fn matches_term(field: &str, term: &str) -> bool {
|
|
||||||
if term.is_empty() || field.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if field == term {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let bytes = field.as_bytes();
|
|
||||||
let tlen = term.len();
|
|
||||||
let mut start = 0;
|
|
||||||
while let Some(pos) = field[start..].find(term) {
|
|
||||||
let i = start + pos;
|
|
||||||
let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
|
|
||||||
let after = i + tlen;
|
|
||||||
let after_ok = after >= bytes.len() || !bytes[after].is_ascii_alphanumeric();
|
|
||||||
if before_ok && after_ok {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
start = i + 1;
|
|
||||||
if start >= field.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Theming ----------------------------------------------------------------
|
// ---- Theming ----------------------------------------------------------------
|
||||||
|
|
||||||
fn build_css(p: &Palette) -> String {
|
fn build_css(
|
||||||
let bg_panel = hex_to_rgba(&p.background, 0.60);
|
p: &Palette,
|
||||||
// breadbox-specific rules only — fonts, palette, and generic widgets come
|
launcher: &bread_theme::shell::Launcher,
|
||||||
// from the shared ecosystem stylesheet (applied first in connect_activate).
|
tokens: &bread_theme::shell::Tokens,
|
||||||
// Colour is set on each surface (panel, search box, hovered/selected row) so
|
) -> String {
|
||||||
// child labels inherit the legible ink for that background. `on_*` are
|
// Panel opacity comes from the theme, not a hardcoded constant. 0.60 made
|
||||||
// luminance-picked black/white — the pywal hues are untouched. Without this a
|
// the launcher wash out over a bright wallpaper and its text hard to read —
|
||||||
// light `surface` slot makes the selected row's text vanish.
|
// a bar can be that translucent because it covers a thin strip, a full
|
||||||
|
// panel cannot. The approved reference uses 0.95/0.93 per theme.
|
||||||
|
let bg_panel = hex_to_rgba(&p.background, launcher.panel_alpha as f32);
|
||||||
|
let radius = format!("{}px", launcher.radius);
|
||||||
|
let on_bg = ink_on(&p.background);
|
||||||
|
// breadbox-specific rules only — the generic widget baseline (buttons,
|
||||||
|
// switches, plain `entry`/`spinbutton`) comes from the shared ecosystem
|
||||||
|
// stylesheet, applied first (lower priority) in connect_activate via
|
||||||
|
// `apply_shared`. Colour is set on each surface (panel, search box,
|
||||||
|
// hovered/selected row) so child labels inherit the legible ink for that
|
||||||
|
// background. `on_*` are luminance-picked black/white — the pywal hues
|
||||||
|
// are untouched. Without this a light `surface` slot makes the selected
|
||||||
|
// row's text vanish.
|
||||||
|
//
|
||||||
|
// Selectors matter here: GTK4's `GtkSearchEntry` CSS node is `entry`
|
||||||
|
// carrying a `.search` style class (confirmed against
|
||||||
|
// `/usr/share/gir-1.0/Gtk-4.0.gir`'s `SearchEntry` "## CSS Nodes" doc,
|
||||||
|
// `entry.search ╰── text`) — NOT a node literally named `searchentry`,
|
||||||
|
// and `GtkListBox`'s node is `list`, NOT `listbox`. Both bogus selectors
|
||||||
|
// shipped here previously and silently matched nothing, which is why
|
||||||
|
// this rule block's `border`/`outline` suppression never actually beat
|
||||||
|
// the shared stylesheet's `entry:focus-within { border-color: @accent }`
|
||||||
|
// rule (`bread_theme::stylesheet`, still layered in underneath at
|
||||||
|
// APPLICATION priority) — the accent-coloured focus "outline" the user
|
||||||
|
// was seeing was that shared rule showing through unopposed. `entry`
|
||||||
|
// alone (no `.search`) would work too since this window has only one
|
||||||
|
// entry, but `.search` documents which GtkSearchEntry state this targets
|
||||||
|
// and is what actually renders.
|
||||||
|
//
|
||||||
|
// Per-theme geometry now comes entirely from `[launcher]`/`[tokens]` —
|
||||||
|
// liquid-motion and glass-workbench set every one of these to different
|
||||||
|
// values (see their `assets/shell/*/theme.toml`), which is what makes
|
||||||
|
// the two themes read as different instruments rather than one
|
||||||
|
// launcher recoloured.
|
||||||
format!(
|
format!(
|
||||||
"window {{ background-color: transparent; }}\
|
"window {{ background-color: transparent; }}\
|
||||||
.launcher-bg {{ background-color: {bg_panel}; color: {on_bg}; border-radius: 8px;\
|
.launcher-bg {{ background-color: {bg_panel}; color: {on_bg}; border-radius: {radius};\
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\
|
/* NO drop shadow. In a browser `backdrop-filter` blurs only the\
|
||||||
searchentry {{ background-color: {surface}; color: {on_surface}; caret-color: {accent};\
|
element's own box, so the demo's shadow is free. Hyprland\
|
||||||
border: none; outline: none; box-shadow: none;\
|
blurs any surface pixel above `ignore_alpha` (0.2), and a\
|
||||||
padding: 12px 16px; border-radius: 6px 6px 0 0; }}\
|
32px shadow at 0.6 alpha is far above it — so the compositor\
|
||||||
listbox {{ background-color: transparent; padding: 4px; }}\
|
blurred the shadow as well, painting a rectangular blurred\
|
||||||
row {{ padding: 8px 12px; color: {on_bg}; background-color: transparent;\
|
halo around the rounded panel. That halo is the \"blur\
|
||||||
border-radius: 6px; }}\
|
backdrop whose radius doesn't match the content\" problem.\
|
||||||
row:hover {{ background-color: {surface}; color: {on_surface}; }}\
|
The blur behind a 0.95-opaque panel already gives plenty of\
|
||||||
row:selected {{ background-color: {surface}; color: {on_surface}; }}\
|
separation; the shadow only fought it. */\
|
||||||
.app-name {{ font-size: 14px; }}\
|
font-family: \"{font_family}\", {font_fallback}; }}\
|
||||||
.app-muted {{ opacity: 0.6; font-size: 12px; }}\
|
entry.search {{ background-color: transparent; color: {on_bg}; caret-color: {accent};\
|
||||||
image {{ margin-right: 8px; }}",
|
border: none; outline: none; box-shadow: none; border-radius: 0;\
|
||||||
bg_panel = bg_panel,
|
border-bottom: 1px solid {hairline};\
|
||||||
surface = p.color0,
|
padding: {search_pv}px {search_ph}px; font-size: {search_fs}px; }}\
|
||||||
accent = p.color4,
|
entry.search:focus, entry.search:focus-within {{\
|
||||||
on_bg = ink_on(&p.background),
|
outline: none; box-shadow: none; border-color: transparent;\
|
||||||
on_surface = ink_on(&p.color0),
|
border-bottom: 1px solid {hairline}; }}\
|
||||||
|
list {{ background-color: transparent; padding: 4px 0; }}\
|
||||||
|
row {{ padding: {row_pv}px {row_ph}px; margin: 0 {row_inset}px; color: {on_bg};\
|
||||||
|
background-color: transparent; border-radius: {row_radius}px;\
|
||||||
|
font-size: {row_fs}px; }}\
|
||||||
|
row:hover, row:selected {{ background-color: {selection_bg}; color: {on_bg}; }}\
|
||||||
|
.app-muted {{ opacity: 0.4; font-size: 11px; }}\
|
||||||
|
/* No background tile: these are real app icons, not the demo's\
|
||||||
|
placeholder .ico boxes. A tint behind a real icon reads as a\
|
||||||
|
failed/unloaded image. */\
|
||||||
|
image {{ margin-right: 8px; border-radius: {icon_radius}px; }}\
|
||||||
|
/* \"Recent\"/\"Apps\" headers (`[launcher].sections`, liquid-motion\
|
||||||
|
only — glass-workbench's flat list never builds these rows). */\
|
||||||
|
.section-header-label {{ font-size: 10px; letter-spacing: 0.14em;\
|
||||||
|
text-transform: uppercase; font-weight: 600; opacity: 0.45; }}\
|
||||||
|
.bread-drawer-section-header {{ padding: 11px 16px 5px; }}\
|
||||||
|
.launcher-footer {{ padding: 8px 16px 12px; font-size: 10px; opacity: 0.4;\
|
||||||
|
{footer_case} }}",
|
||||||
|
bg_panel = bg_panel,
|
||||||
|
accent = hex_to_rgba(&p.color4, 0.9),
|
||||||
|
on_bg = on_bg,
|
||||||
|
hairline = hex_to_rgba(on_bg, 0.08),
|
||||||
|
selection_bg = hex_to_rgba(&p.color4, launcher.selection_alpha as f32),
|
||||||
|
radius = radius,
|
||||||
|
font_family = tokens.font_family(),
|
||||||
|
font_fallback = tokens.font_fallback(),
|
||||||
|
row_fs = tokens.font_size_base(),
|
||||||
|
row_pv = launcher.row_padding_v,
|
||||||
|
row_ph = launcher.row_padding_h,
|
||||||
|
row_inset = launcher.row_inset,
|
||||||
|
row_radius = launcher.row_radius,
|
||||||
|
icon_radius = launcher.icon_radius,
|
||||||
|
search_pv = launcher.search_padding_v,
|
||||||
|
search_ph = launcher.search_padding_h,
|
||||||
|
search_fs = launcher.search_font_size,
|
||||||
|
// Section headers and an uppercase, letter-spaced footer travel
|
||||||
|
// together in both demos (liquid-motion has both; glass-workbench's
|
||||||
|
// flat list has neither) — reusing `sections` here instead of a
|
||||||
|
// dedicated schema key for this one footer detail.
|
||||||
|
footer_case = if launcher.sections {
|
||||||
|
"text-transform: uppercase; letter-spacing: 0.12em;"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Icon loading -----------------------------------------------------------
|
/// `[launcher].footer`'s noun: `"count_apps"` → "applications", anything
|
||||||
|
/// else (`"count_results"` included — every other current/future value
|
||||||
fn make_icon(icon_name: &str, icon_path: Option<&Path>) -> gtk4::Image {
|
/// falls back to this rather than a hard error, matching the "never fails
|
||||||
// Try loading from resolved cached path via gio::File
|
/// to start over a theme string" rule the rest of this schema follows) →
|
||||||
if let Some(path) = icon_path {
|
/// "results". `n`'s plural/singular form is picked here rather than baked
|
||||||
let gio_file = gtk4::gio::File::for_path(path);
|
/// into `[launcher].footer` itself, since neither built-in theme's value is
|
||||||
if let Ok(texture) = gtk4::gdk::Texture::from_file(&gio_file) {
|
/// singular-aware.
|
||||||
let img = gtk4::Image::new();
|
fn footer_text(footer_kind: &str, n: usize) -> String {
|
||||||
img.set_paintable(Some(&texture));
|
let noun = if footer_kind == "count_apps" {
|
||||||
img.set_pixel_size(32);
|
"application"
|
||||||
return img;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fall back to GTK icon theme lookup by name
|
|
||||||
let name = if icon_name.is_empty() {
|
|
||||||
"application-x-executable"
|
|
||||||
} else {
|
} else {
|
||||||
icon_name
|
"result"
|
||||||
};
|
};
|
||||||
let img = gtk4::Image::from_icon_name(name);
|
if n == 1 {
|
||||||
img.set_pixel_size(32);
|
format!("1 {noun}")
|
||||||
img
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Launch -----------------------------------------------------------------
|
|
||||||
|
|
||||||
fn pick_terminal() -> String {
|
|
||||||
if let Ok(t) = env::var("TERMINAL") {
|
|
||||||
if !t.is_empty() {
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let path_var = env::var("PATH").unwrap_or_default();
|
|
||||||
for t in ["foot", "kitty", "alacritty", "wezterm", "ghostty", "xterm"] {
|
|
||||||
if path_var.split(':').any(|d| Path::new(d).join(t).exists()) {
|
|
||||||
return t.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"xterm".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn do_launch(entry: &DesktopEntry) {
|
|
||||||
let cmd = entry.exec.trim();
|
|
||||||
let spawned = if entry.terminal {
|
|
||||||
let term = pick_terminal();
|
|
||||||
Command::new(&term)
|
|
||||||
.args(["-e", "bash", "-c", cmd])
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
} else {
|
} else {
|
||||||
Command::new("bash")
|
format!("{n} {noun}s")
|
||||||
.args(["-c", cmd])
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
};
|
|
||||||
if spawned.is_ok() {
|
|
||||||
emit_launched(entry);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes `bread.box.launched` after a successful spawn. Fire-and-forget
|
/// Counts the rows currently shown as real (non-header) app matches — the
|
||||||
/// and non-fatal (`BreadClient::emit` never blocks or errors this caller) —
|
/// same "visible AND carries a `DesktopEntry`" test `ResultsList` itself
|
||||||
/// breadd being absent must never affect launching itself.
|
/// uses for keyboard selection (`select_next`/`select_prev` in
|
||||||
fn emit_launched(entry: &DesktopEntry) {
|
/// `bread_launcher::gtk`), reimplemented here read-only since `ResultsList`
|
||||||
let id = if entry.id.is_empty() {
|
/// doesn't expose a count of its own.
|
||||||
entry.exec.as_str()
|
fn visible_app_count(list: >k4::ListBox) -> usize {
|
||||||
} else {
|
let mut i = 0i32;
|
||||||
entry.id.as_str()
|
let mut n = 0usize;
|
||||||
};
|
while let Some(row) = list.row_at_index(i) {
|
||||||
BreadClient::connect(APP_ID).emit(
|
if row.is_visible() && bread_launcher::gtk::row_entry(&row).is_some() {
|
||||||
"bread.box.launched",
|
n += 1;
|
||||||
serde_json::json!({ "id": id, "name": entry.name }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Fuzzy matching ---------------------------------------------------------
|
|
||||||
|
|
||||||
fn fuzzy_matches(pattern: &str, text: &str) -> bool {
|
|
||||||
if pattern.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let mut chars = text.chars();
|
|
||||||
for pc in pattern.chars() {
|
|
||||||
let pl = pc.to_lowercase().next().unwrap_or(pc);
|
|
||||||
if !chars
|
|
||||||
.by_ref()
|
|
||||||
.any(|tc| tc.to_lowercase().next().unwrap_or(tc) == pl)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
i += 1;
|
||||||
}
|
}
|
||||||
true
|
n
|
||||||
}
|
|
||||||
|
|
||||||
fn fuzzy_score(query: &str, entry: &DesktopEntry) -> u32 {
|
|
||||||
let q = query.to_lowercase();
|
|
||||||
let name = entry.name.to_lowercase();
|
|
||||||
let wm = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
|
|
||||||
if name == q || wm == q { return 0; }
|
|
||||||
if name.starts_with(&q) { return 1; }
|
|
||||||
if name.contains(&q) { return 2; }
|
|
||||||
if wm.starts_with(&q) || wm.contains(&q) { return 3; }
|
|
||||||
4 // subsequence match
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- UI ---------------------------------------------------------------------
|
// ---- UI ---------------------------------------------------------------------
|
||||||
|
|
||||||
fn get_row_entry(row: >k4::ListBoxRow) -> Option<DesktopEntry> {
|
|
||||||
unsafe {
|
|
||||||
row.data::<DesktopEntry>("entry")
|
|
||||||
.map(|p| p.as_ref().clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_ui(
|
fn run_ui(
|
||||||
entries: Vec<DesktopEntry>,
|
entries: Vec<DesktopEntry>,
|
||||||
history: LaunchHistory,
|
history: LaunchHistory,
|
||||||
|
|
@ -303,15 +227,24 @@ fn run_ui(
|
||||||
let app = builder.build();
|
let app = builder.build();
|
||||||
|
|
||||||
let history_rc = Rc::new(RefCell::new(history));
|
let history_rc = Rc::new(RefCell::new(history));
|
||||||
let query_rc: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
|
|
||||||
let is_screenshot_run = screenshot_req.is_some();
|
let is_screenshot_run = screenshot_req.is_some();
|
||||||
|
|
||||||
app.connect_activate(move |app| {
|
app.connect_activate(move |app| {
|
||||||
|
// Shell theme, loaded once per process and cached (see src/theme.rs)
|
||||||
|
// — the source of the launcher's geometry and style values below,
|
||||||
|
// per THEME_SYSTEM_PLAN.md §4/§11 Phase 4.
|
||||||
|
let shell_theme = theme::shell_theme();
|
||||||
|
let launcher = shell_theme.launcher().clone();
|
||||||
|
|
||||||
// Shared ecosystem base (fonts, palette, generic widgets) first, then
|
// Shared ecosystem base (fonts, palette, generic widgets) first, then
|
||||||
// breadbox-specific CSS layered on top — both hot-reload on
|
// breadbox-specific CSS layered on top — both hot-reload on
|
||||||
// `bread-theme reload` (the closure re-reads the pywal palette).
|
// `bread-theme reload` (the closure re-reads the pywal palette).
|
||||||
bread_theme::gtk::apply_shared();
|
bread_theme::gtk::apply_shared();
|
||||||
bread_theme::gtk::apply_app_css(|| build_css(&load_palette()));
|
{
|
||||||
|
let launcher = launcher.clone();
|
||||||
|
let tokens = shell_theme.tokens().clone();
|
||||||
|
bread_theme::gtk::apply_app_css(move || build_css(&load_palette(), &launcher, &tokens));
|
||||||
|
}
|
||||||
|
|
||||||
// User CSS override
|
// User CSS override
|
||||||
{
|
{
|
||||||
|
|
@ -320,16 +253,9 @@ fn run_ui(
|
||||||
bread_theme::gtk::apply_user_css(&user_css_path, &user_cell);
|
bread_theme::gtk::apply_user_css(&user_css_path, &user_cell);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full-screen transparent window; clicks outside the launcher panel close it.
|
// Full-screen transparent overlay; panel widget is positioned inside it.
|
||||||
let window = ApplicationWindow::builder().application(app).build();
|
let window = bread_utils::gtk_popup::new_overlay_window(app, "breadbox");
|
||||||
window.init_layer_shell();
|
bread_theme::gtk::bind_window_auto(&window);
|
||||||
window.set_namespace(Some("breadbox"));
|
|
||||||
window.set_layer(Layer::Overlay);
|
|
||||||
window.set_keyboard_mode(KeyboardMode::Exclusive);
|
|
||||||
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
|
|
||||||
window.set_anchor(edge, true);
|
|
||||||
}
|
|
||||||
window.set_exclusive_zone(0);
|
|
||||||
|
|
||||||
let close_all: Rc<dyn Fn()> = Rc::new({
|
let close_all: Rc<dyn Fn()> = Rc::new({
|
||||||
let w = window.clone();
|
let w = window.clone();
|
||||||
|
|
@ -342,118 +268,69 @@ fn run_ui(
|
||||||
vbox.add_css_class("launcher-bg");
|
vbox.add_css_class("launcher-bg");
|
||||||
vbox.set_halign(gtk4::Align::Center);
|
vbox.set_halign(gtk4::Align::Center);
|
||||||
vbox.set_valign(gtk4::Align::Start);
|
vbox.set_valign(gtk4::Align::Start);
|
||||||
vbox.set_margin_top(120);
|
vbox.set_margin_top(theme::top_margin_px(&launcher.top));
|
||||||
vbox.set_size_request(600, -1);
|
// `set_size_request` only pins a MINIMUM, not a maximum — the same
|
||||||
|
// class of bug that made breadbar's capsule stretch to its widest
|
||||||
|
// row (`surface.rs`/`main.rs`'s `set_size_request` pin comment).
|
||||||
|
// This is safe here because `results.scroller` below is the
|
||||||
|
// `bread_launcher::gtk::ResultsList` widget, which already calls
|
||||||
|
// `scroller.set_propagate_natural_width(false)` specifically so its
|
||||||
|
// `ListBox`'s widest row (icon + full app name + wm-class) can never
|
||||||
|
// push a host wider than what the host requests — see that widget's
|
||||||
|
// own comment in `bread-launcher/src/gtk.rs`. `search` (a plain
|
||||||
|
// `SearchEntry`) has no unbounded natural width either. So nothing
|
||||||
|
// under `vbox` can grow past `launcher.width`, and this minimum-only
|
||||||
|
// request is effectively exact.
|
||||||
|
vbox.set_size_request(launcher.width, -1);
|
||||||
|
|
||||||
let search = SearchEntry::new();
|
let search = SearchEntry::new();
|
||||||
search.set_placeholder_text(Some("breadbox"));
|
// "Search", not the app's own name — matches both design demos'
|
||||||
|
// `<input placeholder="Search">` (bos-ui-demos/proposed/
|
||||||
|
// {liquid-motion,glass-workbench}.html); no user-visible copy here
|
||||||
|
// should read like an internal identifier.
|
||||||
|
search.set_placeholder_text(Some("Search"));
|
||||||
vbox.append(&search);
|
vbox.append(&search);
|
||||||
|
|
||||||
let scroll = ScrolledWindow::new();
|
// Row building, fuzzy filtering, match/history sorting, and
|
||||||
scroll.set_policy(PolicyType::Never, PolicyType::Automatic);
|
// keyboard-style selection movement all live in bread-launcher's
|
||||||
scroll.set_max_content_height(480);
|
// `ResultsList` — the widget breadbar's embedded capsule also uses
|
||||||
scroll.set_propagate_natural_height(true);
|
// (THEME_SYSTEM_PLAN.md §7). `[launcher].sections`: liquid-motion
|
||||||
|
// groups the idle view into "Recent"/"Apps" headers, glass-workbench
|
||||||
|
// stays the flat, ungrouped list — no longer hardcoded to `false`.
|
||||||
|
let results = ResultsList::new(
|
||||||
|
&entries,
|
||||||
|
launcher.icon_px,
|
||||||
|
Rc::clone(&history_rc),
|
||||||
|
launcher.sections,
|
||||||
|
);
|
||||||
|
|
||||||
let list = ListBox::new();
|
vbox.append(&results.scroller);
|
||||||
list.set_selection_mode(SelectionMode::Browse);
|
|
||||||
|
|
||||||
for (idx, entry) in entries.iter().enumerate() {
|
// Footer: "N applications" (liquid-motion) / "N results"
|
||||||
let row = gtk4::ListBoxRow::new();
|
// (glass-workbench) — `[launcher].footer` selects the noun (see
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 0);
|
// `footer_text`), updated alongside the query on every keystroke.
|
||||||
hbox.set_margin_start(6);
|
let footer = Label::new(None);
|
||||||
hbox.set_margin_end(6);
|
footer.add_css_class("launcher-footer");
|
||||||
hbox.set_valign(gtk4::Align::Center);
|
footer.set_xalign(0.0);
|
||||||
|
footer.set_label(&footer_text(&launcher.footer, visible_app_count(&results.list)));
|
||||||
|
vbox.append(&footer);
|
||||||
|
|
||||||
let icon = make_icon(&entry.icon_name, entry.icon_path.as_deref());
|
|
||||||
hbox.append(&icon);
|
|
||||||
|
|
||||||
let name_lbl = Label::new(Some(&entry.name));
|
|
||||||
name_lbl.add_css_class("app-name");
|
|
||||||
name_lbl.set_xalign(0.0);
|
|
||||||
name_lbl.set_hexpand(true);
|
|
||||||
name_lbl.set_ellipsize(EllipsizeMode::End);
|
|
||||||
hbox.append(&name_lbl);
|
|
||||||
|
|
||||||
if let Some(ref wm) = entry.wm_class {
|
|
||||||
let wm_lbl = Label::new(Some(wm));
|
|
||||||
wm_lbl.add_css_class("app-muted");
|
|
||||||
wm_lbl.set_xalign(1.0);
|
|
||||||
hbox.append(&wm_lbl);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.set_child(Some(&hbox));
|
|
||||||
unsafe { row.set_data("entry", entry.clone()) };
|
|
||||||
unsafe { row.set_data("initial_order", idx as u32) };
|
|
||||||
list.append(&row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by match quality + launch count when a query is active;
|
|
||||||
// fall back to insertion order (priority + launch frequency) when empty.
|
|
||||||
let sort_query = Rc::clone(&query_rc);
|
|
||||||
let sort_history = Rc::clone(&history_rc);
|
|
||||||
list.set_sort_func(move |row_a, row_b| {
|
|
||||||
let query = sort_query.borrow();
|
|
||||||
if query.is_empty() {
|
|
||||||
let oa = unsafe { row_a.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref()) };
|
|
||||||
let ob = unsafe { row_b.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref()) };
|
|
||||||
return oa.cmp(&ob).into();
|
|
||||||
}
|
|
||||||
let (Some(ea), Some(eb)) = (get_row_entry(row_a), get_row_entry(row_b)) else {
|
|
||||||
return std::cmp::Ordering::Equal.into();
|
|
||||||
};
|
|
||||||
let sa = fuzzy_score(&query, &ea);
|
|
||||||
let sb = fuzzy_score(&query, &eb);
|
|
||||||
let history = sort_history.borrow();
|
|
||||||
let ca = history.count(&ea.name);
|
|
||||||
let cb = history.count(&eb.name);
|
|
||||||
sa.cmp(&sb)
|
|
||||||
.then(cb.cmp(&ca))
|
|
||||||
.then(ea.name.to_lowercase().cmp(&eb.name.to_lowercase()))
|
|
||||||
.into()
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(first) = list.row_at_index(0) {
|
|
||||||
list.select_row(Some(&first));
|
|
||||||
}
|
|
||||||
|
|
||||||
scroll.set_child(Some(&list));
|
|
||||||
vbox.append(&scroll);
|
|
||||||
window.set_child(Some(&vbox));
|
window.set_child(Some(&vbox));
|
||||||
|
|
||||||
// Filter on keystroke
|
// Filter on keystroke
|
||||||
let list_f = list.clone();
|
let results_f = results.clone();
|
||||||
let filter_query = Rc::clone(&query_rc);
|
let footer_f = footer.clone();
|
||||||
|
let footer_kind = launcher.footer.clone();
|
||||||
search.connect_changed(move |entry| {
|
search.connect_changed(move |entry| {
|
||||||
let text = entry.text();
|
results_f.set_query(entry.text().as_str());
|
||||||
let query = text.as_str();
|
footer_f.set_label(&footer_text(&footer_kind, visible_app_count(&results_f.list)));
|
||||||
*filter_query.borrow_mut() = query.to_string();
|
|
||||||
let mut i = 0i32;
|
|
||||||
while let Some(row) = list_f.row_at_index(i) {
|
|
||||||
let vis = get_row_entry(&row)
|
|
||||||
.map(|e| {
|
|
||||||
fuzzy_matches(query, &e.name)
|
|
||||||
|| e.wm_class
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|w| fuzzy_matches(query, w))
|
|
||||||
|| fuzzy_matches(query, &e.exec)
|
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
|
||||||
row.set_visible(vis);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
list_f.invalidate_sort();
|
|
||||||
let first_vis = (0i32..).find_map(|j| {
|
|
||||||
list_f.row_at_index(j).filter(|r| r.is_visible())
|
|
||||||
});
|
|
||||||
list_f.select_row(first_vis.as_ref());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard handling — capture phase on window
|
// Keyboard handling — capture phase on window
|
||||||
let key_ctrl = EventControllerKey::new();
|
let key_ctrl = EventControllerKey::new();
|
||||||
key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||||
let close_k = Rc::clone(&close_all);
|
let close_k = Rc::clone(&close_all);
|
||||||
let list_k = list.clone();
|
let results_k = results.clone();
|
||||||
let history_k = Rc::clone(&history_rc);
|
|
||||||
key_ctrl.connect_key_pressed(move |_, key, _, _| {
|
key_ctrl.connect_key_pressed(move |_, key, _, _| {
|
||||||
use gtk4::gdk::Key;
|
use gtk4::gdk::Key;
|
||||||
match key {
|
match key {
|
||||||
|
|
@ -462,47 +339,19 @@ fn run_ui(
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Return | Key::KP_Enter => {
|
Key::Return | Key::KP_Enter => {
|
||||||
if let Some(row) = list_k.selected_row() {
|
if let Some(entry) = results_k.selected_entry() {
|
||||||
if let Some(entry) = get_row_entry(&row) {
|
results_k.record_launch(&entry);
|
||||||
history_k.borrow_mut().increment(&entry.name);
|
bread_launcher::do_launch(&entry, APP_ID, LAUNCHED_EVENT);
|
||||||
history_k.borrow().save();
|
close_k();
|
||||||
do_launch(&entry);
|
|
||||||
close_k();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Down => {
|
Key::Down => {
|
||||||
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1);
|
results_k.select_next();
|
||||||
let mut i = cur + 1;
|
|
||||||
loop {
|
|
||||||
match list_k.row_at_index(i) {
|
|
||||||
Some(r) if r.is_visible() => {
|
|
||||||
list_k.select_row(Some(&r));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(_) => i += 1,
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Up => {
|
Key::Up => {
|
||||||
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0);
|
results_k.select_prev();
|
||||||
let mut i = cur - 1;
|
|
||||||
loop {
|
|
||||||
if i < 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match list_k.row_at_index(i) {
|
|
||||||
Some(r) if r.is_visible() => {
|
|
||||||
list_k.select_row(Some(&r));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(_) => i -= 1,
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
_ => glib::Propagation::Proceed,
|
_ => glib::Propagation::Proceed,
|
||||||
|
|
@ -512,33 +361,20 @@ fn run_ui(
|
||||||
|
|
||||||
// Row click launches
|
// Row click launches
|
||||||
let close_a = Rc::clone(&close_all);
|
let close_a = Rc::clone(&close_all);
|
||||||
let history_a = Rc::clone(&history_rc);
|
let results_a = results.clone();
|
||||||
list.connect_row_activated(move |_, row| {
|
results.list.connect_row_activated(move |_, row| {
|
||||||
if let Some(entry) = get_row_entry(row) {
|
if let Some(entry) = row_entry(row) {
|
||||||
history_a.borrow_mut().increment(&entry.name);
|
results_a.record_launch(&entry);
|
||||||
history_a.borrow().save();
|
bread_launcher::do_launch(&entry, APP_ID, LAUNCHED_EVENT);
|
||||||
do_launch(&entry);
|
|
||||||
close_a();
|
close_a();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click outside launcher panel → close
|
// Click outside launcher panel → close
|
||||||
let close_outside = Rc::clone(&close_all);
|
{
|
||||||
let vbox_ref = vbox.clone();
|
let close_outside = Rc::clone(&close_all);
|
||||||
let win_ref = window.clone();
|
bread_utils::gtk_popup::close_on_outside_click(&window, &vbox, move || close_outside());
|
||||||
let outside_click = gtk4::GestureClick::new();
|
}
|
||||||
outside_click.connect_pressed(move |_, _, x, y| {
|
|
||||||
if let Some(b) = vbox_ref.compute_bounds(&win_ref) {
|
|
||||||
if x < b.x() as f64
|
|
||||||
|| x > (b.x() + b.width()) as f64
|
|
||||||
|| y < b.y() as f64
|
|
||||||
|| y > (b.y() + b.height()) as f64
|
|
||||||
{
|
|
||||||
close_outside();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.add_controller(outside_click);
|
|
||||||
|
|
||||||
if let Some(req) = screenshot_req.clone() {
|
if let Some(req) = screenshot_req.clone() {
|
||||||
screenshot::dispatch(&window, req);
|
screenshot::dispatch(&window, req);
|
||||||
|
|
@ -570,6 +406,32 @@ fn main() {
|
||||||
let cli = screenshot::Cli::parse();
|
let cli = screenshot::Cli::parse();
|
||||||
let screenshot_req = cli.screenshot_request();
|
let screenshot_req = cli.screenshot_request();
|
||||||
|
|
||||||
|
// Under an `[launcher] mode = "embedded"` theme (spotlight,
|
||||||
|
// THEME_SYSTEM_PLAN.md §7 phase 6c), breadbar's own bar-drawer capsule
|
||||||
|
// IS the launcher — mapping this binary's overlay window on top of it
|
||||||
|
// would stack a second launcher over the capsule, exactly the bug a
|
||||||
|
// keybind that still directly execs `breadbox` (see the CLAUDE.md task
|
||||||
|
// notes: `bash -c breadbox` in ~/.config/hypr/binds.json) would hit
|
||||||
|
// every time. `--screenshot` runs are exempt — those exist to capture
|
||||||
|
// THIS binary's own overlay for its own screenshot views regardless of
|
||||||
|
// whatever theme happens to be active on the machine running them.
|
||||||
|
if screenshot_req.is_none()
|
||||||
|
&& crate::theme::shell_theme().launcher().mode
|
||||||
|
== bread_theme::shell::LauncherMode::Embedded
|
||||||
|
{
|
||||||
|
// `dispatch_embedded_open` only returns `true` once it has confirmed
|
||||||
|
// breadd itself is reachable (`BreadClient::health`) and emitted the
|
||||||
|
// redirect — see its doc comment for why that is the strongest
|
||||||
|
// guarantee this transport can give (there is no ack from breadbar).
|
||||||
|
// When breadd is unreachable, fall through to mapping breadbox's own
|
||||||
|
// overlay below instead of returning: a keybind press must always
|
||||||
|
// open *something*, even under an embedded theme, rather than
|
||||||
|
// silently doing nothing because the bus happens to be down.
|
||||||
|
if dispatch_embedded_open() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// `toggle_or_kill` kills whatever's holding the single-instance lock —
|
// `toggle_or_kill` kills whatever's holding the single-instance lock —
|
||||||
// a real, already-running breadbox included. A screenshot run must
|
// a real, already-running breadbox included. A screenshot run must
|
||||||
// never touch it: it's a separate, disposable instance by design (same
|
// never touch it: it's a separate, disposable instance by design (same
|
||||||
|
|
@ -586,7 +448,9 @@ fn main() {
|
||||||
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
|
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
|
||||||
Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
|
Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("breadbox: single-instance lock unavailable ({e}); continuing without it");
|
eprintln!(
|
||||||
|
"breadbox: single-instance lock unavailable ({e}); continuing without it"
|
||||||
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -599,9 +463,70 @@ fn main() {
|
||||||
.map(|c| c.priority.clone())
|
.map(|c| c.priority.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let history = LaunchHistory::load();
|
let history = breadbox_shared::launch_history();
|
||||||
let manifest = load_manifest();
|
let manifest = load_manifest();
|
||||||
let entries = load_sorted_entries(&manifest, &priority, &history);
|
let entries = bread_launcher::load_sorted_entries(&manifest, &priority, &history);
|
||||||
|
|
||||||
run_ui(entries, history, screenshot_req);
|
run_ui(entries, history, screenshot_req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Redirects an embedded-theme launch to the bus instead of mapping a
|
||||||
|
/// window (see the `main` call site). breadbar's capsule (spotlight)
|
||||||
|
/// subscribes to this and focuses/opens itself on receipt — see
|
||||||
|
/// `breadbar/src/launcher_command.rs`.
|
||||||
|
///
|
||||||
|
/// Emits `bread.box.open_requested`, NOT `bread.command.box.open`. Two
|
||||||
|
/// reasons, one mechanical and one semantic:
|
||||||
|
///
|
||||||
|
/// `BreadClient::emit` enforces that an app may only publish within its own
|
||||||
|
/// `bread.<app_id>.*` namespace (`bread_utils::bread_client`'s
|
||||||
|
/// `validate_app_namespace`). breadbox's app id is `box`, so a
|
||||||
|
/// `bread.command.*` event is refused outright and the redirect silently did
|
||||||
|
/// nothing but print a warning.
|
||||||
|
///
|
||||||
|
/// The guard is right, and the original name had the direction backwards.
|
||||||
|
/// `bread.command.<app>.*` is a command addressed TO an app by an outside
|
||||||
|
/// trigger — it is what breadbox's own `listen` subscribes to. An app
|
||||||
|
/// emitting a command at itself would mean breadbox both sends and receives
|
||||||
|
/// the same verb, which is also how the respawn loop `listen.rs` guards
|
||||||
|
/// against arises. What actually happened here is an event: breadbox was
|
||||||
|
/// asked to open and is reporting that, so it belongs in breadbox's own
|
||||||
|
/// namespace as a past-tense fact.
|
||||||
|
///
|
||||||
|
/// Fire-and-forget, same as every other `BreadClient::emit` here — breadd
|
||||||
|
/// being unreachable must never turn a keybind press into an error dialog or
|
||||||
|
/// a hung process. But "nobody was listening" used to also mean "the
|
||||||
|
/// keybind silently does nothing at all", which is the worst failure mode
|
||||||
|
/// available for a launcher: no window, no error, nothing on stderr. This
|
||||||
|
/// now checks reachability first (`BreadClient::health`, a real round trip
|
||||||
|
/// with a bounded timeout) so the `main` call site can fall back to mapping
|
||||||
|
/// breadbox's own overlay window when breadd itself is down — see its call
|
||||||
|
/// site.
|
||||||
|
///
|
||||||
|
/// That fallback only covers "breadd is unreachable", not "breadd is up but
|
||||||
|
/// nobody is subscribed" (breadbar isn't running, or is running under a
|
||||||
|
/// non-Embedded theme and therefore never subscribed —
|
||||||
|
/// `launcher_command.rs::spawn`). `BreadClient`'s API has no way to ask "is
|
||||||
|
/// anything actually subscribed to this event" — `emit` has no ack and
|
||||||
|
/// `health` only reports breadd's own liveness — so that narrower gap can't
|
||||||
|
/// be closed without a bus-level ack protocol, which is out of scope here.
|
||||||
|
/// Returns `true` iff breadd was reachable and the redirect was sent.
|
||||||
|
fn dispatch_embedded_open() -> bool {
|
||||||
|
let client = bread_utils::bread_client::BreadClient::connect(APP_ID);
|
||||||
|
if client.health().is_none() {
|
||||||
|
eprintln!(
|
||||||
|
"breadbox: embedded launcher theme active but breadd is unreachable \
|
||||||
|
(health check failed); falling back to breadbox's own overlay window \
|
||||||
|
instead of a silent no-op"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
client.emit(EMBEDDED_OPEN_EVENT, serde_json::json!({}));
|
||||||
|
eprintln!(
|
||||||
|
"breadbox: embedded launcher theme active; redirected the open request to \
|
||||||
|
'{EMBEDDED_OPEN_EVENT}' for breadbar's capsule to handle (breadd is \
|
||||||
|
reachable, but this is fire-and-forget with no ack — if breadbar isn't \
|
||||||
|
running or isn't subscribed, this is still a silent no-op on the bus)"
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
|
||||||
98
breadbox/src/theme.rs
Normal file
98
breadbox/src/theme.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
//! The active shell theme, loaded once per process (plan §5/§6, Phase 4b-i).
|
||||||
|
//!
|
||||||
|
//! Mirrors breadbar's `src/theme.rs::shell_theme()` accessor: the underlying
|
||||||
|
//! `bread_theme::shell::load()` call happens at most once per process (via
|
||||||
|
//! the thread-local cache below), not once per call site — every place in
|
||||||
|
//! this crate that needs launcher geometry/style reads through this shared
|
||||||
|
//! instance instead of calling `bread_theme::shell::load()` itself.
|
||||||
|
//!
|
||||||
|
//! Only `ShellTheme::launcher()` is consumed today; window-spec/tokens/slots
|
||||||
|
//! accessors exist on the type but breadbox has no bar/workspace/clock
|
||||||
|
//! chrome to drive from them.
|
||||||
|
|
||||||
|
use bread_theme::shell::ShellTheme;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static SHELL_THEME: RefCell<Rc<ShellTheme>> =
|
||||||
|
RefCell::new(Rc::new(bread_theme::shell::load()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active shell theme. Loaded at most once per process; subsequent calls
|
||||||
|
/// just clone the cached `Rc`.
|
||||||
|
pub fn shell_theme() -> Rc<ShellTheme> {
|
||||||
|
SHELL_THEME.with(|cell| cell.borrow().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback margin (pixels) for any `[launcher] top` value this function
|
||||||
|
/// can't make sense of — breadbox's pre-theme hardcoded default.
|
||||||
|
const DEFAULT_TOP_MARGIN_PX: i32 = 120;
|
||||||
|
|
||||||
|
/// Parses a `[launcher] top` value (e.g. `"120px"`) into whole pixels for
|
||||||
|
/// `Widget::set_margin_top`. Falls back to [`DEFAULT_TOP_MARGIN_PX`] — with a
|
||||||
|
/// logged reason — for any form this Phase 4b-i doesn't understand yet (a
|
||||||
|
/// bare percentage like theme 04's `"16%"`, or a malformed value in a
|
||||||
|
/// hand-edited theme) — per the manifest's "never fails to start" rule (plan
|
||||||
|
/// §4), a launcher window is not worth refusing to open over an unparseable
|
||||||
|
/// margin.
|
||||||
|
///
|
||||||
|
/// A value that parses but is negative (e.g. `"-50px"`) is clamped to `0`
|
||||||
|
/// rather than passed straight through: `Widget::set_margin_top` requires a
|
||||||
|
/// non-negative margin, and passing it a negative one silently does nothing
|
||||||
|
/// useful (a `g_return_if_fail` on the GTK side) rather than the "push the
|
||||||
|
/// panel up off-screen" a theme author might expect — 0 (flush with the top
|
||||||
|
/// edge) is the closest sane interpretation.
|
||||||
|
pub fn top_margin_px(top: &str) -> i32 {
|
||||||
|
let trimmed = top.trim().trim_end_matches("px");
|
||||||
|
match trimmed.parse::<i32>() {
|
||||||
|
Ok(px) if px < 0 => {
|
||||||
|
eprintln!(
|
||||||
|
"breadbox: [launcher] top = {top:?} parsed to a negative margin \
|
||||||
|
({px}px); clamping to 0"
|
||||||
|
);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Ok(px) => px,
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!(
|
||||||
|
"breadbox: [launcher] top = {top:?} is not a plain pixel value \
|
||||||
|
(e.g. \"120px\"); falling back to the default \
|
||||||
|
{DEFAULT_TOP_MARGIN_PX}px margin"
|
||||||
|
);
|
||||||
|
DEFAULT_TOP_MARGIN_PX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_pixel_value() {
|
||||||
|
assert_eq!(top_margin_px("120px"), 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bare_number_without_unit_suffix() {
|
||||||
|
assert_eq!(top_margin_px("120"), 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percentage_form_falls_back_to_default() {
|
||||||
|
// The doc comment's own anticipated edge case (theme 04's original
|
||||||
|
// "16%" idea) — not parseable as a plain pixel count today.
|
||||||
|
assert_eq!(top_margin_px("16%"), DEFAULT_TOP_MARGIN_PX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn garbage_falls_back_to_default() {
|
||||||
|
assert_eq!(top_margin_px("not-a-length"), DEFAULT_TOP_MARGIN_PX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_value_clamps_to_zero() {
|
||||||
|
assert_eq!(top_margin_px("-50px"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue