From d5bbc259860be5bdfa417d9477c1ca4ff73c7fb4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 23 May 2026 11:14:16 +0800 Subject: [PATCH 01/60] breadbox: fast Rust application launcher for wofi Zero-dependency Rust rewrite of the breadboard launcher. Parses .desktop files in-process (no awk subprocesses), builds a tab-separated cache for wofi dmenu, and supports stale-while-revalidate background rebuilds. Apps-only for now; icon support to be added later. --- .gitignore | 21 ++++ Cargo.lock | 7 ++ Cargo.toml | 4 + LICENSE | 21 ++++ src/main.rs | 273 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 326 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3bd9af --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +/target/ + +# Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS artifacts +.DS_Store +Thumbs.db + +# Environment / secrets +.env +.env.* +*.pem +*.key + +# Claude Code session data +.claude/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3fb91bb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "breadbox" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bf8e14a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "breadbox" +version = "0.1.0" +edition = "2021" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..373e4ee --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Breadway + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ad419bc --- /dev/null +++ b/src/main.rs @@ -0,0 +1,273 @@ +use std::{ + collections::HashMap, + env, + fs::{self, File}, + io::{BufRead, BufReader, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, +}; + +const CACHE_TIMEOUT_SECS: u64 = 86400; + +fn home_dir() -> PathBuf { + PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into())) +} + +fn cache_path() -> PathBuf { + let dir = env::var("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".cache")); + dir.join("breadbox.cache") +} + +fn app_dirs() -> [PathBuf; 2] { + [ + PathBuf::from("/usr/share/applications"), + home_dir().join(".local/share/applications"), + ] +} + +fn mtime(path: &Path) -> u64 { + fs::metadata(path) + .and_then(|m| m.modified()) + .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()) + .unwrap_or(0) +} + +fn cache_valid(cache: &Path) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let cm = mtime(cache); + if now.saturating_sub(cm) >= CACHE_TIMEOUT_SECS { + return false; + } + app_dirs().iter().all(|d| !d.is_dir() || mtime(d) <= cm) +} + +fn strip_exec_codes(exec: &str) -> String { + let mut out = String::with_capacity(exec.len()); + 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 +} + +struct App { + name: String, + exec: String, + terminal: bool, +} + +fn parse_desktop(path: &Path) -> Option { + let file = File::open(path).ok()?; + let mut in_entry = false; + let (mut name, mut exec, mut app_type) = (None::, None::, None::); + let (mut no_display, mut hidden, mut terminal) = (false, false, 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("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().is_some_and(|t| t != "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; + } + + Some(App { name, exec, terminal }) +} + +fn build_cache(cache: &Path) { + let _ = fs::create_dir_all(cache.parent().unwrap_or(Path::new("/tmp"))); + let mut apps: HashMap = 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 id = entry.file_name().to_string_lossy().into_owned(); + if let Some(app) = parse_desktop(&path) { + apps.insert(id, app); + } + } + } + + let mut lines: Vec = apps + .into_values() + .map(|a| { + let prefix = if a.terminal { "term" } else { "app" }; + format!("{}\t{}::{}", a.name, prefix, a.exec) + }) + .collect(); + lines.sort_unstable(); + + let tmp = cache.with_extension("tmp"); + if let Ok(mut f) = File::create(&tmp) { + for line in &lines { + let _ = writeln!(f, "{}", line); + } + let _ = fs::rename(&tmp, cache); + } +} + +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 main() { + let cache = cache_path(); + + if env::var("BREADBOX_REBUILD_ONLY").as_deref() == Ok("1") { + build_cache(&cache); + return; + } + + // Toggle: second press closes an open wofi instance + if Command::new("pgrep") + .args(["-f", "wofi.*breadbox"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + { + let _ = Command::new("pkill") + .args(["-f", "wofi.*breadbox"]) + .status(); + return; + } + + // Stale-while-revalidate: never block on a rebuild if cache exists + if !cache.exists() { + build_cache(&cache); + } else if !cache_valid(&cache) { + if let Ok(exe) = env::current_exe() { + let _ = Command::new(exe) + .env("BREADBOX_REBUILD_ONLY", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } + } + + let content = fs::read_to_string(&cache).unwrap_or_default(); + + let mut child = match Command::new("wofi") + .args([ + "--dmenu", + "--parse-search", + "--matching", + "fuzzy", + "--insensitive", + "--prompt", + "breadbox", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(_) => return, + }; + + if let Some(mut stdin) = child.stdin.take() { + let _ = write!(stdin, "{}", content); + } + + let out = match child.wait_with_output() { + Ok(o) => o, + Err(_) => return, + }; + + let choice = std::str::from_utf8(&out.stdout) + .unwrap_or("") + .trim() + .to_string(); + if choice.is_empty() { + return; + } + + let action = choice.split('\t').nth(1).unwrap_or(""); + + if let Some(cmd) = action.strip_prefix("app::") { + let _ = Command::new("bash") + .args(["-c", cmd]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } else if let Some(cmd) = action.strip_prefix("term::") { + let term = pick_terminal(); + let _ = Command::new(&term) + .args(["-e", "bash", "-c", cmd]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } +} From d94a00d982d5b461983c415f3298843fed2f52a8 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 23 May 2026 11:14:16 +0800 Subject: [PATCH 02/60] breadbox: fast Rust application launcher for wofi Zero-dependency Rust rewrite of the breadboard launcher. Parses .desktop files in-process (no awk subprocesses), builds a tab-separated cache for wofi dmenu, and supports stale-while-revalidate background rebuilds. Apps-only for now; icon support to be added later. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 21 ++++ Cargo.lock | 7 ++ Cargo.toml | 4 + LICENSE | 21 ++++ src/main.rs | 273 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 326 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3bd9af --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +/target/ + +# Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS artifacts +.DS_Store +Thumbs.db + +# Environment / secrets +.env +.env.* +*.pem +*.key + +# Claude Code session data +.claude/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3fb91bb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "breadbox" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bf8e14a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "breadbox" +version = "0.1.0" +edition = "2021" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..373e4ee --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Breadway + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ad419bc --- /dev/null +++ b/src/main.rs @@ -0,0 +1,273 @@ +use std::{ + collections::HashMap, + env, + fs::{self, File}, + io::{BufRead, BufReader, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, +}; + +const CACHE_TIMEOUT_SECS: u64 = 86400; + +fn home_dir() -> PathBuf { + PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into())) +} + +fn cache_path() -> PathBuf { + let dir = env::var("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".cache")); + dir.join("breadbox.cache") +} + +fn app_dirs() -> [PathBuf; 2] { + [ + PathBuf::from("/usr/share/applications"), + home_dir().join(".local/share/applications"), + ] +} + +fn mtime(path: &Path) -> u64 { + fs::metadata(path) + .and_then(|m| m.modified()) + .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()) + .unwrap_or(0) +} + +fn cache_valid(cache: &Path) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let cm = mtime(cache); + if now.saturating_sub(cm) >= CACHE_TIMEOUT_SECS { + return false; + } + app_dirs().iter().all(|d| !d.is_dir() || mtime(d) <= cm) +} + +fn strip_exec_codes(exec: &str) -> String { + let mut out = String::with_capacity(exec.len()); + 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 +} + +struct App { + name: String, + exec: String, + terminal: bool, +} + +fn parse_desktop(path: &Path) -> Option { + let file = File::open(path).ok()?; + let mut in_entry = false; + let (mut name, mut exec, mut app_type) = (None::, None::, None::); + let (mut no_display, mut hidden, mut terminal) = (false, false, 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("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().is_some_and(|t| t != "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; + } + + Some(App { name, exec, terminal }) +} + +fn build_cache(cache: &Path) { + let _ = fs::create_dir_all(cache.parent().unwrap_or(Path::new("/tmp"))); + let mut apps: HashMap = 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 id = entry.file_name().to_string_lossy().into_owned(); + if let Some(app) = parse_desktop(&path) { + apps.insert(id, app); + } + } + } + + let mut lines: Vec = apps + .into_values() + .map(|a| { + let prefix = if a.terminal { "term" } else { "app" }; + format!("{}\t{}::{}", a.name, prefix, a.exec) + }) + .collect(); + lines.sort_unstable(); + + let tmp = cache.with_extension("tmp"); + if let Ok(mut f) = File::create(&tmp) { + for line in &lines { + let _ = writeln!(f, "{}", line); + } + let _ = fs::rename(&tmp, cache); + } +} + +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 main() { + let cache = cache_path(); + + if env::var("BREADBOX_REBUILD_ONLY").as_deref() == Ok("1") { + build_cache(&cache); + return; + } + + // Toggle: second press closes an open wofi instance + if Command::new("pgrep") + .args(["-f", "wofi.*breadbox"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + { + let _ = Command::new("pkill") + .args(["-f", "wofi.*breadbox"]) + .status(); + return; + } + + // Stale-while-revalidate: never block on a rebuild if cache exists + if !cache.exists() { + build_cache(&cache); + } else if !cache_valid(&cache) { + if let Ok(exe) = env::current_exe() { + let _ = Command::new(exe) + .env("BREADBOX_REBUILD_ONLY", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } + } + + let content = fs::read_to_string(&cache).unwrap_or_default(); + + let mut child = match Command::new("wofi") + .args([ + "--dmenu", + "--parse-search", + "--matching", + "fuzzy", + "--insensitive", + "--prompt", + "breadbox", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(_) => return, + }; + + if let Some(mut stdin) = child.stdin.take() { + let _ = write!(stdin, "{}", content); + } + + let out = match child.wait_with_output() { + Ok(o) => o, + Err(_) => return, + }; + + let choice = std::str::from_utf8(&out.stdout) + .unwrap_or("") + .trim() + .to_string(); + if choice.is_empty() { + return; + } + + let action = choice.split('\t').nth(1).unwrap_or(""); + + if let Some(cmd) = action.strip_prefix("app::") { + let _ = Command::new("bash") + .args(["-c", cmd]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } else if let Some(cmd) = action.strip_prefix("term::") { + let term = pick_terminal(); + let _ = Command::new(&term) + .args(["-e", "bash", "-c", cmd]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } +} From e6204e94a9ab50b9f711527d492ae5ff77ca3ef8 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 23 May 2026 11:36:29 +0800 Subject: [PATCH 03/60] breadbox: replace wofi with native GTK4 layer-shell UI Remove the wofi subprocess entirely. The launcher now renders its own Wayland overlay window via gtk4 + gtk4-layer-shell: a SearchEntry at the top with live fuzzy filtering, a ListBox of results below, and keyboard navigation (Enter/Esc/arrows). Toggle (keybind press while open closes it) is handled via a PID file in $XDG_RUNTIME_DIR. --- Cargo.lock | 720 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 4 + src/main.rs | 423 +++++++++++++++++++++++------- 3 files changed, 1057 insertions(+), 90 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3fb91bb..76771c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,726 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "breadbox" version = "0.1.0" +dependencies = [ + "gtk4", + "gtk4-layer-shell", +] + +[[package]] +name = "cairo-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cfg-expr" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "gl", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gio" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glib" +version = "0.22.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-layer-shell" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +dependencies = [ + "bitflags", + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-layer-shell-sys", + "libc", +] + +[[package]] +name = "gtk4-layer-shell-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gtk4-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "pango" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" diff --git a/Cargo.toml b/Cargo.toml index bf8e14a..7e0d3ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,3 +2,7 @@ name = "breadbox" version = "0.1.0" edition = "2021" + +[dependencies] +gtk4 = "0.11" +gtk4-layer-shell = "0.8" diff --git a/src/main.rs b/src/main.rs index ad419bc..52c2a21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,17 +8,62 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; +use gtk4::{ + gdk::Display, + glib, + pango::EllipsizeMode, + prelude::*, + Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label, + ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, +}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; + const CACHE_TIMEOUT_SECS: u64 = 86400; +const CSS: &str = " +window, .background { + background-color: #1e1e2e; +} +searchentry { + background-color: #313244; + color: #cdd6f4; + caret-color: #cba6f7; + border: none; + outline: none; + box-shadow: none; + padding: 12px 16px; + font-size: 15px; +} +listbox { + background-color: transparent; + padding: 4px; +} +row { + padding: 6px 12px; + color: #cdd6f4; + background-color: transparent; + border-radius: 4px; +} +row:selected { + background-color: #45475a; +} +.action { + color: #6c7086; + font-size: 12px; +} +"; + +// ---- cache helpers -------------------------------------------------------- + fn home_dir() -> PathBuf { PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into())) } fn cache_path() -> PathBuf { - let dir = env::var("XDG_CACHE_HOME") + env::var("XDG_CACHE_HOME") .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")); - dir.join("breadbox.cache") + .unwrap_or_else(|_| home_dir().join(".cache")) + .join("breadbox.cache") } fn app_dirs() -> [PathBuf; 2] { @@ -41,10 +86,8 @@ fn cache_valid(cache: &Path) -> bool { .unwrap_or_default() .as_secs(); let cm = mtime(cache); - if now.saturating_sub(cm) >= CACHE_TIMEOUT_SECS { - return false; - } - app_dirs().iter().all(|d| !d.is_dir() || mtime(d) <= cm) + now.saturating_sub(cm) < CACHE_TIMEOUT_SECS + && app_dirs().iter().all(|d| !d.is_dir() || mtime(d) <= cm) } fn strip_exec_codes(exec: &str) -> String { @@ -69,13 +112,13 @@ fn strip_exec_codes(exec: &str) -> String { out } -struct App { +struct DesktopApp { name: String, exec: String, terminal: bool, } -fn parse_desktop(path: &Path) -> Option { +fn parse_desktop(path: &Path) -> Option { let file = File::open(path).ok()?; let mut in_entry = false; let (mut name, mut exec, mut app_type) = (None::, None::, None::); @@ -123,12 +166,12 @@ fn parse_desktop(path: &Path) -> Option { return None; } - Some(App { name, exec, terminal }) + Some(DesktopApp { name, exec, terminal }) } fn build_cache(cache: &Path) { let _ = fs::create_dir_all(cache.parent().unwrap_or(Path::new("/tmp"))); - let mut apps: HashMap = HashMap::new(); + let mut apps: HashMap = HashMap::new(); for dir in &app_dirs() { let Ok(entries) = fs::read_dir(dir) else { continue }; @@ -137,9 +180,8 @@ fn build_cache(cache: &Path) { if path.extension().and_then(|e| e.to_str()) != Some("desktop") { continue; } - let id = entry.file_name().to_string_lossy().into_owned(); if let Some(app) = parse_desktop(&path) { - apps.insert(id, app); + apps.insert(entry.file_name().to_string_lossy().into_owned(), app); } } } @@ -162,6 +204,21 @@ fn build_cache(cache: &Path) { } } +fn load_entries(cache: &Path) -> Vec<(String, String)> { + fs::read_to_string(cache) + .unwrap_or_default() + .lines() + .filter_map(|line| { + let mut parts = line.splitn(2, '\t'); + let name = parts.next()?.to_string(); + let action = parts.next()?.to_string(); + (!name.is_empty() && !action.is_empty()).then_some((name, action)) + }) + .collect() +} + +// ---- launch --------------------------------------------------------------- + fn pick_terminal() -> String { if let Ok(t) = env::var("TERMINAL") { if !t.is_empty() { @@ -177,83 +234,7 @@ fn pick_terminal() -> String { "xterm".to_string() } -fn main() { - let cache = cache_path(); - - if env::var("BREADBOX_REBUILD_ONLY").as_deref() == Ok("1") { - build_cache(&cache); - return; - } - - // Toggle: second press closes an open wofi instance - if Command::new("pgrep") - .args(["-f", "wofi.*breadbox"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - { - let _ = Command::new("pkill") - .args(["-f", "wofi.*breadbox"]) - .status(); - return; - } - - // Stale-while-revalidate: never block on a rebuild if cache exists - if !cache.exists() { - build_cache(&cache); - } else if !cache_valid(&cache) { - if let Ok(exe) = env::current_exe() { - let _ = Command::new(exe) - .env("BREADBOX_REBUILD_ONLY", "1") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - } - } - - let content = fs::read_to_string(&cache).unwrap_or_default(); - - let mut child = match Command::new("wofi") - .args([ - "--dmenu", - "--parse-search", - "--matching", - "fuzzy", - "--insensitive", - "--prompt", - "breadbox", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - { - Ok(c) => c, - Err(_) => return, - }; - - if let Some(mut stdin) = child.stdin.take() { - let _ = write!(stdin, "{}", content); - } - - let out = match child.wait_with_output() { - Ok(o) => o, - Err(_) => return, - }; - - let choice = std::str::from_utf8(&out.stdout) - .unwrap_or("") - .trim() - .to_string(); - if choice.is_empty() { - return; - } - - let action = choice.split('\t').nth(1).unwrap_or(""); - +fn do_launch(action: &str) { if let Some(cmd) = action.strip_prefix("app::") { let _ = Command::new("bash") .args(["-c", cmd]) @@ -271,3 +252,265 @@ fn main() { .spawn(); } } + +// ---- 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; + } + } + true +} + +// ---- toggle via pid file -------------------------------------------------- + +fn pid_file() -> PathBuf { + env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")) + .join("breadbox.pid") +} + +// Returns false if an existing instance was killed (caller should exit). +fn toggle_or_continue() -> bool { + let pf = pid_file(); + if let Ok(content) = fs::read_to_string(&pf) { + if let Ok(pid) = content.trim().parse::() { + if Path::new(&format!("/proc/{}", pid)).exists() { + let _ = Command::new("kill").arg(pid.to_string()).status(); + return false; + } + } + } + let _ = fs::write(&pf, std::process::id().to_string()); + true +} + +fn cleanup_pid() { + let _ = fs::remove_file(pid_file()); +} + +// ---- UI ------------------------------------------------------------------- + +fn get_row_data(row: >k4::ListBoxRow, key: &str) -> String { + unsafe { + row.data::(key) + .map(|p| p.as_ref().clone()) + .unwrap_or_default() + } +} + +fn run_ui(entries: Vec<(String, String)>) { + let app = Application::builder() + .application_id("com.breadway.breadbox") + .build(); + + app.connect_activate(move |app| { + let provider = CssProvider::new(); + provider.load_from_data(CSS); + gtk4::style_context_add_provider_for_display( + &Display::default().expect("no display"), + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + let window = ApplicationWindow::builder() + .application(app) + .default_width(700) + .build(); + + window.init_layer_shell(); + window.set_layer(Layer::Overlay); + window.set_keyboard_mode(KeyboardMode::OnDemand); + window.set_anchor(Edge::Top, true); + window.set_exclusive_zone(-1); + + let vbox = GBox::new(Orientation::Vertical, 0); + + let search = SearchEntry::new(); + search.set_placeholder_text(Some("breadbox")); + vbox.append(&search); + + let scroll = ScrolledWindow::new(); + scroll.set_policy(PolicyType::Never, PolicyType::Automatic); + scroll.set_max_content_height(400); + scroll.set_propagate_natural_height(true); + + let list = ListBox::new(); + list.set_selection_mode(SelectionMode::Browse); + + for (name, action) in &entries { + let row = gtk4::ListBoxRow::new(); + let hbox = GBox::new(Orientation::Horizontal, 8); + hbox.set_margin_start(4); + hbox.set_margin_end(4); + + let name_lbl = Label::new(Some(name)); + name_lbl.set_xalign(0.0); + name_lbl.set_hexpand(true); + hbox.append(&name_lbl); + + let action_lbl = Label::new(Some(action)); + action_lbl.add_css_class("action"); + action_lbl.set_xalign(1.0); + action_lbl.set_ellipsize(EllipsizeMode::End); + action_lbl.set_max_width_chars(50); + hbox.append(&action_lbl); + + row.set_child(Some(&hbox)); + unsafe { + row.set_data("name", name.clone()); + row.set_data("action", action.clone()); + } + list.append(&row); + } + + 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)); + + // Filter rows on every keystroke + let list_f = list.clone(); + search.connect_changed(move |entry| { + let text = entry.text(); + let query = text.as_str(); + let mut first_vis: Option = None; + let mut i = 0i32; + while let Some(row) = list_f.row_at_index(i) { + let name = get_row_data(&row, "name"); + let vis = fuzzy_matches(query, &name); + row.set_visible(vis); + if vis && first_vis.is_none() { + first_vis = Some(row); + } + i += 1; + } + list_f.select_row(first_vis.as_ref()); + }); + + // Keyboard: Esc, Enter, arrows — keep focus in search bar + let key_ctrl = EventControllerKey::new(); + let window_k = window.clone(); + let list_k = list.clone(); + key_ctrl.connect_key_pressed(move |_, key, _, _| { + use gtk4::gdk::Key; + match key { + Key::Escape => { + cleanup_pid(); + window_k.close(); + glib::Propagation::Stop + } + Key::Return | Key::KP_Enter => { + if let Some(row) = list_k.selected_row() { + let action = get_row_data(&row, "action"); + if !action.is_empty() { + do_launch(&action); + cleanup_pid(); + window_k.close(); + } + } + glib::Propagation::Stop + } + Key::Down => { + let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1); + 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 + } + Key::Up => { + let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0); + 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::Proceed, + } + }); + search.add_controller(key_ctrl); + + // Click to launch + let window_a = window.clone(); + list.connect_row_activated(move |_, row| { + let action = get_row_data(row, "action"); + if !action.is_empty() { + do_launch(&action); + cleanup_pid(); + window_a.close(); + } + }); + + // Cleanup pid when window is destroyed for any reason + window.connect_destroy(|_| cleanup_pid()); + + window.present(); + search.grab_focus(); + }); + + app.run(); +} + +// ---- main ----------------------------------------------------------------- + +fn main() { + let cache = cache_path(); + + if env::var("BREADBOX_REBUILD_ONLY").as_deref() == Ok("1") { + build_cache(&cache); + return; + } + + if !toggle_or_continue() { + return; + } + + if !cache.exists() { + build_cache(&cache); + } else if !cache_valid(&cache) { + if let Ok(exe) = env::current_exe() { + let _ = Command::new(exe) + .env("BREADBOX_REBUILD_ONLY", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } + } + + let entries = load_entries(&cache); + run_ui(entries); +} From 30e40ec54fc4321141e1b6e0313344f70ad5cd5d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 23 May 2026 11:36:29 +0800 Subject: [PATCH 04/60] breadbox: replace wofi with native GTK4 layer-shell UI Remove the wofi subprocess entirely. The launcher now renders its own Wayland overlay window via gtk4 + gtk4-layer-shell: a SearchEntry at the top with live fuzzy filtering, a ListBox of results below, and keyboard navigation (Enter/Esc/arrows). Toggle (keybind press while open closes it) is handled via a PID file in $XDG_RUNTIME_DIR. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 720 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 4 + src/main.rs | 423 +++++++++++++++++++++++------- 3 files changed, 1057 insertions(+), 90 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3fb91bb..76771c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,726 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "breadbox" version = "0.1.0" +dependencies = [ + "gtk4", + "gtk4-layer-shell", +] + +[[package]] +name = "cairo-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cfg-expr" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "gl", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gio" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glib" +version = "0.22.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-layer-shell" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +dependencies = [ + "bitflags", + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-layer-shell-sys", + "libc", +] + +[[package]] +name = "gtk4-layer-shell-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gtk4-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "pango" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" diff --git a/Cargo.toml b/Cargo.toml index bf8e14a..7e0d3ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,3 +2,7 @@ name = "breadbox" version = "0.1.0" edition = "2021" + +[dependencies] +gtk4 = "0.11" +gtk4-layer-shell = "0.8" diff --git a/src/main.rs b/src/main.rs index ad419bc..52c2a21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,17 +8,62 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; +use gtk4::{ + gdk::Display, + glib, + pango::EllipsizeMode, + prelude::*, + Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label, + ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, +}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; + const CACHE_TIMEOUT_SECS: u64 = 86400; +const CSS: &str = " +window, .background { + background-color: #1e1e2e; +} +searchentry { + background-color: #313244; + color: #cdd6f4; + caret-color: #cba6f7; + border: none; + outline: none; + box-shadow: none; + padding: 12px 16px; + font-size: 15px; +} +listbox { + background-color: transparent; + padding: 4px; +} +row { + padding: 6px 12px; + color: #cdd6f4; + background-color: transparent; + border-radius: 4px; +} +row:selected { + background-color: #45475a; +} +.action { + color: #6c7086; + font-size: 12px; +} +"; + +// ---- cache helpers -------------------------------------------------------- + fn home_dir() -> PathBuf { PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into())) } fn cache_path() -> PathBuf { - let dir = env::var("XDG_CACHE_HOME") + env::var("XDG_CACHE_HOME") .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")); - dir.join("breadbox.cache") + .unwrap_or_else(|_| home_dir().join(".cache")) + .join("breadbox.cache") } fn app_dirs() -> [PathBuf; 2] { @@ -41,10 +86,8 @@ fn cache_valid(cache: &Path) -> bool { .unwrap_or_default() .as_secs(); let cm = mtime(cache); - if now.saturating_sub(cm) >= CACHE_TIMEOUT_SECS { - return false; - } - app_dirs().iter().all(|d| !d.is_dir() || mtime(d) <= cm) + now.saturating_sub(cm) < CACHE_TIMEOUT_SECS + && app_dirs().iter().all(|d| !d.is_dir() || mtime(d) <= cm) } fn strip_exec_codes(exec: &str) -> String { @@ -69,13 +112,13 @@ fn strip_exec_codes(exec: &str) -> String { out } -struct App { +struct DesktopApp { name: String, exec: String, terminal: bool, } -fn parse_desktop(path: &Path) -> Option { +fn parse_desktop(path: &Path) -> Option { let file = File::open(path).ok()?; let mut in_entry = false; let (mut name, mut exec, mut app_type) = (None::, None::, None::); @@ -123,12 +166,12 @@ fn parse_desktop(path: &Path) -> Option { return None; } - Some(App { name, exec, terminal }) + Some(DesktopApp { name, exec, terminal }) } fn build_cache(cache: &Path) { let _ = fs::create_dir_all(cache.parent().unwrap_or(Path::new("/tmp"))); - let mut apps: HashMap = HashMap::new(); + let mut apps: HashMap = HashMap::new(); for dir in &app_dirs() { let Ok(entries) = fs::read_dir(dir) else { continue }; @@ -137,9 +180,8 @@ fn build_cache(cache: &Path) { if path.extension().and_then(|e| e.to_str()) != Some("desktop") { continue; } - let id = entry.file_name().to_string_lossy().into_owned(); if let Some(app) = parse_desktop(&path) { - apps.insert(id, app); + apps.insert(entry.file_name().to_string_lossy().into_owned(), app); } } } @@ -162,6 +204,21 @@ fn build_cache(cache: &Path) { } } +fn load_entries(cache: &Path) -> Vec<(String, String)> { + fs::read_to_string(cache) + .unwrap_or_default() + .lines() + .filter_map(|line| { + let mut parts = line.splitn(2, '\t'); + let name = parts.next()?.to_string(); + let action = parts.next()?.to_string(); + (!name.is_empty() && !action.is_empty()).then_some((name, action)) + }) + .collect() +} + +// ---- launch --------------------------------------------------------------- + fn pick_terminal() -> String { if let Ok(t) = env::var("TERMINAL") { if !t.is_empty() { @@ -177,83 +234,7 @@ fn pick_terminal() -> String { "xterm".to_string() } -fn main() { - let cache = cache_path(); - - if env::var("BREADBOX_REBUILD_ONLY").as_deref() == Ok("1") { - build_cache(&cache); - return; - } - - // Toggle: second press closes an open wofi instance - if Command::new("pgrep") - .args(["-f", "wofi.*breadbox"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - { - let _ = Command::new("pkill") - .args(["-f", "wofi.*breadbox"]) - .status(); - return; - } - - // Stale-while-revalidate: never block on a rebuild if cache exists - if !cache.exists() { - build_cache(&cache); - } else if !cache_valid(&cache) { - if let Ok(exe) = env::current_exe() { - let _ = Command::new(exe) - .env("BREADBOX_REBUILD_ONLY", "1") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - } - } - - let content = fs::read_to_string(&cache).unwrap_or_default(); - - let mut child = match Command::new("wofi") - .args([ - "--dmenu", - "--parse-search", - "--matching", - "fuzzy", - "--insensitive", - "--prompt", - "breadbox", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - { - Ok(c) => c, - Err(_) => return, - }; - - if let Some(mut stdin) = child.stdin.take() { - let _ = write!(stdin, "{}", content); - } - - let out = match child.wait_with_output() { - Ok(o) => o, - Err(_) => return, - }; - - let choice = std::str::from_utf8(&out.stdout) - .unwrap_or("") - .trim() - .to_string(); - if choice.is_empty() { - return; - } - - let action = choice.split('\t').nth(1).unwrap_or(""); - +fn do_launch(action: &str) { if let Some(cmd) = action.strip_prefix("app::") { let _ = Command::new("bash") .args(["-c", cmd]) @@ -271,3 +252,265 @@ fn main() { .spawn(); } } + +// ---- 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; + } + } + true +} + +// ---- toggle via pid file -------------------------------------------------- + +fn pid_file() -> PathBuf { + env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")) + .join("breadbox.pid") +} + +// Returns false if an existing instance was killed (caller should exit). +fn toggle_or_continue() -> bool { + let pf = pid_file(); + if let Ok(content) = fs::read_to_string(&pf) { + if let Ok(pid) = content.trim().parse::() { + if Path::new(&format!("/proc/{}", pid)).exists() { + let _ = Command::new("kill").arg(pid.to_string()).status(); + return false; + } + } + } + let _ = fs::write(&pf, std::process::id().to_string()); + true +} + +fn cleanup_pid() { + let _ = fs::remove_file(pid_file()); +} + +// ---- UI ------------------------------------------------------------------- + +fn get_row_data(row: >k4::ListBoxRow, key: &str) -> String { + unsafe { + row.data::(key) + .map(|p| p.as_ref().clone()) + .unwrap_or_default() + } +} + +fn run_ui(entries: Vec<(String, String)>) { + let app = Application::builder() + .application_id("com.breadway.breadbox") + .build(); + + app.connect_activate(move |app| { + let provider = CssProvider::new(); + provider.load_from_data(CSS); + gtk4::style_context_add_provider_for_display( + &Display::default().expect("no display"), + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + let window = ApplicationWindow::builder() + .application(app) + .default_width(700) + .build(); + + window.init_layer_shell(); + window.set_layer(Layer::Overlay); + window.set_keyboard_mode(KeyboardMode::OnDemand); + window.set_anchor(Edge::Top, true); + window.set_exclusive_zone(-1); + + let vbox = GBox::new(Orientation::Vertical, 0); + + let search = SearchEntry::new(); + search.set_placeholder_text(Some("breadbox")); + vbox.append(&search); + + let scroll = ScrolledWindow::new(); + scroll.set_policy(PolicyType::Never, PolicyType::Automatic); + scroll.set_max_content_height(400); + scroll.set_propagate_natural_height(true); + + let list = ListBox::new(); + list.set_selection_mode(SelectionMode::Browse); + + for (name, action) in &entries { + let row = gtk4::ListBoxRow::new(); + let hbox = GBox::new(Orientation::Horizontal, 8); + hbox.set_margin_start(4); + hbox.set_margin_end(4); + + let name_lbl = Label::new(Some(name)); + name_lbl.set_xalign(0.0); + name_lbl.set_hexpand(true); + hbox.append(&name_lbl); + + let action_lbl = Label::new(Some(action)); + action_lbl.add_css_class("action"); + action_lbl.set_xalign(1.0); + action_lbl.set_ellipsize(EllipsizeMode::End); + action_lbl.set_max_width_chars(50); + hbox.append(&action_lbl); + + row.set_child(Some(&hbox)); + unsafe { + row.set_data("name", name.clone()); + row.set_data("action", action.clone()); + } + list.append(&row); + } + + 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)); + + // Filter rows on every keystroke + let list_f = list.clone(); + search.connect_changed(move |entry| { + let text = entry.text(); + let query = text.as_str(); + let mut first_vis: Option = None; + let mut i = 0i32; + while let Some(row) = list_f.row_at_index(i) { + let name = get_row_data(&row, "name"); + let vis = fuzzy_matches(query, &name); + row.set_visible(vis); + if vis && first_vis.is_none() { + first_vis = Some(row); + } + i += 1; + } + list_f.select_row(first_vis.as_ref()); + }); + + // Keyboard: Esc, Enter, arrows — keep focus in search bar + let key_ctrl = EventControllerKey::new(); + let window_k = window.clone(); + let list_k = list.clone(); + key_ctrl.connect_key_pressed(move |_, key, _, _| { + use gtk4::gdk::Key; + match key { + Key::Escape => { + cleanup_pid(); + window_k.close(); + glib::Propagation::Stop + } + Key::Return | Key::KP_Enter => { + if let Some(row) = list_k.selected_row() { + let action = get_row_data(&row, "action"); + if !action.is_empty() { + do_launch(&action); + cleanup_pid(); + window_k.close(); + } + } + glib::Propagation::Stop + } + Key::Down => { + let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1); + 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 + } + Key::Up => { + let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0); + 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::Proceed, + } + }); + search.add_controller(key_ctrl); + + // Click to launch + let window_a = window.clone(); + list.connect_row_activated(move |_, row| { + let action = get_row_data(row, "action"); + if !action.is_empty() { + do_launch(&action); + cleanup_pid(); + window_a.close(); + } + }); + + // Cleanup pid when window is destroyed for any reason + window.connect_destroy(|_| cleanup_pid()); + + window.present(); + search.grab_focus(); + }); + + app.run(); +} + +// ---- main ----------------------------------------------------------------- + +fn main() { + let cache = cache_path(); + + if env::var("BREADBOX_REBUILD_ONLY").as_deref() == Ok("1") { + build_cache(&cache); + return; + } + + if !toggle_or_continue() { + return; + } + + if !cache.exists() { + build_cache(&cache); + } else if !cache_valid(&cache) { + if let Ok(exe) = env::current_exe() { + let _ = Command::new(exe) + .env("BREADBOX_REBUILD_ONLY", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } + } + + let entries = load_entries(&cache); + run_ui(entries); +} From ba8273b2ca06ccb5c85f84d6d232c8b618154373 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 23 May 2026 12:12:51 +0800 Subject: [PATCH 05/60] breadbox: steal focus on open, close on unfocus Switch KeyboardMode to Exclusive so Hyprland hands keyboard focus to breadbox immediately on creation. Add an EventControllerFocus on the window that calls close() the moment focus leaves (click outside, alt-tab, compositor focus change, etc.). --- src/main.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 52c2a21..e93da63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -331,7 +331,7 @@ fn run_ui(entries: Vec<(String, String)>) { window.init_layer_shell(); window.set_layer(Layer::Overlay); - window.set_keyboard_mode(KeyboardMode::OnDemand); + window.set_keyboard_mode(KeyboardMode::Exclusive); window.set_anchor(Edge::Top, true); window.set_exclusive_zone(-1); @@ -474,6 +474,15 @@ fn run_ui(entries: Vec<(String, String)>) { } }); + // Close when focus leaves the window (click outside, alt-tab, etc.) + let window_foc = window.clone(); + let focus_ctrl = gtk4::EventControllerFocus::new(); + focus_ctrl.connect_leave(move |_| { + cleanup_pid(); + window_foc.close(); + }); + window.add_controller(focus_ctrl); + // Cleanup pid when window is destroyed for any reason window.connect_destroy(|_| cleanup_pid()); From ec07b82747786a3cacecbec233e60a2e2b53028c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 23 May 2026 12:12:51 +0800 Subject: [PATCH 06/60] breadbox: steal focus on open, close on unfocus Switch KeyboardMode to Exclusive so Hyprland hands keyboard focus to breadbox immediately on creation. Add an EventControllerFocus on the window that calls close() the moment focus leaves (click outside, alt-tab, compositor focus change, etc.). Co-Authored-By: Claude Sonnet 4.6 --- src/main.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 52c2a21..e93da63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -331,7 +331,7 @@ fn run_ui(entries: Vec<(String, String)>) { window.init_layer_shell(); window.set_layer(Layer::Overlay); - window.set_keyboard_mode(KeyboardMode::OnDemand); + window.set_keyboard_mode(KeyboardMode::Exclusive); window.set_anchor(Edge::Top, true); window.set_exclusive_zone(-1); @@ -474,6 +474,15 @@ fn run_ui(entries: Vec<(String, String)>) { } }); + // Close when focus leaves the window (click outside, alt-tab, etc.) + let window_foc = window.clone(); + let focus_ctrl = gtk4::EventControllerFocus::new(); + focus_ctrl.connect_leave(move |_| { + cleanup_pid(); + window_foc.close(); + }); + window.add_controller(focus_ctrl); + // Cleanup pid when window is destroyed for any reason window.connect_destroy(|_| cleanup_pid()); From 26431d5969f80340c555ee8b02a0d826b4cff21d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 23 May 2026 12:17:56 +0800 Subject: [PATCH 07/60] breadbox: fix unfocus-close, Enter, and arrow key navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KeyboardMode::Exclusive → OnDemand: with Exclusive the compositor never sends wl_keyboard::leave, so EventControllerFocus::connect_leave never fires. OnDemand lets Hyprland hand focus back on click-outside. - Move EventControllerKey from search to window, capture phase: bubble phase on SearchEntry let its own handlers consume Enter and arrows first. Capture phase on the window intercepts all keys before any widget sees them, so Enter/Up/Down/Esc always reach our handler. --- src/main.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index e93da63..1287c26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -331,7 +331,7 @@ fn run_ui(entries: Vec<(String, String)>) { window.init_layer_shell(); window.set_layer(Layer::Overlay); - window.set_keyboard_mode(KeyboardMode::Exclusive); + window.set_keyboard_mode(KeyboardMode::OnDemand); window.set_anchor(Edge::Top, true); window.set_exclusive_zone(-1); @@ -402,8 +402,10 @@ fn run_ui(entries: Vec<(String, String)>) { list_f.select_row(first_vis.as_ref()); }); - // Keyboard: Esc, Enter, arrows — keep focus in search bar + // Keyboard: Esc, Enter, arrows — capture phase on window so we + // intercept before SearchEntry's own handlers consume them let key_ctrl = EventControllerKey::new(); + key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); let window_k = window.clone(); let list_k = list.clone(); key_ctrl.connect_key_pressed(move |_, key, _, _| { @@ -461,7 +463,7 @@ fn run_ui(entries: Vec<(String, String)>) { _ => glib::Propagation::Proceed, } }); - search.add_controller(key_ctrl); + window.add_controller(key_ctrl); // Click to launch let window_a = window.clone(); From f6653206341334481dbde2bc928b6a570930523f Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 23 May 2026 12:17:56 +0800 Subject: [PATCH 08/60] breadbox: fix unfocus-close, Enter, and arrow key navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KeyboardMode::Exclusive → OnDemand: with Exclusive the compositor never sends wl_keyboard::leave, so EventControllerFocus::connect_leave never fires. OnDemand lets Hyprland hand focus back on click-outside. - Move EventControllerKey from search to window, capture phase: bubble phase on SearchEntry let its own handlers consume Enter and arrows first. Capture phase on the window intercepts all keys before any widget sees them, so Enter/Up/Down/Esc always reach our handler. Co-Authored-By: Claude Sonnet 4.6 --- src/main.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index e93da63..1287c26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -331,7 +331,7 @@ fn run_ui(entries: Vec<(String, String)>) { window.init_layer_shell(); window.set_layer(Layer::Overlay); - window.set_keyboard_mode(KeyboardMode::Exclusive); + window.set_keyboard_mode(KeyboardMode::OnDemand); window.set_anchor(Edge::Top, true); window.set_exclusive_zone(-1); @@ -402,8 +402,10 @@ fn run_ui(entries: Vec<(String, String)>) { list_f.select_row(first_vis.as_ref()); }); - // Keyboard: Esc, Enter, arrows — keep focus in search bar + // Keyboard: Esc, Enter, arrows — capture phase on window so we + // intercept before SearchEntry's own handlers consume them let key_ctrl = EventControllerKey::new(); + key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); let window_k = window.clone(); let list_k = list.clone(); key_ctrl.connect_key_pressed(move |_, key, _, _| { @@ -461,7 +463,7 @@ fn run_ui(entries: Vec<(String, String)>) { _ => glib::Propagation::Proceed, } }); - search.add_controller(key_ctrl); + window.add_controller(key_ctrl); // Click to launch let window_a = window.clone(); From 74de775a9b7b28bb5e53019e495e43627ed5e183 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 23 May 2026 12:31:14 +0800 Subject: [PATCH 09/60] breadbox: fix spurious close via transparent backdrop window EventControllerFocus::connect_leave was firing whenever the pointer left the launcher surface because Hyprland's focus-follows-mouse hands keyboard focus back to the window under the cursor (OnDemand mode). Fix: introduce a full-screen transparent backdrop window at Layer::Top. The launcher sits at Layer::Overlay (above it), so the backdrop never intercepts launcher clicks. Clicking anywhere outside the launcher hits the backdrop and closes both windows via a shared Rc callback. The launcher returns to KeyboardMode::Exclusive so the compositor can no longer steal focus on pointer leave. EventControllerFocus is removed. --- src/main.rs | 68 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1287c26..9109f26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use std::{ io::{BufRead, BufReader, Write}, path::{Path, PathBuf}, process::{Command, Stdio}, + rc::Rc, time::{SystemTime, UNIX_EPOCH}, }; @@ -21,7 +22,10 @@ use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; const CACHE_TIMEOUT_SECS: u64 = 86400; const CSS: &str = " -window, .background { +window { + background-color: transparent; +} +.launcher-bg { background-color: #1e1e2e; } searchentry { @@ -324,18 +328,45 @@ fn run_ui(entries: Vec<(String, String)>) { gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, ); + // Full-screen transparent backdrop at Layer::Top — catches clicks + // outside the launcher and closes it. Sits below the launcher + // (Layer::Overlay) so it never intercepts clicks on the UI itself. + let backdrop = ApplicationWindow::builder() + .application(app) + .build(); + backdrop.init_layer_shell(); + backdrop.set_layer(Layer::Top); + backdrop.set_keyboard_mode(KeyboardMode::None); + for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { + backdrop.set_anchor(edge, true); + } + + // Main launcher window let window = ApplicationWindow::builder() .application(app) .default_width(700) .build(); - window.init_layer_shell(); window.set_layer(Layer::Overlay); - window.set_keyboard_mode(KeyboardMode::OnDemand); + // Exclusive: compositor won't hand focus to another window on pointer + // leave, so the backdrop (not a focus event) handles click-outside. + window.set_keyboard_mode(KeyboardMode::Exclusive); window.set_anchor(Edge::Top, true); window.set_exclusive_zone(-1); + // Shared close: dismisses both windows and cleans up the PID file. + let close_all: Rc = Rc::new({ + let w = window.clone(); + let b = backdrop.clone(); + move || { + cleanup_pid(); + w.close(); + b.close(); + } + }); + let vbox = GBox::new(Orientation::Vertical, 0); + vbox.add_css_class("launcher-bg"); let search = SearchEntry::new(); search.set_placeholder_text(Some("breadbox")); @@ -406,14 +437,13 @@ fn run_ui(entries: Vec<(String, String)>) { // intercept before SearchEntry's own handlers consume them let key_ctrl = EventControllerKey::new(); key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); - let window_k = window.clone(); + let close_k = Rc::clone(&close_all); let list_k = list.clone(); key_ctrl.connect_key_pressed(move |_, key, _, _| { use gtk4::gdk::Key; match key { Key::Escape => { - cleanup_pid(); - window_k.close(); + close_k(); glib::Propagation::Stop } Key::Return | Key::KP_Enter => { @@ -421,8 +451,7 @@ fn run_ui(entries: Vec<(String, String)>) { let action = get_row_data(&row, "action"); if !action.is_empty() { do_launch(&action); - cleanup_pid(); - window_k.close(); + close_k(); } } glib::Propagation::Stop @@ -465,29 +494,26 @@ fn run_ui(entries: Vec<(String, String)>) { }); window.add_controller(key_ctrl); - // Click to launch - let window_a = window.clone(); + // Row click / Enter activates launch + let close_a = Rc::clone(&close_all); list.connect_row_activated(move |_, row| { let action = get_row_data(row, "action"); if !action.is_empty() { do_launch(&action); - cleanup_pid(); - window_a.close(); + close_a(); } }); - // Close when focus leaves the window (click outside, alt-tab, etc.) - let window_foc = window.clone(); - let focus_ctrl = gtk4::EventControllerFocus::new(); - focus_ctrl.connect_leave(move |_| { - cleanup_pid(); - window_foc.close(); - }); - window.add_controller(focus_ctrl); + // Backdrop click: user clicked outside the launcher + let close_bd = Rc::clone(&close_all); + let click = gtk4::GestureClick::new(); + click.connect_released(move |_, _, _, _| close_bd()); + backdrop.add_controller(click); - // Cleanup pid when window is destroyed for any reason + // Safety net: clean up PID if the window is destroyed by the compositor window.connect_destroy(|_| cleanup_pid()); + backdrop.present(); window.present(); search.grab_focus(); }); From afd1f8f9e2972ebf12eadc99ee91aa6b0472d133 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 23 May 2026 12:31:14 +0800 Subject: [PATCH 10/60] breadbox: fix spurious close via transparent backdrop window EventControllerFocus::connect_leave was firing whenever the pointer left the launcher surface because Hyprland's focus-follows-mouse hands keyboard focus back to the window under the cursor (OnDemand mode). Fix: introduce a full-screen transparent backdrop window at Layer::Top. The launcher sits at Layer::Overlay (above it), so the backdrop never intercepts launcher clicks. Clicking anywhere outside the launcher hits the backdrop and closes both windows via a shared Rc callback. The launcher returns to KeyboardMode::Exclusive so the compositor can no longer steal focus on pointer leave. EventControllerFocus is removed. Co-Authored-By: Claude Sonnet 4.6 --- src/main.rs | 68 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1287c26..9109f26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use std::{ io::{BufRead, BufReader, Write}, path::{Path, PathBuf}, process::{Command, Stdio}, + rc::Rc, time::{SystemTime, UNIX_EPOCH}, }; @@ -21,7 +22,10 @@ use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; const CACHE_TIMEOUT_SECS: u64 = 86400; const CSS: &str = " -window, .background { +window { + background-color: transparent; +} +.launcher-bg { background-color: #1e1e2e; } searchentry { @@ -324,18 +328,45 @@ fn run_ui(entries: Vec<(String, String)>) { gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, ); + // Full-screen transparent backdrop at Layer::Top — catches clicks + // outside the launcher and closes it. Sits below the launcher + // (Layer::Overlay) so it never intercepts clicks on the UI itself. + let backdrop = ApplicationWindow::builder() + .application(app) + .build(); + backdrop.init_layer_shell(); + backdrop.set_layer(Layer::Top); + backdrop.set_keyboard_mode(KeyboardMode::None); + for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { + backdrop.set_anchor(edge, true); + } + + // Main launcher window let window = ApplicationWindow::builder() .application(app) .default_width(700) .build(); - window.init_layer_shell(); window.set_layer(Layer::Overlay); - window.set_keyboard_mode(KeyboardMode::OnDemand); + // Exclusive: compositor won't hand focus to another window on pointer + // leave, so the backdrop (not a focus event) handles click-outside. + window.set_keyboard_mode(KeyboardMode::Exclusive); window.set_anchor(Edge::Top, true); window.set_exclusive_zone(-1); + // Shared close: dismisses both windows and cleans up the PID file. + let close_all: Rc = Rc::new({ + let w = window.clone(); + let b = backdrop.clone(); + move || { + cleanup_pid(); + w.close(); + b.close(); + } + }); + let vbox = GBox::new(Orientation::Vertical, 0); + vbox.add_css_class("launcher-bg"); let search = SearchEntry::new(); search.set_placeholder_text(Some("breadbox")); @@ -406,14 +437,13 @@ fn run_ui(entries: Vec<(String, String)>) { // intercept before SearchEntry's own handlers consume them let key_ctrl = EventControllerKey::new(); key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); - let window_k = window.clone(); + let close_k = Rc::clone(&close_all); let list_k = list.clone(); key_ctrl.connect_key_pressed(move |_, key, _, _| { use gtk4::gdk::Key; match key { Key::Escape => { - cleanup_pid(); - window_k.close(); + close_k(); glib::Propagation::Stop } Key::Return | Key::KP_Enter => { @@ -421,8 +451,7 @@ fn run_ui(entries: Vec<(String, String)>) { let action = get_row_data(&row, "action"); if !action.is_empty() { do_launch(&action); - cleanup_pid(); - window_k.close(); + close_k(); } } glib::Propagation::Stop @@ -465,29 +494,26 @@ fn run_ui(entries: Vec<(String, String)>) { }); window.add_controller(key_ctrl); - // Click to launch - let window_a = window.clone(); + // Row click / Enter activates launch + let close_a = Rc::clone(&close_all); list.connect_row_activated(move |_, row| { let action = get_row_data(row, "action"); if !action.is_empty() { do_launch(&action); - cleanup_pid(); - window_a.close(); + close_a(); } }); - // Close when focus leaves the window (click outside, alt-tab, etc.) - let window_foc = window.clone(); - let focus_ctrl = gtk4::EventControllerFocus::new(); - focus_ctrl.connect_leave(move |_| { - cleanup_pid(); - window_foc.close(); - }); - window.add_controller(focus_ctrl); + // Backdrop click: user clicked outside the launcher + let close_bd = Rc::clone(&close_all); + let click = gtk4::GestureClick::new(); + click.connect_released(move |_, _, _, _| close_bd()); + backdrop.add_controller(click); - // Cleanup pid when window is destroyed for any reason + // Safety net: clean up PID if the window is destroyed by the compositor window.connect_destroy(|_| cleanup_pid()); + backdrop.present(); window.present(); search.grab_focus(); }); From f4996e495ff187ecd21c353227ce4c125e2ca45d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 23 May 2026 12:36:18 +0800 Subject: [PATCH 11/60] breadbox: replace backdrop window with full-screen single window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backdrop approach failed because a GTK4 window with no child widget gets an empty Wayland input region, so pointer events passed straight through it rather than being delivered to the GestureClick handler. Replace with a single full-screen window (anchored to all 4 edges, transparent background). The launcher UI (vbox) is 700 px wide, halign=Center, valign=Start — visually identical to before. A GestureClick on the window closes it when (x,y) falls outside the vbox's allocation rectangle; clicks inside the vbox reach child widgets normally and don't trigger the close path. --- src/main.rs | 59 ++++++++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9109f26..a84efdc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -328,45 +328,35 @@ fn run_ui(entries: Vec<(String, String)>) { gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, ); - // Full-screen transparent backdrop at Layer::Top — catches clicks - // outside the launcher and closes it. Sits below the launcher - // (Layer::Overlay) so it never intercepts clicks on the UI itself. - let backdrop = ApplicationWindow::builder() - .application(app) - .build(); - backdrop.init_layer_shell(); - backdrop.set_layer(Layer::Top); - backdrop.set_keyboard_mode(KeyboardMode::None); - for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { - backdrop.set_anchor(edge, true); - } - - // Main launcher window + // Single full-screen window covers the entire monitor. The window + // background is transparent; only the launcher vbox is visible. + // Clicks outside the vbox are detected via coordinate check and + // close the window. KeyboardMode::Exclusive keeps focus stable so + // pointer-leave events never steal it away. let window = ApplicationWindow::builder() .application(app) - .default_width(700) .build(); window.init_layer_shell(); window.set_layer(Layer::Overlay); - // Exclusive: compositor won't hand focus to another window on pointer - // leave, so the backdrop (not a focus event) handles click-outside. window.set_keyboard_mode(KeyboardMode::Exclusive); - window.set_anchor(Edge::Top, true); - window.set_exclusive_zone(-1); + for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { + window.set_anchor(edge, true); + } + window.set_exclusive_zone(0); - // Shared close: dismisses both windows and cleans up the PID file. let close_all: Rc = Rc::new({ let w = window.clone(); - let b = backdrop.clone(); move || { cleanup_pid(); w.close(); - b.close(); } }); let vbox = GBox::new(Orientation::Vertical, 0); vbox.add_css_class("launcher-bg"); + vbox.set_halign(gtk4::Align::Center); + vbox.set_valign(gtk4::Align::Start); + vbox.set_size_request(700, -1); let search = SearchEntry::new(); search.set_placeholder_text(Some("breadbox")); @@ -504,16 +494,29 @@ fn run_ui(entries: Vec<(String, String)>) { } }); - // Backdrop click: user clicked outside the launcher - let close_bd = Rc::clone(&close_all); - let click = gtk4::GestureClick::new(); - click.connect_released(move |_, _, _, _| close_bd()); - backdrop.add_controller(click); + // Close when clicking outside the launcher box. + // connect_pressed fires before child widgets handle the click, so + // (x, y) are window-relative and always available. Clicks inside the + // vbox are within its allocation and are ignored; everything outside + // (the transparent full-screen area) dismisses the launcher. + let close_outside = Rc::clone(&close_all); + let vbox_ref = vbox.clone(); + let outside_click = gtk4::GestureClick::new(); + outside_click.connect_pressed(move |_, _, x, y| { + let a = vbox_ref.allocation(); + if x < a.x() as f64 + || x > (a.x() + a.width()) as f64 + || y < a.y() as f64 + || y > (a.y() + a.height()) as f64 + { + close_outside(); + } + }); + window.add_controller(outside_click); // Safety net: clean up PID if the window is destroyed by the compositor window.connect_destroy(|_| cleanup_pid()); - backdrop.present(); window.present(); search.grab_focus(); }); From 7df0003c2cb6b55d8488897c3e760baa190c09e4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 23 May 2026 12:36:18 +0800 Subject: [PATCH 12/60] breadbox: replace backdrop window with full-screen single window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backdrop approach failed because a GTK4 window with no child widget gets an empty Wayland input region, so pointer events passed straight through it rather than being delivered to the GestureClick handler. Replace with a single full-screen window (anchored to all 4 edges, transparent background). The launcher UI (vbox) is 700 px wide, halign=Center, valign=Start — visually identical to before. A GestureClick on the window closes it when (x,y) falls outside the vbox's allocation rectangle; clicks inside the vbox reach child widgets normally and don't trigger the close path. Co-Authored-By: Claude Sonnet 4.6 --- src/main.rs | 59 ++++++++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9109f26..a84efdc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -328,45 +328,35 @@ fn run_ui(entries: Vec<(String, String)>) { gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, ); - // Full-screen transparent backdrop at Layer::Top — catches clicks - // outside the launcher and closes it. Sits below the launcher - // (Layer::Overlay) so it never intercepts clicks on the UI itself. - let backdrop = ApplicationWindow::builder() - .application(app) - .build(); - backdrop.init_layer_shell(); - backdrop.set_layer(Layer::Top); - backdrop.set_keyboard_mode(KeyboardMode::None); - for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { - backdrop.set_anchor(edge, true); - } - - // Main launcher window + // Single full-screen window covers the entire monitor. The window + // background is transparent; only the launcher vbox is visible. + // Clicks outside the vbox are detected via coordinate check and + // close the window. KeyboardMode::Exclusive keeps focus stable so + // pointer-leave events never steal it away. let window = ApplicationWindow::builder() .application(app) - .default_width(700) .build(); window.init_layer_shell(); window.set_layer(Layer::Overlay); - // Exclusive: compositor won't hand focus to another window on pointer - // leave, so the backdrop (not a focus event) handles click-outside. window.set_keyboard_mode(KeyboardMode::Exclusive); - window.set_anchor(Edge::Top, true); - window.set_exclusive_zone(-1); + for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { + window.set_anchor(edge, true); + } + window.set_exclusive_zone(0); - // Shared close: dismisses both windows and cleans up the PID file. let close_all: Rc = Rc::new({ let w = window.clone(); - let b = backdrop.clone(); move || { cleanup_pid(); w.close(); - b.close(); } }); let vbox = GBox::new(Orientation::Vertical, 0); vbox.add_css_class("launcher-bg"); + vbox.set_halign(gtk4::Align::Center); + vbox.set_valign(gtk4::Align::Start); + vbox.set_size_request(700, -1); let search = SearchEntry::new(); search.set_placeholder_text(Some("breadbox")); @@ -504,16 +494,29 @@ fn run_ui(entries: Vec<(String, String)>) { } }); - // Backdrop click: user clicked outside the launcher - let close_bd = Rc::clone(&close_all); - let click = gtk4::GestureClick::new(); - click.connect_released(move |_, _, _, _| close_bd()); - backdrop.add_controller(click); + // Close when clicking outside the launcher box. + // connect_pressed fires before child widgets handle the click, so + // (x, y) are window-relative and always available. Clicks inside the + // vbox are within its allocation and are ignored; everything outside + // (the transparent full-screen area) dismisses the launcher. + let close_outside = Rc::clone(&close_all); + let vbox_ref = vbox.clone(); + let outside_click = gtk4::GestureClick::new(); + outside_click.connect_pressed(move |_, _, x, y| { + let a = vbox_ref.allocation(); + if x < a.x() as f64 + || x > (a.x() + a.width()) as f64 + || y < a.y() as f64 + || y > (a.y() + a.height()) as f64 + { + close_outside(); + } + }); + window.add_controller(outside_click); // Safety net: clean up PID if the window is destroyed by the compositor window.connect_destroy(|_| cleanup_pid()); - backdrop.present(); window.present(); search.grab_focus(); }); From d823edc14ec6e9092befaaff8846a242ec6ad229 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 24 May 2026 18:57:01 +0800 Subject: [PATCH 13/60] can't be bothered writing a commit message --- Cargo.lock | 675 +++++++++++++++++++++++++++++++- Cargo.toml | 11 +- README.md | 124 ++++++ breadbox-shared/Cargo.toml | 9 + breadbox-shared/src/lib.rs | 261 ++++++++++++ breadbox-sync/Cargo.toml | 14 + breadbox-sync/src/main.rs | 281 +++++++++++++ breadbox/Cargo.toml | 15 + breadbox/src/main.rs | 564 ++++++++++++++++++++++++++ config.example.toml | 19 + packaging/breadbox-sync.service | 15 + src/main.rs | 556 -------------------------- 12 files changed, 1971 insertions(+), 573 deletions(-) create mode 100644 README.md create mode 100644 breadbox-shared/Cargo.toml create mode 100644 breadbox-shared/src/lib.rs create mode 100644 breadbox-sync/Cargo.toml create mode 100644 breadbox-sync/src/main.rs create mode 100644 breadbox/Cargo.toml create mode 100644 breadbox/src/main.rs create mode 100644 config.example.toml create mode 100644 packaging/breadbox-sync.service delete mode 100644 src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 76771c4..0e8046d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,24 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.1" @@ -18,8 +30,27 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" name = "breadbox" version = "0.1.0" dependencies = [ + "breadbox-shared", "gtk4", "gtk4-layer-shell", + "serde_json", +] + +[[package]] +name = "breadbox-shared" +version = "0.1.0" +dependencies = [ + "serde", + "toml 0.8.23", +] + +[[package]] +name = "breadbox-sync" +version = "0.1.0" +dependencies = [ + "breadbox-shared", + "serde_json", + "ureq", ] [[package]] @@ -45,6 +76,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-expr" version = "0.20.7" @@ -55,6 +96,32 @@ dependencies = [ "target-lexicon", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -71,6 +138,31 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -191,6 +283,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "gio" version = "0.22.6" @@ -218,7 +321,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -441,6 +544,109 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -451,6 +657,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "khronos_api" version = "3.1.0" @@ -463,6 +675,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.29" @@ -484,6 +702,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "pango" version = "0.22.6" @@ -508,6 +742,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -520,13 +760,22 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -547,6 +796,20 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -556,12 +819,57 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -582,6 +890,28 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -591,6 +921,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -603,6 +945,18 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -614,6 +968,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "system-deps" version = "7.0.8" @@ -623,7 +988,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "toml 1.1.2+spec-1.1.0", "version-compare", ] @@ -633,6 +998,28 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -641,11 +1028,20 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -657,6 +1053,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.11+spec-1.1.0" @@ -664,9 +1074,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -675,9 +1085,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -690,18 +1106,91 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "version-compare" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -711,6 +1200,79 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.3" @@ -720,8 +1282,103 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "xml-rs" version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 7e0d3ae..2c3d18a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,3 @@ -[package] -name = "breadbox" -version = "0.1.0" -edition = "2021" - -[dependencies] -gtk4 = "0.11" -gtk4-layer-shell = "0.8" +[workspace] +members = ["breadbox-shared", "breadbox-sync", "breadbox"] +resolver = "2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b6ce13 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# breadbox + +A GTK4 app launcher for Hyprland / Wayland on Arch Linux. + +``` +breadbox-shared shared types (DesktopEntry, IconCache, Config) +breadbox-sync standalone icon resolution + caching binary +breadbox GTK4 layer-shell launcher +``` + +## Features + +- Layer-shell window, centered 600 px wide, keyboard-exclusive +- Reads the active Hyprland workspace and sorts apps by context priority +- Fuzzy filtering as you type; Enter launches, Escape closes +- App icons loaded from the resolved icon cache (see `breadbox-sync`) +- pywal palette auto-detected from `~/.cache/wal/colors.json`, falls back to Catppuccin Mocha +- User CSS override at `~/.config/breadbox/style.css` +- Toggle/dismiss: running a second instance kills the first + +## Build dependencies + +``` +gtk4 (pacman -S gtk4) +gtk4-layer-shell (pacman -S gtk4-layer-shell) +librsvg (pacman -S librsvg) # for SVG icon support +rust (stable) (rustup toolchain install stable) +``` + +## Build + +```bash +# debug +cargo build + +# release (recommended — put both binaries on $PATH) +cargo build --release +# binaries are at target/release/breadbox and target/release/breadbox-sync +``` + +Install to `~/.cargo/bin` (or anywhere on your PATH): + +```bash +cargo install --path breadbox +cargo install --path breadbox-sync +``` + +## Configuration + +Copy and edit the example config: + +```bash +mkdir -p ~/.config/breadbox +cp config.example.toml ~/.config/breadbox/config.toml +``` + +The `[[context]]` blocks map Hyprland workspace names to app priority lists. +Workspace name `"default"` is the catch-all fallback. + +```toml +[[context]] +name = "default" +priority = ["firefox", "code", "obsidian", "kitty"] + +[[context]] +name = "2" +priority = ["slack", "discord"] +``` + +### CSS theming + +breadbox applies pywal colors automatically when `~/.cache/wal/colors.json` is +present. To override or extend the theme: + +```bash +~/.config/breadbox/style.css +``` + +This file is loaded at the highest CSS priority level, so any rule here wins. + +## Icon sync + +`breadbox-sync` resolves icons for all installed apps and writes them to +`~/.cache/breadbox/`. Run it once before first launch: + +```bash +breadbox-sync +``` + +Icon resolution order: +1. System icon theme (`~/.local/share/icons`, `/usr/share/icons`, `/usr/share/pixmaps`) — 64 px > 48 px PNG, then SVG +2. Flathub media server — for reverse-DNS app IDs (e.g. `org.gnome.Gedit`) +3. icon.horse — downloaded and cached +4. `application-x-executable` fallback from system theme + +### Systemd service (run on login) + +```bash +cp packaging/breadbox-sync.service ~/.config/systemd/user/ +systemctl --user enable --now breadbox-sync.service +``` + +The service runs `breadbox-sync` once at login (after network is up) and logs +to journald. Re-run manually after installing new apps: + +```bash +systemctl --user start breadbox-sync.service +# or just: +breadbox-sync +``` + +## Hyprland keybind + +Add to `~/.config/hypr/hyprland.conf`: + +``` +bind = $mainMod, SPACE, exec, breadbox +``` + +Pressing the keybind again while the launcher is open dismisses it. + +## Licence + +MIT — see [LICENSE](LICENSE). diff --git a/breadbox-shared/Cargo.toml b/breadbox-shared/Cargo.toml new file mode 100644 index 0000000..1600acd --- /dev/null +++ b/breadbox-shared/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "breadbox-shared" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] +serde = { version = "1", features = ["derive"] } +toml = "0.8" diff --git a/breadbox-shared/src/lib.rs b/breadbox-shared/src/lib.rs new file mode 100644 index 0000000..8ce6f7d --- /dev/null +++ b/breadbox-shared/src/lib.rs @@ -0,0 +1,261 @@ +use std::{ + env, + fs::{self, File}, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +// ---- XDG path helpers ------------------------------------------------------- + +pub fn home_dir() -> PathBuf { + PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into())) +} + +pub fn cache_dir() -> PathBuf { + env::var("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".cache")) + .join("breadbox") +} + +pub fn config_dir() -> PathBuf { + env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".config")) + .join("breadbox") +} + +pub fn app_dirs() -> Vec { + let home = home_dir(); + 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 ---------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct DesktopEntry { + pub name: String, + pub exec: String, + pub icon_name: String, + pub icon_path: Option, // resolved by caller from manifest + pub categories: Vec, + pub wm_class: Option, + pub terminal: bool, +} + +pub fn strip_exec_codes(exec: &str) -> String { + let mut out = String::with_capacity(exec.len()); + 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). +pub fn parse_desktop(path: &Path) -> Option { + let file = File::open(path).ok()?; + let mut in_entry = false; + let mut name: Option = None; + let mut exec: Option = None; + let mut icon: Option = None; + let mut categories: Option = None; + let mut wm_class: Option = None; + let mut app_type: Option = 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(); + + Some(DesktopEntry { + 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 { + let mut seen: std::collections::HashMap = 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() + } +} + +// ---- Config ----------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + #[serde(default, rename = "context")] + pub contexts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Context { + pub name: String, + #[serde(default)] + pub priority: Vec, +} + +impl Config { + pub fn load() -> Self { + let path = config_dir().join("config.toml"); + let content = match fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(), + Err(e) => { + eprintln!("breadbox: could not read {}: {}", path.display(), e); + return Self::default(); + } + }; + match toml::from_str(&content) { + Ok(c) => c, + Err(e) => { + eprintln!("breadbox: parse error in {}: {}", path.display(), e); + Self::default() + } + } + } + + /// Find the context matching `workspace`, falling back to "default", then + /// returning None if neither exists. + pub fn context_for(&self, workspace: &str) -> Option<&Context> { + self.contexts + .iter() + .find(|c| c.name == workspace) + .or_else(|| self.contexts.iter().find(|c| c.name == "default")) + } +} diff --git a/breadbox-sync/Cargo.toml b/breadbox-sync/Cargo.toml new file mode 100644 index 0000000..1c68856 --- /dev/null +++ b/breadbox-sync/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "breadbox-sync" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[[bin]] +name = "breadbox-sync" +path = "src/main.rs" + +[dependencies] +breadbox-shared = { path = "../breadbox-shared" } +serde_json = "1" +ureq = "2" diff --git a/breadbox-sync/src/main.rs b/breadbox-sync/src/main.rs new file mode 100644 index 0000000..8c919bf --- /dev/null +++ b/breadbox-sync/src/main.rs @@ -0,0 +1,281 @@ +use std::{ + collections::HashMap, + env, + fs, + io::Read, + path::{Path, PathBuf}, +}; + +use breadbox_shared::{home_dir, IconCache}; + +// ---- Icon theme lookup ------------------------------------------------------ + +fn current_icon_theme() -> String { + let home = home_dir(); + for cfg in [ + home.join(".config/gtk-4.0/settings.ini"), + home.join(".config/gtk-3.0/settings.ini"), + ] { + if let Ok(content) = fs::read_to_string(&cfg) { + for line in content.lines() { + if let Some(v) = line.strip_prefix("gtk-icon-theme-name=") { + let t = v.trim().trim_matches('"'); + if !t.is_empty() { + return t.to_string(); + } + } + } + } + } + "hicolor".to_string() +} + +fn icon_search_dirs() -> Vec { + let home = home_dir(); + let xdg_data_home = env::var("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home.join(".local/share")); + + let mut dirs = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for d in [ + xdg_data_home.join("icons"), + home.join(".local/share/icons"), + PathBuf::from("/usr/share/icons"), + ] { + if seen.insert(d.clone()) { + dirs.push(d); + } + } + dirs +} + +/// Search for `name` in system icon theme directories. +/// Prefers 64px > 48px > 128px > 32px > 256px PNG, then scalable SVG. +fn find_system_icon(name: &str, theme: &str) -> Option { + let sizes = ["64x64", "48x48", "128x128", "32x32", "256x256"]; + let dirs = icon_search_dirs(); + + let themes: Vec<&str> = if theme != "hicolor" { + vec![theme, "hicolor"] + } else { + vec!["hicolor"] + }; + + for dir in &dirs { + for t in &themes { + for size in &sizes { + let p = dir.join(t).join(size).join("apps").join(format!("{}.png", name)); + if p.exists() { + return Some(p); + } + // Alternative path layout: /apps// + let p2 = dir.join(t).join("apps").join(size).join(format!("{}.png", name)); + if p2.exists() { + return Some(p2); + } + } + // SVG (scalable) + for subdir in ["scalable/apps", "apps/scalable"] { + let p = dir.join(t).join(subdir).join(format!("{}.svg", name)); + if p.exists() { + return Some(p); + } + } + } + } + + // /usr/share/pixmaps + for ext in ["png", "svg", "xpm"] { + let p = PathBuf::from("/usr/share/pixmaps").join(format!("{}.{}", name, ext)); + if p.exists() { + return Some(p); + } + } + + None +} + +// ---- Helpers ---------------------------------------------------------------- + +/// Strip file extension from an icon field value, returning the canonical name. +fn canonical_icon_name(icon: &str) -> String { + if icon.starts_with('/') { + return Path::new(icon) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(icon) + .to_string(); + } + icon.strip_suffix(".png") + .or_else(|| icon.strip_suffix(".svg")) + .or_else(|| icon.strip_suffix(".xpm")) + .unwrap_or(icon) + .to_string() +} + +/// A stem like `org.gnome.Gedit` or `com.github.App` — at least three segments, +/// all alphanumeric/hyphen/underscore. +fn looks_like_reverse_dns(stem: &str) -> bool { + let parts: Vec<&str> = stem.split('.').collect(); + parts.len() >= 3 + && parts[0].len() >= 2 + && parts.iter().all(|p| { + !p.is_empty() + && p.chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + }) +} + +/// Try to GET `url` and write the body to `dest`. Returns true on success. +fn try_download(agent: &ureq::Agent, url: &str, dest: &Path) -> bool { + let resp = match agent.get(url).call() { + Ok(r) if r.status() == 200 => r, + _ => return false, + }; + let mut bytes = Vec::new(); + if resp.into_reader().take(2_097_152).read_to_end(&mut bytes).is_err() || bytes.is_empty() { + return false; + } + // Validate the PNG signature so a 200 error page is never cached as an icon. + const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + if !bytes.starts_with(&PNG_MAGIC) { + return false; + } + fs::write(dest, &bytes).is_ok() +} + +/// Resolve an icon to a local path, downloading if necessary. +/// Returns None only if all strategies fail and no generic fallback is found. +fn resolve_icon( + icon_field: &str, + desktop_stem: &str, + theme: &str, + icon_cache: &IconCache, + agent: &ureq::Agent, +) -> Option { + // Absolute path in Icon= field + if icon_field.starts_with('/') { + let p = PathBuf::from(icon_field); + if p.exists() { + return Some(p); + } + } + + let name = canonical_icon_name(icon_field); + if name.is_empty() { + return find_system_icon("application-x-executable", theme); + } + + // 1. System icon theme + if let Some(p) = find_system_icon(&name, theme) { + return Some(p); + } + + // Already cached from a previous run? + let cached = icon_cache.path_for(&name); + if cached.exists() { + return Some(cached); + } + + // 2. Flathub (appstream icon path, not the media CDN) + if looks_like_reverse_dns(desktop_stem) { + let url = format!( + "https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/{}.png", + desktop_stem + ); + let dest = icon_cache.path_for(desktop_stem); + if try_download(agent, &url, &dest) { + eprintln!(" [flathub] {}", desktop_stem); + return Some(dest); + } + } + + // 3. Generic fallback + find_system_icon("application-x-executable", theme) +} + +// ---- Main ------------------------------------------------------------------- + +fn main() { + if let Err(e) = run() { + eprintln!("breadbox-sync: {}", e); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let icon_cache = IconCache::new(); + icon_cache.ensure_dir()?; + + let theme = current_icon_theme(); + eprintln!("breadbox-sync: icon theme = {}", theme); + + let agent = ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(10)) + .build(); + + let mut manifest: HashMap = HashMap::new(); + + // Walk directories directly to get both the entry and its filename stem + // (needed for Flathub reverse-DNS resolution). + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + for dir in breadbox_shared::app_dirs() { + let Ok(read_dir) = fs::read_dir(&dir) else { continue }; + for file_entry in read_dir.flatten() { + let path = file_entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("desktop") { + continue; + } + + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + // User-local overrides system; process in dir order (system first, local last). + // Later entries for the same stem will overwrite earlier ones in the manifest. + + let app = match breadbox_shared::parse_desktop(&path) { + Some(a) => a, + None => continue, + }; + + if app.icon_name.is_empty() { + continue; + } + + // Deduplicate by the raw Icon= value, which is also the manifest key, + // so every distinct icon_name gets its own entry. + if !seen.insert(app.icon_name.clone()) { + continue; + } + + eprint!("resolving icon for {} ({}) ... ", app.name, app.icon_name); + match resolve_icon(&app.icon_name, &stem, &theme, &icon_cache, &agent) { + Some(p) => { + eprintln!("{}", p.display()); + manifest.insert(app.icon_name.clone(), p.to_string_lossy().into_owned()); + } + None => { + eprintln!("not found"); + } + } + } + } + + let manifest_path = IconCache::manifest_path(); + let json = serde_json::to_string_pretty(&manifest)?; + let tmp = manifest_path.with_extension("tmp"); + fs::write(&tmp, &json)?; + fs::rename(&tmp, &manifest_path)?; + + eprintln!( + "breadbox-sync: wrote manifest ({} entries) to {}", + manifest.len(), + manifest_path.display() + ); + Ok(()) +} diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml new file mode 100644 index 0000000..343be40 --- /dev/null +++ b/breadbox/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "breadbox" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[[bin]] +name = "breadbox" +path = "src/main.rs" + +[dependencies] +breadbox-shared = { path = "../breadbox-shared" } +gtk4 = { version = "0.11", features = ["v4_12"] } +gtk4-layer-shell = "0.8" +serde_json = "1" diff --git a/breadbox/src/main.rs b/breadbox/src/main.rs new file mode 100644 index 0000000..8b63052 --- /dev/null +++ b/breadbox/src/main.rs @@ -0,0 +1,564 @@ +use std::{ + collections::HashMap, + env, + fs, + io::{Read, Write}, + os::unix::net::UnixStream, + path::{Path, PathBuf}, + process::{Command, Stdio}, + rc::Rc, +}; + +use breadbox_shared::{ + config_dir, home_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, +}; +use gtk4::{ + gdk::Display, + glib, + pango::EllipsizeMode, + prelude::*, + Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label, + ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, +}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; + +// ---- Hyprland IPC ----------------------------------------------------------- + +fn get_active_workspace() -> Option { + let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; + let rt = env::var("XDG_RUNTIME_DIR").ok()?; + let socket_path = format!("{}/hypr/{}/.socket.sock", rt, sig); + + let mut stream = UnixStream::connect(&socket_path).ok()?; + stream.write_all(b"j/activeworkspace").ok()?; + stream.shutdown(std::net::Shutdown::Write).ok()?; + + let mut response = String::new(); + stream.read_to_string(&mut response).ok()?; + + let v: serde_json::Value = serde_json::from_str(&response).ok()?; + v["name"].as_str().map(|s| s.to_string()) +} + +// ---- Manifest --------------------------------------------------------------- + +fn load_manifest() -> HashMap { + let path = IconCache::manifest_path(); + let content = fs::read_to_string(&path).unwrap_or_default(); + serde_json::from_str::>(&content) + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, PathBuf::from(v))) + .collect() +} + +// ---- Entry loading and sorting ---------------------------------------------- + +fn load_sorted_entries( + manifest: &HashMap, + priority: &[String], +) -> Vec { + 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 = 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) => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + } + }); + + entries +} + +fn priority_rank(entry: &DesktopEntry, priority_lower: &[String]) -> Option { + 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 ---------------------------------------------------------------- + +#[derive(Debug)] +struct Palette { + bg: String, + surface: String, + fg: String, + accent: String, +} + +impl Palette { + fn catppuccin_mocha() -> Self { + Palette { + bg: "#1e1e2e".into(), + surface: "#181825".into(), + fg: "#cdd6f4".into(), + accent: "#89b4fa".into(), + } + } + + fn from_wal() -> Option { + let path = env::var("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".cache")) + .join("wal/colors.json"); + let content = fs::read_to_string(&path).ok()?; + let v: serde_json::Value = serde_json::from_str(&content).ok()?; + + let spec = &v["special"]; + let cols = &v["colors"]; + + let bg = spec["background"].as_str()?.to_string(); + let surface = cols["color0"].as_str().unwrap_or(&bg).to_string(); + let fg = cols["color15"].as_str().unwrap_or("#cdd6f4").to_string(); + let accent = cols["color1"].as_str().unwrap_or("#89b4fa").to_string(); + + Some(Palette { bg, surface, fg, accent }) + } +} + +fn hex_to_rgba(hex: &str, alpha: f32) -> String { + let h = hex.trim_start_matches('#'); + let r = u8::from_str_radix(h.get(0..2).unwrap_or("00"), 16).unwrap_or(0); + let g = u8::from_str_radix(h.get(2..4).unwrap_or("00"), 16).unwrap_or(0); + let b = u8::from_str_radix(h.get(4..6).unwrap_or("00"), 16).unwrap_or(0); + format!("rgba({r}, {g}, {b}, {alpha})") +} + +fn build_css(p: &Palette) -> String { + let bg_panel = hex_to_rgba(&p.bg, 0.60); + format!( + "* {{ font-family: 'JetBrainsMono Nerd Font Mono', monospace; font-size: 14px; }}\ + window {{ background-color: transparent; }}\ + .launcher-bg {{ background-color: {bg_panel}; border-radius: 8px;\ + box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\ + searchentry {{ background-color: {surface}; color: {fg}; caret-color: {accent};\ + border: none; outline: none; box-shadow: none;\ + padding: 12px 16px; border-radius: 4px 4px 0 0; }}\ + listbox {{ background-color: transparent; padding: 4px; }}\ + row {{ padding: 5px 10px; color: {fg}; background-color: transparent;\ + border-radius: 4px; }}\ + row:hover {{ background-color: {surface}; }}\ + row:selected {{ background-color: {surface}; }}\ + .app-name {{ font-size: 14px; }}\ + .app-muted {{ color: {fg}; opacity: 0.6; font-size: 12px; }}\ + image {{ margin-right: 8px; }}", + bg_panel = bg_panel, + surface = p.surface, + fg = p.fg, + accent = p.accent, + ) +} + +// ---- Icon loading ----------------------------------------------------------- + +fn make_icon(icon_name: &str, icon_path: Option<&Path>) -> gtk4::Image { + // Try loading from resolved cached path via gio::File + if let Some(path) = icon_path { + let gio_file = gtk4::gio::File::for_path(path); + if let Ok(texture) = gtk4::gdk::Texture::from_file(&gio_file) { + let img = gtk4::Image::new(); + img.set_paintable(Some(&texture)); + img.set_pixel_size(32); + return img; + } + } + // Fall back to GTK icon theme lookup by name + let name = if icon_name.is_empty() { + "application-x-executable" + } else { + icon_name + }; + let img = gtk4::Image::from_icon_name(name); + img.set_pixel_size(32); + 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(); + if entry.terminal { + let term = pick_terminal(); + let _ = Command::new(&term) + .args(["-e", "bash", "-c", cmd]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } else { + let _ = Command::new("bash") + .args(["-c", cmd]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } +} + +// ---- 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; + } + } + true +} + +// ---- PID file toggle -------------------------------------------------------- + +fn pid_file() -> PathBuf { + env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")) + .join("breadbox.pid") +} + +fn is_breadbox_pid(pid: u32) -> bool { + fs::read_to_string(format!("/proc/{}/comm", pid)) + .map(|s| s.trim() == "breadbox") + .unwrap_or(false) +} + +// Returns false if an existing instance was killed (caller should exit). +fn toggle_or_continue() -> bool { + let pf = pid_file(); + if let Ok(content) = fs::read_to_string(&pf) { + if let Ok(pid) = content.trim().parse::() { + if is_breadbox_pid(pid) { + let _ = Command::new("kill").arg(pid.to_string()).status(); + return false; + } + } + } + let _ = fs::write(&pf, std::process::id().to_string()); + true +} + +fn cleanup_pid() { + let _ = fs::remove_file(pid_file()); +} + +// ---- UI --------------------------------------------------------------------- + +fn get_row_entry(row: >k4::ListBoxRow) -> Option { + unsafe { + row.data::("entry") + .map(|p| p.as_ref().clone()) + } +} + +fn run_ui(entries: Vec, css: String) { + let app = Application::builder() + .application_id("com.breadway.breadbox") + .build(); + + app.connect_activate(move |app| { + // Base CSS + let provider = CssProvider::new(); + provider.load_from_string(&css); + gtk4::style_context_add_provider_for_display( + &Display::default().expect("no display"), + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + // User CSS override + let user_css_path = config_dir().join("style.css"); + if user_css_path.exists() { + let user_provider = CssProvider::new(); + user_provider.load_from_path(&user_css_path); + gtk4::style_context_add_provider_for_display( + &Display::default().expect("no display"), + &user_provider, + gtk4::STYLE_PROVIDER_PRIORITY_USER, + ); + } + + // Full-screen transparent window; clicks outside the launcher panel close it. + let window = ApplicationWindow::builder().application(app).build(); + window.init_layer_shell(); + 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 = Rc::new({ + let w = window.clone(); + move || { + cleanup_pid(); + w.close(); + } + }); + + let vbox = GBox::new(Orientation::Vertical, 0); + vbox.add_css_class("launcher-bg"); + vbox.set_halign(gtk4::Align::Center); + vbox.set_valign(gtk4::Align::Start); + vbox.set_margin_top(120); + vbox.set_size_request(600, -1); + + let search = SearchEntry::new(); + search.set_placeholder_text(Some("breadbox")); + vbox.append(&search); + + let scroll = ScrolledWindow::new(); + scroll.set_policy(PolicyType::Never, PolicyType::Automatic); + scroll.set_max_content_height(480); + scroll.set_propagate_natural_height(true); + + let list = ListBox::new(); + list.set_selection_mode(SelectionMode::Browse); + + for entry in &entries { + let row = gtk4::ListBoxRow::new(); + let hbox = GBox::new(Orientation::Horizontal, 0); + hbox.set_margin_start(6); + hbox.set_margin_end(6); + hbox.set_valign(gtk4::Align::Center); + + 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()) }; + list.append(&row); + } + + 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)); + + // Filter on keystroke + let list_f = list.clone(); + search.connect_changed(move |entry| { + let text = entry.text(); + let query = text.as_str(); + let mut first_vis: Option = None; + 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); + if vis && first_vis.is_none() { + first_vis = Some(row); + } + i += 1; + } + list_f.select_row(first_vis.as_ref()); + }); + + // Keyboard handling — capture phase on window + let key_ctrl = EventControllerKey::new(); + key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); + let close_k = Rc::clone(&close_all); + let list_k = list.clone(); + key_ctrl.connect_key_pressed(move |_, key, _, _| { + use gtk4::gdk::Key; + match key { + Key::Escape => { + close_k(); + glib::Propagation::Stop + } + Key::Return | Key::KP_Enter => { + if let Some(row) = list_k.selected_row() { + if let Some(entry) = get_row_entry(&row) { + do_launch(&entry); + close_k(); + } + } + glib::Propagation::Stop + } + Key::Down => { + let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1); + 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 + } + Key::Up => { + let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0); + 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::Proceed, + } + }); + window.add_controller(key_ctrl); + + // Row click launches + let close_a = Rc::clone(&close_all); + list.connect_row_activated(move |_, row| { + if let Some(entry) = get_row_entry(row) { + do_launch(&entry); + close_a(); + } + }); + + // Click outside launcher panel → close + let close_outside = Rc::clone(&close_all); + let vbox_ref = vbox.clone(); + let win_ref = window.clone(); + 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); + + window.connect_destroy(|_| cleanup_pid()); + window.present(); + search.grab_focus(); + }); + + app.run(); +} + +// ---- Main ------------------------------------------------------------------- + +fn main() { + if !toggle_or_continue() { + return; + } + + let config = Config::load(); + let workspace = get_active_workspace().unwrap_or_default(); + let priority = config + .context_for(&workspace) + .map(|c| c.priority.clone()) + .unwrap_or_default(); + + let manifest = load_manifest(); + let entries = load_sorted_entries(&manifest, &priority); + + let palette = Palette::from_wal().unwrap_or_else(Palette::catppuccin_mocha); + let css = build_css(&palette); + + run_ui(entries, css); +} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..5ec97f7 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,19 @@ +# breadbox configuration +# Copy to ~/.config/breadbox/config.toml + +# Each [[context]] block matches a Hyprland workspace by name. +# The "default" context is used when no named context matches. +# priority lists app names (case-insensitive substring match against Name= and StartupWMClass=). +# Matched apps are sorted first, in priority order; everything else is alphabetical. + +[[context]] +name = "default" +priority = ["firefox", "code", "obsidian", "kitty"] + +# [[context]] +# name = "2" +# priority = ["slack", "discord", "telegram"] + +# [[context]] +# name = "work" +# priority = ["code", "alacritty", "postman"] diff --git a/packaging/breadbox-sync.service b/packaging/breadbox-sync.service new file mode 100644 index 0000000..ac3e6f4 --- /dev/null +++ b/packaging/breadbox-sync.service @@ -0,0 +1,15 @@ +[Unit] +Description=Breadbox icon sync +Documentation=https://github.com/breadway/breadbox +After=network.target + +[Service] +Type=oneshot +ExecStart=%h/.cargo/bin/breadbox-sync +StandardOutput=journal +StandardError=journal +# Allow up to 2 minutes for slow icon downloads +TimeoutStartSec=120 + +[Install] +WantedBy=default.target diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index a84efdc..0000000 --- a/src/main.rs +++ /dev/null @@ -1,556 +0,0 @@ -use std::{ - collections::HashMap, - env, - fs::{self, File}, - io::{BufRead, BufReader, Write}, - path::{Path, PathBuf}, - process::{Command, Stdio}, - rc::Rc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use gtk4::{ - gdk::Display, - glib, - pango::EllipsizeMode, - prelude::*, - Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label, - ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, -}; -use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; - -const CACHE_TIMEOUT_SECS: u64 = 86400; - -const CSS: &str = " -window { - background-color: transparent; -} -.launcher-bg { - background-color: #1e1e2e; -} -searchentry { - background-color: #313244; - color: #cdd6f4; - caret-color: #cba6f7; - border: none; - outline: none; - box-shadow: none; - padding: 12px 16px; - font-size: 15px; -} -listbox { - background-color: transparent; - padding: 4px; -} -row { - padding: 6px 12px; - color: #cdd6f4; - background-color: transparent; - border-radius: 4px; -} -row:selected { - background-color: #45475a; -} -.action { - color: #6c7086; - font-size: 12px; -} -"; - -// ---- cache helpers -------------------------------------------------------- - -fn home_dir() -> PathBuf { - PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into())) -} - -fn cache_path() -> PathBuf { - env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")) - .join("breadbox.cache") -} - -fn app_dirs() -> [PathBuf; 2] { - [ - PathBuf::from("/usr/share/applications"), - home_dir().join(".local/share/applications"), - ] -} - -fn mtime(path: &Path) -> u64 { - fs::metadata(path) - .and_then(|m| m.modified()) - .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()) - .unwrap_or(0) -} - -fn cache_valid(cache: &Path) -> bool { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let cm = mtime(cache); - now.saturating_sub(cm) < CACHE_TIMEOUT_SECS - && app_dirs().iter().all(|d| !d.is_dir() || mtime(d) <= cm) -} - -fn strip_exec_codes(exec: &str) -> String { - let mut out = String::with_capacity(exec.len()); - 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 -} - -struct DesktopApp { - name: String, - exec: String, - terminal: bool, -} - -fn parse_desktop(path: &Path) -> Option { - let file = File::open(path).ok()?; - let mut in_entry = false; - let (mut name, mut exec, mut app_type) = (None::, None::, None::); - let (mut no_display, mut hidden, mut terminal) = (false, false, 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("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().is_some_and(|t| t != "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; - } - - Some(DesktopApp { name, exec, terminal }) -} - -fn build_cache(cache: &Path) { - let _ = fs::create_dir_all(cache.parent().unwrap_or(Path::new("/tmp"))); - let mut apps: HashMap = 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; - } - if let Some(app) = parse_desktop(&path) { - apps.insert(entry.file_name().to_string_lossy().into_owned(), app); - } - } - } - - let mut lines: Vec = apps - .into_values() - .map(|a| { - let prefix = if a.terminal { "term" } else { "app" }; - format!("{}\t{}::{}", a.name, prefix, a.exec) - }) - .collect(); - lines.sort_unstable(); - - let tmp = cache.with_extension("tmp"); - if let Ok(mut f) = File::create(&tmp) { - for line in &lines { - let _ = writeln!(f, "{}", line); - } - let _ = fs::rename(&tmp, cache); - } -} - -fn load_entries(cache: &Path) -> Vec<(String, String)> { - fs::read_to_string(cache) - .unwrap_or_default() - .lines() - .filter_map(|line| { - let mut parts = line.splitn(2, '\t'); - let name = parts.next()?.to_string(); - let action = parts.next()?.to_string(); - (!name.is_empty() && !action.is_empty()).then_some((name, action)) - }) - .collect() -} - -// ---- 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(action: &str) { - if let Some(cmd) = action.strip_prefix("app::") { - let _ = Command::new("bash") - .args(["-c", cmd]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - } else if let Some(cmd) = action.strip_prefix("term::") { - let term = pick_terminal(); - let _ = Command::new(&term) - .args(["-e", "bash", "-c", cmd]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - } -} - -// ---- 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; - } - } - true -} - -// ---- toggle via pid file -------------------------------------------------- - -fn pid_file() -> PathBuf { - env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp")) - .join("breadbox.pid") -} - -// Returns false if an existing instance was killed (caller should exit). -fn toggle_or_continue() -> bool { - let pf = pid_file(); - if let Ok(content) = fs::read_to_string(&pf) { - if let Ok(pid) = content.trim().parse::() { - if Path::new(&format!("/proc/{}", pid)).exists() { - let _ = Command::new("kill").arg(pid.to_string()).status(); - return false; - } - } - } - let _ = fs::write(&pf, std::process::id().to_string()); - true -} - -fn cleanup_pid() { - let _ = fs::remove_file(pid_file()); -} - -// ---- UI ------------------------------------------------------------------- - -fn get_row_data(row: >k4::ListBoxRow, key: &str) -> String { - unsafe { - row.data::(key) - .map(|p| p.as_ref().clone()) - .unwrap_or_default() - } -} - -fn run_ui(entries: Vec<(String, String)>) { - let app = Application::builder() - .application_id("com.breadway.breadbox") - .build(); - - app.connect_activate(move |app| { - let provider = CssProvider::new(); - provider.load_from_data(CSS); - gtk4::style_context_add_provider_for_display( - &Display::default().expect("no display"), - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); - - // Single full-screen window covers the entire monitor. The window - // background is transparent; only the launcher vbox is visible. - // Clicks outside the vbox are detected via coordinate check and - // close the window. KeyboardMode::Exclusive keeps focus stable so - // pointer-leave events never steal it away. - let window = ApplicationWindow::builder() - .application(app) - .build(); - window.init_layer_shell(); - 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 = Rc::new({ - let w = window.clone(); - move || { - cleanup_pid(); - w.close(); - } - }); - - let vbox = GBox::new(Orientation::Vertical, 0); - vbox.add_css_class("launcher-bg"); - vbox.set_halign(gtk4::Align::Center); - vbox.set_valign(gtk4::Align::Start); - vbox.set_size_request(700, -1); - - let search = SearchEntry::new(); - search.set_placeholder_text(Some("breadbox")); - vbox.append(&search); - - let scroll = ScrolledWindow::new(); - scroll.set_policy(PolicyType::Never, PolicyType::Automatic); - scroll.set_max_content_height(400); - scroll.set_propagate_natural_height(true); - - let list = ListBox::new(); - list.set_selection_mode(SelectionMode::Browse); - - for (name, action) in &entries { - let row = gtk4::ListBoxRow::new(); - let hbox = GBox::new(Orientation::Horizontal, 8); - hbox.set_margin_start(4); - hbox.set_margin_end(4); - - let name_lbl = Label::new(Some(name)); - name_lbl.set_xalign(0.0); - name_lbl.set_hexpand(true); - hbox.append(&name_lbl); - - let action_lbl = Label::new(Some(action)); - action_lbl.add_css_class("action"); - action_lbl.set_xalign(1.0); - action_lbl.set_ellipsize(EllipsizeMode::End); - action_lbl.set_max_width_chars(50); - hbox.append(&action_lbl); - - row.set_child(Some(&hbox)); - unsafe { - row.set_data("name", name.clone()); - row.set_data("action", action.clone()); - } - list.append(&row); - } - - 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)); - - // Filter rows on every keystroke - let list_f = list.clone(); - search.connect_changed(move |entry| { - let text = entry.text(); - let query = text.as_str(); - let mut first_vis: Option = None; - let mut i = 0i32; - while let Some(row) = list_f.row_at_index(i) { - let name = get_row_data(&row, "name"); - let vis = fuzzy_matches(query, &name); - row.set_visible(vis); - if vis && first_vis.is_none() { - first_vis = Some(row); - } - i += 1; - } - list_f.select_row(first_vis.as_ref()); - }); - - // Keyboard: Esc, Enter, arrows — capture phase on window so we - // intercept before SearchEntry's own handlers consume them - let key_ctrl = EventControllerKey::new(); - key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); - let close_k = Rc::clone(&close_all); - let list_k = list.clone(); - key_ctrl.connect_key_pressed(move |_, key, _, _| { - use gtk4::gdk::Key; - match key { - Key::Escape => { - close_k(); - glib::Propagation::Stop - } - Key::Return | Key::KP_Enter => { - if let Some(row) = list_k.selected_row() { - let action = get_row_data(&row, "action"); - if !action.is_empty() { - do_launch(&action); - close_k(); - } - } - glib::Propagation::Stop - } - Key::Down => { - let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1); - 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 - } - Key::Up => { - let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0); - 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::Proceed, - } - }); - window.add_controller(key_ctrl); - - // Row click / Enter activates launch - let close_a = Rc::clone(&close_all); - list.connect_row_activated(move |_, row| { - let action = get_row_data(row, "action"); - if !action.is_empty() { - do_launch(&action); - close_a(); - } - }); - - // Close when clicking outside the launcher box. - // connect_pressed fires before child widgets handle the click, so - // (x, y) are window-relative and always available. Clicks inside the - // vbox are within its allocation and are ignored; everything outside - // (the transparent full-screen area) dismisses the launcher. - let close_outside = Rc::clone(&close_all); - let vbox_ref = vbox.clone(); - let outside_click = gtk4::GestureClick::new(); - outside_click.connect_pressed(move |_, _, x, y| { - let a = vbox_ref.allocation(); - if x < a.x() as f64 - || x > (a.x() + a.width()) as f64 - || y < a.y() as f64 - || y > (a.y() + a.height()) as f64 - { - close_outside(); - } - }); - window.add_controller(outside_click); - - // Safety net: clean up PID if the window is destroyed by the compositor - window.connect_destroy(|_| cleanup_pid()); - - window.present(); - search.grab_focus(); - }); - - app.run(); -} - -// ---- main ----------------------------------------------------------------- - -fn main() { - let cache = cache_path(); - - if env::var("BREADBOX_REBUILD_ONLY").as_deref() == Ok("1") { - build_cache(&cache); - return; - } - - if !toggle_or_continue() { - return; - } - - if !cache.exists() { - build_cache(&cache); - } else if !cache_valid(&cache) { - if let Ok(exe) = env::current_exe() { - let _ = Command::new(exe) - .env("BREADBOX_REBUILD_ONLY", "1") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - } - } - - let entries = load_entries(&cache); - run_ui(entries); -} From 81319dd5848bf229bfc335a52f0254e50d7c0753 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 24 May 2026 18:57:01 +0800 Subject: [PATCH 14/60] can't be bothered writing a commit message --- Cargo.lock | 675 +++++++++++++++++++++++++++++++- Cargo.toml | 11 +- README.md | 124 ++++++ breadbox-shared/Cargo.toml | 9 + breadbox-shared/src/lib.rs | 261 ++++++++++++ breadbox-sync/Cargo.toml | 14 + breadbox-sync/src/main.rs | 281 +++++++++++++ breadbox/Cargo.toml | 15 + breadbox/src/main.rs | 564 ++++++++++++++++++++++++++ config.example.toml | 19 + packaging/breadbox-sync.service | 15 + src/main.rs | 556 -------------------------- 12 files changed, 1971 insertions(+), 573 deletions(-) create mode 100644 README.md create mode 100644 breadbox-shared/Cargo.toml create mode 100644 breadbox-shared/src/lib.rs create mode 100644 breadbox-sync/Cargo.toml create mode 100644 breadbox-sync/src/main.rs create mode 100644 breadbox/Cargo.toml create mode 100644 breadbox/src/main.rs create mode 100644 config.example.toml create mode 100644 packaging/breadbox-sync.service delete mode 100644 src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 76771c4..0e8046d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,24 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.1" @@ -18,8 +30,27 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" name = "breadbox" version = "0.1.0" dependencies = [ + "breadbox-shared", "gtk4", "gtk4-layer-shell", + "serde_json", +] + +[[package]] +name = "breadbox-shared" +version = "0.1.0" +dependencies = [ + "serde", + "toml 0.8.23", +] + +[[package]] +name = "breadbox-sync" +version = "0.1.0" +dependencies = [ + "breadbox-shared", + "serde_json", + "ureq", ] [[package]] @@ -45,6 +76,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-expr" version = "0.20.7" @@ -55,6 +96,32 @@ dependencies = [ "target-lexicon", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -71,6 +138,31 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -191,6 +283,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "gio" version = "0.22.6" @@ -218,7 +321,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -441,6 +544,109 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -451,6 +657,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "khronos_api" version = "3.1.0" @@ -463,6 +675,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.29" @@ -484,6 +702,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "pango" version = "0.22.6" @@ -508,6 +742,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -520,13 +760,22 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -547,6 +796,20 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -556,12 +819,57 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -582,6 +890,28 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -591,6 +921,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -603,6 +945,18 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -614,6 +968,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "system-deps" version = "7.0.8" @@ -623,7 +988,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "toml 1.1.2+spec-1.1.0", "version-compare", ] @@ -633,6 +998,28 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -641,11 +1028,20 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -657,6 +1053,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.11+spec-1.1.0" @@ -664,9 +1074,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -675,9 +1085,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -690,18 +1106,91 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "version-compare" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -711,6 +1200,79 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.3" @@ -720,8 +1282,103 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "xml-rs" version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 7e0d3ae..2c3d18a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,3 @@ -[package] -name = "breadbox" -version = "0.1.0" -edition = "2021" - -[dependencies] -gtk4 = "0.11" -gtk4-layer-shell = "0.8" +[workspace] +members = ["breadbox-shared", "breadbox-sync", "breadbox"] +resolver = "2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b6ce13 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# breadbox + +A GTK4 app launcher for Hyprland / Wayland on Arch Linux. + +``` +breadbox-shared shared types (DesktopEntry, IconCache, Config) +breadbox-sync standalone icon resolution + caching binary +breadbox GTK4 layer-shell launcher +``` + +## Features + +- Layer-shell window, centered 600 px wide, keyboard-exclusive +- Reads the active Hyprland workspace and sorts apps by context priority +- Fuzzy filtering as you type; Enter launches, Escape closes +- App icons loaded from the resolved icon cache (see `breadbox-sync`) +- pywal palette auto-detected from `~/.cache/wal/colors.json`, falls back to Catppuccin Mocha +- User CSS override at `~/.config/breadbox/style.css` +- Toggle/dismiss: running a second instance kills the first + +## Build dependencies + +``` +gtk4 (pacman -S gtk4) +gtk4-layer-shell (pacman -S gtk4-layer-shell) +librsvg (pacman -S librsvg) # for SVG icon support +rust (stable) (rustup toolchain install stable) +``` + +## Build + +```bash +# debug +cargo build + +# release (recommended — put both binaries on $PATH) +cargo build --release +# binaries are at target/release/breadbox and target/release/breadbox-sync +``` + +Install to `~/.cargo/bin` (or anywhere on your PATH): + +```bash +cargo install --path breadbox +cargo install --path breadbox-sync +``` + +## Configuration + +Copy and edit the example config: + +```bash +mkdir -p ~/.config/breadbox +cp config.example.toml ~/.config/breadbox/config.toml +``` + +The `[[context]]` blocks map Hyprland workspace names to app priority lists. +Workspace name `"default"` is the catch-all fallback. + +```toml +[[context]] +name = "default" +priority = ["firefox", "code", "obsidian", "kitty"] + +[[context]] +name = "2" +priority = ["slack", "discord"] +``` + +### CSS theming + +breadbox applies pywal colors automatically when `~/.cache/wal/colors.json` is +present. To override or extend the theme: + +```bash +~/.config/breadbox/style.css +``` + +This file is loaded at the highest CSS priority level, so any rule here wins. + +## Icon sync + +`breadbox-sync` resolves icons for all installed apps and writes them to +`~/.cache/breadbox/`. Run it once before first launch: + +```bash +breadbox-sync +``` + +Icon resolution order: +1. System icon theme (`~/.local/share/icons`, `/usr/share/icons`, `/usr/share/pixmaps`) — 64 px > 48 px PNG, then SVG +2. Flathub media server — for reverse-DNS app IDs (e.g. `org.gnome.Gedit`) +3. icon.horse — downloaded and cached +4. `application-x-executable` fallback from system theme + +### Systemd service (run on login) + +```bash +cp packaging/breadbox-sync.service ~/.config/systemd/user/ +systemctl --user enable --now breadbox-sync.service +``` + +The service runs `breadbox-sync` once at login (after network is up) and logs +to journald. Re-run manually after installing new apps: + +```bash +systemctl --user start breadbox-sync.service +# or just: +breadbox-sync +``` + +## Hyprland keybind + +Add to `~/.config/hypr/hyprland.conf`: + +``` +bind = $mainMod, SPACE, exec, breadbox +``` + +Pressing the keybind again while the launcher is open dismisses it. + +## Licence + +MIT — see [LICENSE](LICENSE). diff --git a/breadbox-shared/Cargo.toml b/breadbox-shared/Cargo.toml new file mode 100644 index 0000000..1600acd --- /dev/null +++ b/breadbox-shared/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "breadbox-shared" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] +serde = { version = "1", features = ["derive"] } +toml = "0.8" diff --git a/breadbox-shared/src/lib.rs b/breadbox-shared/src/lib.rs new file mode 100644 index 0000000..8ce6f7d --- /dev/null +++ b/breadbox-shared/src/lib.rs @@ -0,0 +1,261 @@ +use std::{ + env, + fs::{self, File}, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +// ---- XDG path helpers ------------------------------------------------------- + +pub fn home_dir() -> PathBuf { + PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into())) +} + +pub fn cache_dir() -> PathBuf { + env::var("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".cache")) + .join("breadbox") +} + +pub fn config_dir() -> PathBuf { + env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".config")) + .join("breadbox") +} + +pub fn app_dirs() -> Vec { + let home = home_dir(); + 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 ---------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct DesktopEntry { + pub name: String, + pub exec: String, + pub icon_name: String, + pub icon_path: Option, // resolved by caller from manifest + pub categories: Vec, + pub wm_class: Option, + pub terminal: bool, +} + +pub fn strip_exec_codes(exec: &str) -> String { + let mut out = String::with_capacity(exec.len()); + 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). +pub fn parse_desktop(path: &Path) -> Option { + let file = File::open(path).ok()?; + let mut in_entry = false; + let mut name: Option = None; + let mut exec: Option = None; + let mut icon: Option = None; + let mut categories: Option = None; + let mut wm_class: Option = None; + let mut app_type: Option = 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(); + + Some(DesktopEntry { + 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 { + let mut seen: std::collections::HashMap = 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() + } +} + +// ---- Config ----------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + #[serde(default, rename = "context")] + pub contexts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Context { + pub name: String, + #[serde(default)] + pub priority: Vec, +} + +impl Config { + pub fn load() -> Self { + let path = config_dir().join("config.toml"); + let content = match fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(), + Err(e) => { + eprintln!("breadbox: could not read {}: {}", path.display(), e); + return Self::default(); + } + }; + match toml::from_str(&content) { + Ok(c) => c, + Err(e) => { + eprintln!("breadbox: parse error in {}: {}", path.display(), e); + Self::default() + } + } + } + + /// Find the context matching `workspace`, falling back to "default", then + /// returning None if neither exists. + pub fn context_for(&self, workspace: &str) -> Option<&Context> { + self.contexts + .iter() + .find(|c| c.name == workspace) + .or_else(|| self.contexts.iter().find(|c| c.name == "default")) + } +} diff --git a/breadbox-sync/Cargo.toml b/breadbox-sync/Cargo.toml new file mode 100644 index 0000000..1c68856 --- /dev/null +++ b/breadbox-sync/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "breadbox-sync" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[[bin]] +name = "breadbox-sync" +path = "src/main.rs" + +[dependencies] +breadbox-shared = { path = "../breadbox-shared" } +serde_json = "1" +ureq = "2" diff --git a/breadbox-sync/src/main.rs b/breadbox-sync/src/main.rs new file mode 100644 index 0000000..8c919bf --- /dev/null +++ b/breadbox-sync/src/main.rs @@ -0,0 +1,281 @@ +use std::{ + collections::HashMap, + env, + fs, + io::Read, + path::{Path, PathBuf}, +}; + +use breadbox_shared::{home_dir, IconCache}; + +// ---- Icon theme lookup ------------------------------------------------------ + +fn current_icon_theme() -> String { + let home = home_dir(); + for cfg in [ + home.join(".config/gtk-4.0/settings.ini"), + home.join(".config/gtk-3.0/settings.ini"), + ] { + if let Ok(content) = fs::read_to_string(&cfg) { + for line in content.lines() { + if let Some(v) = line.strip_prefix("gtk-icon-theme-name=") { + let t = v.trim().trim_matches('"'); + if !t.is_empty() { + return t.to_string(); + } + } + } + } + } + "hicolor".to_string() +} + +fn icon_search_dirs() -> Vec { + let home = home_dir(); + let xdg_data_home = env::var("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home.join(".local/share")); + + let mut dirs = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for d in [ + xdg_data_home.join("icons"), + home.join(".local/share/icons"), + PathBuf::from("/usr/share/icons"), + ] { + if seen.insert(d.clone()) { + dirs.push(d); + } + } + dirs +} + +/// Search for `name` in system icon theme directories. +/// Prefers 64px > 48px > 128px > 32px > 256px PNG, then scalable SVG. +fn find_system_icon(name: &str, theme: &str) -> Option { + let sizes = ["64x64", "48x48", "128x128", "32x32", "256x256"]; + let dirs = icon_search_dirs(); + + let themes: Vec<&str> = if theme != "hicolor" { + vec![theme, "hicolor"] + } else { + vec!["hicolor"] + }; + + for dir in &dirs { + for t in &themes { + for size in &sizes { + let p = dir.join(t).join(size).join("apps").join(format!("{}.png", name)); + if p.exists() { + return Some(p); + } + // Alternative path layout: /apps// + let p2 = dir.join(t).join("apps").join(size).join(format!("{}.png", name)); + if p2.exists() { + return Some(p2); + } + } + // SVG (scalable) + for subdir in ["scalable/apps", "apps/scalable"] { + let p = dir.join(t).join(subdir).join(format!("{}.svg", name)); + if p.exists() { + return Some(p); + } + } + } + } + + // /usr/share/pixmaps + for ext in ["png", "svg", "xpm"] { + let p = PathBuf::from("/usr/share/pixmaps").join(format!("{}.{}", name, ext)); + if p.exists() { + return Some(p); + } + } + + None +} + +// ---- Helpers ---------------------------------------------------------------- + +/// Strip file extension from an icon field value, returning the canonical name. +fn canonical_icon_name(icon: &str) -> String { + if icon.starts_with('/') { + return Path::new(icon) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(icon) + .to_string(); + } + icon.strip_suffix(".png") + .or_else(|| icon.strip_suffix(".svg")) + .or_else(|| icon.strip_suffix(".xpm")) + .unwrap_or(icon) + .to_string() +} + +/// A stem like `org.gnome.Gedit` or `com.github.App` — at least three segments, +/// all alphanumeric/hyphen/underscore. +fn looks_like_reverse_dns(stem: &str) -> bool { + let parts: Vec<&str> = stem.split('.').collect(); + parts.len() >= 3 + && parts[0].len() >= 2 + && parts.iter().all(|p| { + !p.is_empty() + && p.chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + }) +} + +/// Try to GET `url` and write the body to `dest`. Returns true on success. +fn try_download(agent: &ureq::Agent, url: &str, dest: &Path) -> bool { + let resp = match agent.get(url).call() { + Ok(r) if r.status() == 200 => r, + _ => return false, + }; + let mut bytes = Vec::new(); + if resp.into_reader().take(2_097_152).read_to_end(&mut bytes).is_err() || bytes.is_empty() { + return false; + } + // Validate the PNG signature so a 200 error page is never cached as an icon. + const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + if !bytes.starts_with(&PNG_MAGIC) { + return false; + } + fs::write(dest, &bytes).is_ok() +} + +/// Resolve an icon to a local path, downloading if necessary. +/// Returns None only if all strategies fail and no generic fallback is found. +fn resolve_icon( + icon_field: &str, + desktop_stem: &str, + theme: &str, + icon_cache: &IconCache, + agent: &ureq::Agent, +) -> Option { + // Absolute path in Icon= field + if icon_field.starts_with('/') { + let p = PathBuf::from(icon_field); + if p.exists() { + return Some(p); + } + } + + let name = canonical_icon_name(icon_field); + if name.is_empty() { + return find_system_icon("application-x-executable", theme); + } + + // 1. System icon theme + if let Some(p) = find_system_icon(&name, theme) { + return Some(p); + } + + // Already cached from a previous run? + let cached = icon_cache.path_for(&name); + if cached.exists() { + return Some(cached); + } + + // 2. Flathub (appstream icon path, not the media CDN) + if looks_like_reverse_dns(desktop_stem) { + let url = format!( + "https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/{}.png", + desktop_stem + ); + let dest = icon_cache.path_for(desktop_stem); + if try_download(agent, &url, &dest) { + eprintln!(" [flathub] {}", desktop_stem); + return Some(dest); + } + } + + // 3. Generic fallback + find_system_icon("application-x-executable", theme) +} + +// ---- Main ------------------------------------------------------------------- + +fn main() { + if let Err(e) = run() { + eprintln!("breadbox-sync: {}", e); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let icon_cache = IconCache::new(); + icon_cache.ensure_dir()?; + + let theme = current_icon_theme(); + eprintln!("breadbox-sync: icon theme = {}", theme); + + let agent = ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(10)) + .build(); + + let mut manifest: HashMap = HashMap::new(); + + // Walk directories directly to get both the entry and its filename stem + // (needed for Flathub reverse-DNS resolution). + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + for dir in breadbox_shared::app_dirs() { + let Ok(read_dir) = fs::read_dir(&dir) else { continue }; + for file_entry in read_dir.flatten() { + let path = file_entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("desktop") { + continue; + } + + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + // User-local overrides system; process in dir order (system first, local last). + // Later entries for the same stem will overwrite earlier ones in the manifest. + + let app = match breadbox_shared::parse_desktop(&path) { + Some(a) => a, + None => continue, + }; + + if app.icon_name.is_empty() { + continue; + } + + // Deduplicate by the raw Icon= value, which is also the manifest key, + // so every distinct icon_name gets its own entry. + if !seen.insert(app.icon_name.clone()) { + continue; + } + + eprint!("resolving icon for {} ({}) ... ", app.name, app.icon_name); + match resolve_icon(&app.icon_name, &stem, &theme, &icon_cache, &agent) { + Some(p) => { + eprintln!("{}", p.display()); + manifest.insert(app.icon_name.clone(), p.to_string_lossy().into_owned()); + } + None => { + eprintln!("not found"); + } + } + } + } + + let manifest_path = IconCache::manifest_path(); + let json = serde_json::to_string_pretty(&manifest)?; + let tmp = manifest_path.with_extension("tmp"); + fs::write(&tmp, &json)?; + fs::rename(&tmp, &manifest_path)?; + + eprintln!( + "breadbox-sync: wrote manifest ({} entries) to {}", + manifest.len(), + manifest_path.display() + ); + Ok(()) +} diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml new file mode 100644 index 0000000..343be40 --- /dev/null +++ b/breadbox/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "breadbox" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[[bin]] +name = "breadbox" +path = "src/main.rs" + +[dependencies] +breadbox-shared = { path = "../breadbox-shared" } +gtk4 = { version = "0.11", features = ["v4_12"] } +gtk4-layer-shell = "0.8" +serde_json = "1" diff --git a/breadbox/src/main.rs b/breadbox/src/main.rs new file mode 100644 index 0000000..8b63052 --- /dev/null +++ b/breadbox/src/main.rs @@ -0,0 +1,564 @@ +use std::{ + collections::HashMap, + env, + fs, + io::{Read, Write}, + os::unix::net::UnixStream, + path::{Path, PathBuf}, + process::{Command, Stdio}, + rc::Rc, +}; + +use breadbox_shared::{ + config_dir, home_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, +}; +use gtk4::{ + gdk::Display, + glib, + pango::EllipsizeMode, + prelude::*, + Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label, + ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, +}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; + +// ---- Hyprland IPC ----------------------------------------------------------- + +fn get_active_workspace() -> Option { + let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; + let rt = env::var("XDG_RUNTIME_DIR").ok()?; + let socket_path = format!("{}/hypr/{}/.socket.sock", rt, sig); + + let mut stream = UnixStream::connect(&socket_path).ok()?; + stream.write_all(b"j/activeworkspace").ok()?; + stream.shutdown(std::net::Shutdown::Write).ok()?; + + let mut response = String::new(); + stream.read_to_string(&mut response).ok()?; + + let v: serde_json::Value = serde_json::from_str(&response).ok()?; + v["name"].as_str().map(|s| s.to_string()) +} + +// ---- Manifest --------------------------------------------------------------- + +fn load_manifest() -> HashMap { + let path = IconCache::manifest_path(); + let content = fs::read_to_string(&path).unwrap_or_default(); + serde_json::from_str::>(&content) + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, PathBuf::from(v))) + .collect() +} + +// ---- Entry loading and sorting ---------------------------------------------- + +fn load_sorted_entries( + manifest: &HashMap, + priority: &[String], +) -> Vec { + 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 = 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) => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + } + }); + + entries +} + +fn priority_rank(entry: &DesktopEntry, priority_lower: &[String]) -> Option { + 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 ---------------------------------------------------------------- + +#[derive(Debug)] +struct Palette { + bg: String, + surface: String, + fg: String, + accent: String, +} + +impl Palette { + fn catppuccin_mocha() -> Self { + Palette { + bg: "#1e1e2e".into(), + surface: "#181825".into(), + fg: "#cdd6f4".into(), + accent: "#89b4fa".into(), + } + } + + fn from_wal() -> Option { + let path = env::var("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".cache")) + .join("wal/colors.json"); + let content = fs::read_to_string(&path).ok()?; + let v: serde_json::Value = serde_json::from_str(&content).ok()?; + + let spec = &v["special"]; + let cols = &v["colors"]; + + let bg = spec["background"].as_str()?.to_string(); + let surface = cols["color0"].as_str().unwrap_or(&bg).to_string(); + let fg = cols["color15"].as_str().unwrap_or("#cdd6f4").to_string(); + let accent = cols["color1"].as_str().unwrap_or("#89b4fa").to_string(); + + Some(Palette { bg, surface, fg, accent }) + } +} + +fn hex_to_rgba(hex: &str, alpha: f32) -> String { + let h = hex.trim_start_matches('#'); + let r = u8::from_str_radix(h.get(0..2).unwrap_or("00"), 16).unwrap_or(0); + let g = u8::from_str_radix(h.get(2..4).unwrap_or("00"), 16).unwrap_or(0); + let b = u8::from_str_radix(h.get(4..6).unwrap_or("00"), 16).unwrap_or(0); + format!("rgba({r}, {g}, {b}, {alpha})") +} + +fn build_css(p: &Palette) -> String { + let bg_panel = hex_to_rgba(&p.bg, 0.60); + format!( + "* {{ font-family: 'JetBrainsMono Nerd Font Mono', monospace; font-size: 14px; }}\ + window {{ background-color: transparent; }}\ + .launcher-bg {{ background-color: {bg_panel}; border-radius: 8px;\ + box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\ + searchentry {{ background-color: {surface}; color: {fg}; caret-color: {accent};\ + border: none; outline: none; box-shadow: none;\ + padding: 12px 16px; border-radius: 4px 4px 0 0; }}\ + listbox {{ background-color: transparent; padding: 4px; }}\ + row {{ padding: 5px 10px; color: {fg}; background-color: transparent;\ + border-radius: 4px; }}\ + row:hover {{ background-color: {surface}; }}\ + row:selected {{ background-color: {surface}; }}\ + .app-name {{ font-size: 14px; }}\ + .app-muted {{ color: {fg}; opacity: 0.6; font-size: 12px; }}\ + image {{ margin-right: 8px; }}", + bg_panel = bg_panel, + surface = p.surface, + fg = p.fg, + accent = p.accent, + ) +} + +// ---- Icon loading ----------------------------------------------------------- + +fn make_icon(icon_name: &str, icon_path: Option<&Path>) -> gtk4::Image { + // Try loading from resolved cached path via gio::File + if let Some(path) = icon_path { + let gio_file = gtk4::gio::File::for_path(path); + if let Ok(texture) = gtk4::gdk::Texture::from_file(&gio_file) { + let img = gtk4::Image::new(); + img.set_paintable(Some(&texture)); + img.set_pixel_size(32); + return img; + } + } + // Fall back to GTK icon theme lookup by name + let name = if icon_name.is_empty() { + "application-x-executable" + } else { + icon_name + }; + let img = gtk4::Image::from_icon_name(name); + img.set_pixel_size(32); + 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(); + if entry.terminal { + let term = pick_terminal(); + let _ = Command::new(&term) + .args(["-e", "bash", "-c", cmd]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } else { + let _ = Command::new("bash") + .args(["-c", cmd]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } +} + +// ---- 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; + } + } + true +} + +// ---- PID file toggle -------------------------------------------------------- + +fn pid_file() -> PathBuf { + env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")) + .join("breadbox.pid") +} + +fn is_breadbox_pid(pid: u32) -> bool { + fs::read_to_string(format!("/proc/{}/comm", pid)) + .map(|s| s.trim() == "breadbox") + .unwrap_or(false) +} + +// Returns false if an existing instance was killed (caller should exit). +fn toggle_or_continue() -> bool { + let pf = pid_file(); + if let Ok(content) = fs::read_to_string(&pf) { + if let Ok(pid) = content.trim().parse::() { + if is_breadbox_pid(pid) { + let _ = Command::new("kill").arg(pid.to_string()).status(); + return false; + } + } + } + let _ = fs::write(&pf, std::process::id().to_string()); + true +} + +fn cleanup_pid() { + let _ = fs::remove_file(pid_file()); +} + +// ---- UI --------------------------------------------------------------------- + +fn get_row_entry(row: >k4::ListBoxRow) -> Option { + unsafe { + row.data::("entry") + .map(|p| p.as_ref().clone()) + } +} + +fn run_ui(entries: Vec, css: String) { + let app = Application::builder() + .application_id("com.breadway.breadbox") + .build(); + + app.connect_activate(move |app| { + // Base CSS + let provider = CssProvider::new(); + provider.load_from_string(&css); + gtk4::style_context_add_provider_for_display( + &Display::default().expect("no display"), + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + // User CSS override + let user_css_path = config_dir().join("style.css"); + if user_css_path.exists() { + let user_provider = CssProvider::new(); + user_provider.load_from_path(&user_css_path); + gtk4::style_context_add_provider_for_display( + &Display::default().expect("no display"), + &user_provider, + gtk4::STYLE_PROVIDER_PRIORITY_USER, + ); + } + + // Full-screen transparent window; clicks outside the launcher panel close it. + let window = ApplicationWindow::builder().application(app).build(); + window.init_layer_shell(); + 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 = Rc::new({ + let w = window.clone(); + move || { + cleanup_pid(); + w.close(); + } + }); + + let vbox = GBox::new(Orientation::Vertical, 0); + vbox.add_css_class("launcher-bg"); + vbox.set_halign(gtk4::Align::Center); + vbox.set_valign(gtk4::Align::Start); + vbox.set_margin_top(120); + vbox.set_size_request(600, -1); + + let search = SearchEntry::new(); + search.set_placeholder_text(Some("breadbox")); + vbox.append(&search); + + let scroll = ScrolledWindow::new(); + scroll.set_policy(PolicyType::Never, PolicyType::Automatic); + scroll.set_max_content_height(480); + scroll.set_propagate_natural_height(true); + + let list = ListBox::new(); + list.set_selection_mode(SelectionMode::Browse); + + for entry in &entries { + let row = gtk4::ListBoxRow::new(); + let hbox = GBox::new(Orientation::Horizontal, 0); + hbox.set_margin_start(6); + hbox.set_margin_end(6); + hbox.set_valign(gtk4::Align::Center); + + 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()) }; + list.append(&row); + } + + 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)); + + // Filter on keystroke + let list_f = list.clone(); + search.connect_changed(move |entry| { + let text = entry.text(); + let query = text.as_str(); + let mut first_vis: Option = None; + 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); + if vis && first_vis.is_none() { + first_vis = Some(row); + } + i += 1; + } + list_f.select_row(first_vis.as_ref()); + }); + + // Keyboard handling — capture phase on window + let key_ctrl = EventControllerKey::new(); + key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); + let close_k = Rc::clone(&close_all); + let list_k = list.clone(); + key_ctrl.connect_key_pressed(move |_, key, _, _| { + use gtk4::gdk::Key; + match key { + Key::Escape => { + close_k(); + glib::Propagation::Stop + } + Key::Return | Key::KP_Enter => { + if let Some(row) = list_k.selected_row() { + if let Some(entry) = get_row_entry(&row) { + do_launch(&entry); + close_k(); + } + } + glib::Propagation::Stop + } + Key::Down => { + let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1); + 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 + } + Key::Up => { + let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0); + 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::Proceed, + } + }); + window.add_controller(key_ctrl); + + // Row click launches + let close_a = Rc::clone(&close_all); + list.connect_row_activated(move |_, row| { + if let Some(entry) = get_row_entry(row) { + do_launch(&entry); + close_a(); + } + }); + + // Click outside launcher panel → close + let close_outside = Rc::clone(&close_all); + let vbox_ref = vbox.clone(); + let win_ref = window.clone(); + 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); + + window.connect_destroy(|_| cleanup_pid()); + window.present(); + search.grab_focus(); + }); + + app.run(); +} + +// ---- Main ------------------------------------------------------------------- + +fn main() { + if !toggle_or_continue() { + return; + } + + let config = Config::load(); + let workspace = get_active_workspace().unwrap_or_default(); + let priority = config + .context_for(&workspace) + .map(|c| c.priority.clone()) + .unwrap_or_default(); + + let manifest = load_manifest(); + let entries = load_sorted_entries(&manifest, &priority); + + let palette = Palette::from_wal().unwrap_or_else(Palette::catppuccin_mocha); + let css = build_css(&palette); + + run_ui(entries, css); +} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..5ec97f7 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,19 @@ +# breadbox configuration +# Copy to ~/.config/breadbox/config.toml + +# Each [[context]] block matches a Hyprland workspace by name. +# The "default" context is used when no named context matches. +# priority lists app names (case-insensitive substring match against Name= and StartupWMClass=). +# Matched apps are sorted first, in priority order; everything else is alphabetical. + +[[context]] +name = "default" +priority = ["firefox", "code", "obsidian", "kitty"] + +# [[context]] +# name = "2" +# priority = ["slack", "discord", "telegram"] + +# [[context]] +# name = "work" +# priority = ["code", "alacritty", "postman"] diff --git a/packaging/breadbox-sync.service b/packaging/breadbox-sync.service new file mode 100644 index 0000000..ac3e6f4 --- /dev/null +++ b/packaging/breadbox-sync.service @@ -0,0 +1,15 @@ +[Unit] +Description=Breadbox icon sync +Documentation=https://github.com/breadway/breadbox +After=network.target + +[Service] +Type=oneshot +ExecStart=%h/.cargo/bin/breadbox-sync +StandardOutput=journal +StandardError=journal +# Allow up to 2 minutes for slow icon downloads +TimeoutStartSec=120 + +[Install] +WantedBy=default.target diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index a84efdc..0000000 --- a/src/main.rs +++ /dev/null @@ -1,556 +0,0 @@ -use std::{ - collections::HashMap, - env, - fs::{self, File}, - io::{BufRead, BufReader, Write}, - path::{Path, PathBuf}, - process::{Command, Stdio}, - rc::Rc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use gtk4::{ - gdk::Display, - glib, - pango::EllipsizeMode, - prelude::*, - Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label, - ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, -}; -use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; - -const CACHE_TIMEOUT_SECS: u64 = 86400; - -const CSS: &str = " -window { - background-color: transparent; -} -.launcher-bg { - background-color: #1e1e2e; -} -searchentry { - background-color: #313244; - color: #cdd6f4; - caret-color: #cba6f7; - border: none; - outline: none; - box-shadow: none; - padding: 12px 16px; - font-size: 15px; -} -listbox { - background-color: transparent; - padding: 4px; -} -row { - padding: 6px 12px; - color: #cdd6f4; - background-color: transparent; - border-radius: 4px; -} -row:selected { - background-color: #45475a; -} -.action { - color: #6c7086; - font-size: 12px; -} -"; - -// ---- cache helpers -------------------------------------------------------- - -fn home_dir() -> PathBuf { - PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into())) -} - -fn cache_path() -> PathBuf { - env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")) - .join("breadbox.cache") -} - -fn app_dirs() -> [PathBuf; 2] { - [ - PathBuf::from("/usr/share/applications"), - home_dir().join(".local/share/applications"), - ] -} - -fn mtime(path: &Path) -> u64 { - fs::metadata(path) - .and_then(|m| m.modified()) - .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()) - .unwrap_or(0) -} - -fn cache_valid(cache: &Path) -> bool { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let cm = mtime(cache); - now.saturating_sub(cm) < CACHE_TIMEOUT_SECS - && app_dirs().iter().all(|d| !d.is_dir() || mtime(d) <= cm) -} - -fn strip_exec_codes(exec: &str) -> String { - let mut out = String::with_capacity(exec.len()); - 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 -} - -struct DesktopApp { - name: String, - exec: String, - terminal: bool, -} - -fn parse_desktop(path: &Path) -> Option { - let file = File::open(path).ok()?; - let mut in_entry = false; - let (mut name, mut exec, mut app_type) = (None::, None::, None::); - let (mut no_display, mut hidden, mut terminal) = (false, false, 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("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().is_some_and(|t| t != "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; - } - - Some(DesktopApp { name, exec, terminal }) -} - -fn build_cache(cache: &Path) { - let _ = fs::create_dir_all(cache.parent().unwrap_or(Path::new("/tmp"))); - let mut apps: HashMap = 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; - } - if let Some(app) = parse_desktop(&path) { - apps.insert(entry.file_name().to_string_lossy().into_owned(), app); - } - } - } - - let mut lines: Vec = apps - .into_values() - .map(|a| { - let prefix = if a.terminal { "term" } else { "app" }; - format!("{}\t{}::{}", a.name, prefix, a.exec) - }) - .collect(); - lines.sort_unstable(); - - let tmp = cache.with_extension("tmp"); - if let Ok(mut f) = File::create(&tmp) { - for line in &lines { - let _ = writeln!(f, "{}", line); - } - let _ = fs::rename(&tmp, cache); - } -} - -fn load_entries(cache: &Path) -> Vec<(String, String)> { - fs::read_to_string(cache) - .unwrap_or_default() - .lines() - .filter_map(|line| { - let mut parts = line.splitn(2, '\t'); - let name = parts.next()?.to_string(); - let action = parts.next()?.to_string(); - (!name.is_empty() && !action.is_empty()).then_some((name, action)) - }) - .collect() -} - -// ---- 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(action: &str) { - if let Some(cmd) = action.strip_prefix("app::") { - let _ = Command::new("bash") - .args(["-c", cmd]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - } else if let Some(cmd) = action.strip_prefix("term::") { - let term = pick_terminal(); - let _ = Command::new(&term) - .args(["-e", "bash", "-c", cmd]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - } -} - -// ---- 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; - } - } - true -} - -// ---- toggle via pid file -------------------------------------------------- - -fn pid_file() -> PathBuf { - env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp")) - .join("breadbox.pid") -} - -// Returns false if an existing instance was killed (caller should exit). -fn toggle_or_continue() -> bool { - let pf = pid_file(); - if let Ok(content) = fs::read_to_string(&pf) { - if let Ok(pid) = content.trim().parse::() { - if Path::new(&format!("/proc/{}", pid)).exists() { - let _ = Command::new("kill").arg(pid.to_string()).status(); - return false; - } - } - } - let _ = fs::write(&pf, std::process::id().to_string()); - true -} - -fn cleanup_pid() { - let _ = fs::remove_file(pid_file()); -} - -// ---- UI ------------------------------------------------------------------- - -fn get_row_data(row: >k4::ListBoxRow, key: &str) -> String { - unsafe { - row.data::(key) - .map(|p| p.as_ref().clone()) - .unwrap_or_default() - } -} - -fn run_ui(entries: Vec<(String, String)>) { - let app = Application::builder() - .application_id("com.breadway.breadbox") - .build(); - - app.connect_activate(move |app| { - let provider = CssProvider::new(); - provider.load_from_data(CSS); - gtk4::style_context_add_provider_for_display( - &Display::default().expect("no display"), - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); - - // Single full-screen window covers the entire monitor. The window - // background is transparent; only the launcher vbox is visible. - // Clicks outside the vbox are detected via coordinate check and - // close the window. KeyboardMode::Exclusive keeps focus stable so - // pointer-leave events never steal it away. - let window = ApplicationWindow::builder() - .application(app) - .build(); - window.init_layer_shell(); - 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 = Rc::new({ - let w = window.clone(); - move || { - cleanup_pid(); - w.close(); - } - }); - - let vbox = GBox::new(Orientation::Vertical, 0); - vbox.add_css_class("launcher-bg"); - vbox.set_halign(gtk4::Align::Center); - vbox.set_valign(gtk4::Align::Start); - vbox.set_size_request(700, -1); - - let search = SearchEntry::new(); - search.set_placeholder_text(Some("breadbox")); - vbox.append(&search); - - let scroll = ScrolledWindow::new(); - scroll.set_policy(PolicyType::Never, PolicyType::Automatic); - scroll.set_max_content_height(400); - scroll.set_propagate_natural_height(true); - - let list = ListBox::new(); - list.set_selection_mode(SelectionMode::Browse); - - for (name, action) in &entries { - let row = gtk4::ListBoxRow::new(); - let hbox = GBox::new(Orientation::Horizontal, 8); - hbox.set_margin_start(4); - hbox.set_margin_end(4); - - let name_lbl = Label::new(Some(name)); - name_lbl.set_xalign(0.0); - name_lbl.set_hexpand(true); - hbox.append(&name_lbl); - - let action_lbl = Label::new(Some(action)); - action_lbl.add_css_class("action"); - action_lbl.set_xalign(1.0); - action_lbl.set_ellipsize(EllipsizeMode::End); - action_lbl.set_max_width_chars(50); - hbox.append(&action_lbl); - - row.set_child(Some(&hbox)); - unsafe { - row.set_data("name", name.clone()); - row.set_data("action", action.clone()); - } - list.append(&row); - } - - 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)); - - // Filter rows on every keystroke - let list_f = list.clone(); - search.connect_changed(move |entry| { - let text = entry.text(); - let query = text.as_str(); - let mut first_vis: Option = None; - let mut i = 0i32; - while let Some(row) = list_f.row_at_index(i) { - let name = get_row_data(&row, "name"); - let vis = fuzzy_matches(query, &name); - row.set_visible(vis); - if vis && first_vis.is_none() { - first_vis = Some(row); - } - i += 1; - } - list_f.select_row(first_vis.as_ref()); - }); - - // Keyboard: Esc, Enter, arrows — capture phase on window so we - // intercept before SearchEntry's own handlers consume them - let key_ctrl = EventControllerKey::new(); - key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); - let close_k = Rc::clone(&close_all); - let list_k = list.clone(); - key_ctrl.connect_key_pressed(move |_, key, _, _| { - use gtk4::gdk::Key; - match key { - Key::Escape => { - close_k(); - glib::Propagation::Stop - } - Key::Return | Key::KP_Enter => { - if let Some(row) = list_k.selected_row() { - let action = get_row_data(&row, "action"); - if !action.is_empty() { - do_launch(&action); - close_k(); - } - } - glib::Propagation::Stop - } - Key::Down => { - let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1); - 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 - } - Key::Up => { - let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0); - 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::Proceed, - } - }); - window.add_controller(key_ctrl); - - // Row click / Enter activates launch - let close_a = Rc::clone(&close_all); - list.connect_row_activated(move |_, row| { - let action = get_row_data(row, "action"); - if !action.is_empty() { - do_launch(&action); - close_a(); - } - }); - - // Close when clicking outside the launcher box. - // connect_pressed fires before child widgets handle the click, so - // (x, y) are window-relative and always available. Clicks inside the - // vbox are within its allocation and are ignored; everything outside - // (the transparent full-screen area) dismisses the launcher. - let close_outside = Rc::clone(&close_all); - let vbox_ref = vbox.clone(); - let outside_click = gtk4::GestureClick::new(); - outside_click.connect_pressed(move |_, _, x, y| { - let a = vbox_ref.allocation(); - if x < a.x() as f64 - || x > (a.x() + a.width()) as f64 - || y < a.y() as f64 - || y > (a.y() + a.height()) as f64 - { - close_outside(); - } - }); - window.add_controller(outside_click); - - // Safety net: clean up PID if the window is destroyed by the compositor - window.connect_destroy(|_| cleanup_pid()); - - window.present(); - search.grab_focus(); - }); - - app.run(); -} - -// ---- main ----------------------------------------------------------------- - -fn main() { - let cache = cache_path(); - - if env::var("BREADBOX_REBUILD_ONLY").as_deref() == Ok("1") { - build_cache(&cache); - return; - } - - if !toggle_or_continue() { - return; - } - - if !cache.exists() { - build_cache(&cache); - } else if !cache_valid(&cache) { - if let Ok(exe) = env::current_exe() { - let _ = Command::new(exe) - .env("BREADBOX_REBUILD_ONLY", "1") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - } - } - - let entries = load_entries(&cache); - run_ui(entries); -} From c561a449af3272d9b8a9f035be9ae16a35ec9a22 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:31:20 +0800 Subject: [PATCH 15/60] Refactor theme onto bread-theme; add bakery.toml and release workflow - breadbox/Cargo.toml: depend on bread-theme with gtk feature - breadbox/src/main.rs: remove local Palette + hex_to_rgba; use bread_theme::{load_palette, hex_to_rgba, gtk::apply_user_css} - bakery.toml: describes breadbox for bakery install - release.yml: builds on hestia self-hosted runner, publishes binaries to dl.breadway.dev and GitHub Releases on v* tags --- .github/workflows/release.yml | 62 +++++++++++++ Cargo.lock | 160 ++++++++++++++++++++++++++++++++-- bakery.toml | 18 ++++ breadbox/Cargo.toml | 3 + breadbox/src/main.rs | 81 ++++------------- 5 files changed, 251 insertions(+), 73 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 bakery.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7d57942 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: release + +on: + push: + tags: ["v*"] + +env: + DL_DIR: /srv/breadway-dl + ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + + - name: install system deps + run: sudo pacman -S --noconfirm gtk4 gtk4-layer-shell librsvg 2>/dev/null || true + + - name: build + run: cargo build --release --locked + + - name: prepare artifacts + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadbox/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadbox breadbox-sync; do + cp "target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp packaging/breadbox-sync.service "${PKG_DIR}/" + cp config.example.toml "${PKG_DIR}/" + cp bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${PKG_DIR}" "${DL_DIR}/breadbox/latest" + + - name: ensure bread-ecosystem + run: | + if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then + git -C "${ECOSYSTEM_DIR}" pull --ff-only + else + mkdir -p "$(dirname "${ECOSYSTEM_DIR}")" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + fi + + - name: regenerate index.json + run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" + + - name: upload to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadbox/${VERSION}" + gh release upload "${GITHUB_REF_NAME}" \ + "${PKG_DIR}/breadbox-x86_64" \ + "${PKG_DIR}/breadbox-sync-x86_64" \ + "${PKG_DIR}/breadbox-x86_64.sha256" \ + "${PKG_DIR}/breadbox-sync-x86_64.sha256" \ + --clobber diff --git a/Cargo.lock b/Cargo.lock index 0e8046d..d72548f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,10 +26,21 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bread-theme" +version = "0.1.0" +dependencies = [ + "dirs", + "gtk4", + "serde", + "serde_json", +] + [[package]] name = "breadbox" version = "0.1.0" dependencies = [ + "bread-theme", "breadbox-shared", "gtk4", "gtk4-layer-shell", @@ -111,6 +122,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -675,6 +707,15 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + [[package]] name = "litemap" version = "0.8.2" @@ -718,6 +759,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "pango" version = "0.22.6" @@ -796,6 +843,17 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "ring" version = "0.17.14" @@ -998,6 +1056,26 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1182,13 +1260,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1200,34 +1287,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1240,24 +1360,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..1f16c1f --- /dev/null +++ b/bakery.toml @@ -0,0 +1,18 @@ +name = "breadbox" +description = "App launcher for Hyprland / Wayland" +binaries = ["breadbox", "breadbox-sync"] +system_deps = ["gtk4", "gtk4-layer-shell", "librsvg"] +bread_deps = [] + +[[service]] +unit = "breadbox-sync.service" +enable = true + +[config] +dir = "~/.config/breadbox" +example = "config.example.toml" + +[install] +post_install = [ + "systemctl --user start breadbox-sync.service 2>/dev/null || breadbox-sync", +] diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 343be40..47ca6ee 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -9,6 +9,9 @@ name = "breadbox" path = "src/main.rs" [dependencies] +# Path dep for local dev; replace with git dep on first tag: +# bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "theme-v0.1.0", features = ["gtk"] } +bread-theme = { path = "../../bread-ecosystem/bread-theme", features = ["gtk"] } breadbox-shared = { path = "../breadbox-shared" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" diff --git a/breadbox/src/main.rs b/breadbox/src/main.rs index 8b63052..29338a9 100644 --- a/breadbox/src/main.rs +++ b/breadbox/src/main.rs @@ -1,3 +1,4 @@ +use bread_theme::{hex_to_rgba, load_palette, Palette}; use std::{ collections::HashMap, env, @@ -10,7 +11,7 @@ use std::{ }; use breadbox_shared::{ - config_dir, home_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, + config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, }; use gtk4::{ gdk::Display, @@ -124,74 +125,28 @@ fn matches_term(field: &str, term: &str) -> bool { // ---- Theming ---------------------------------------------------------------- -#[derive(Debug)] -struct Palette { - bg: String, - surface: String, - fg: String, - accent: String, -} - -impl Palette { - fn catppuccin_mocha() -> Self { - Palette { - bg: "#1e1e2e".into(), - surface: "#181825".into(), - fg: "#cdd6f4".into(), - accent: "#89b4fa".into(), - } - } - - fn from_wal() -> Option { - let path = env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")) - .join("wal/colors.json"); - let content = fs::read_to_string(&path).ok()?; - let v: serde_json::Value = serde_json::from_str(&content).ok()?; - - let spec = &v["special"]; - let cols = &v["colors"]; - - let bg = spec["background"].as_str()?.to_string(); - let surface = cols["color0"].as_str().unwrap_or(&bg).to_string(); - let fg = cols["color15"].as_str().unwrap_or("#cdd6f4").to_string(); - let accent = cols["color1"].as_str().unwrap_or("#89b4fa").to_string(); - - Some(Palette { bg, surface, fg, accent }) - } -} - -fn hex_to_rgba(hex: &str, alpha: f32) -> String { - let h = hex.trim_start_matches('#'); - let r = u8::from_str_radix(h.get(0..2).unwrap_or("00"), 16).unwrap_or(0); - let g = u8::from_str_radix(h.get(2..4).unwrap_or("00"), 16).unwrap_or(0); - let b = u8::from_str_radix(h.get(4..6).unwrap_or("00"), 16).unwrap_or(0); - format!("rgba({r}, {g}, {b}, {alpha})") -} - fn build_css(p: &Palette) -> String { - let bg_panel = hex_to_rgba(&p.bg, 0.60); + let bg_panel = hex_to_rgba(&p.background, 0.60); format!( - "* {{ font-family: 'JetBrainsMono Nerd Font Mono', monospace; font-size: 14px; }}\ + "* {{ font-family: 'Varela Round', sans-serif; font-size: 14px; }}\ window {{ background-color: transparent; }}\ .launcher-bg {{ background-color: {bg_panel}; border-radius: 8px;\ box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\ searchentry {{ background-color: {surface}; color: {fg}; caret-color: {accent};\ border: none; outline: none; box-shadow: none;\ - padding: 12px 16px; border-radius: 4px 4px 0 0; }}\ + padding: 12px 16px; border-radius: 6px 6px 0 0; }}\ listbox {{ background-color: transparent; padding: 4px; }}\ - row {{ padding: 5px 10px; color: {fg}; background-color: transparent;\ - border-radius: 4px; }}\ + row {{ padding: 8px 12px; color: {fg}; background-color: transparent;\ + border-radius: 6px; }}\ row:hover {{ background-color: {surface}; }}\ row:selected {{ background-color: {surface}; }}\ .app-name {{ font-size: 14px; }}\ .app-muted {{ color: {fg}; opacity: 0.6; font-size: 12px; }}\ image {{ margin-right: 8px; }}", bg_panel = bg_panel, - surface = p.surface, - fg = p.fg, - accent = p.accent, + surface = p.color0, + fg = p.foreground, + accent = p.color4, ) } @@ -334,15 +289,11 @@ fn run_ui(entries: Vec, css: String) { ); // User CSS override - let user_css_path = config_dir().join("style.css"); - if user_css_path.exists() { - let user_provider = CssProvider::new(); - user_provider.load_from_path(&user_css_path); - gtk4::style_context_add_provider_for_display( - &Display::default().expect("no display"), - &user_provider, - gtk4::STYLE_PROVIDER_PRIORITY_USER, - ); + { + use std::cell::RefCell; + let user_css_path = config_dir().join("style.css"); + let user_cell: RefCell> = RefCell::new(None); + bread_theme::gtk::apply_user_css(&user_css_path, &user_cell); } // Full-screen transparent window; clicks outside the launcher panel close it. @@ -557,7 +508,7 @@ fn main() { let manifest = load_manifest(); let entries = load_sorted_entries(&manifest, &priority); - let palette = Palette::from_wal().unwrap_or_else(Palette::catppuccin_mocha); + let palette = load_palette(); let css = build_css(&palette); run_ui(entries, css); From 53756e840a12769893b8011b6e52aa7cc4a36df2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:31:20 +0800 Subject: [PATCH 16/60] Refactor theme onto bread-theme; add bakery.toml and release workflow - breadbox/Cargo.toml: depend on bread-theme with gtk feature - breadbox/src/main.rs: remove local Palette + hex_to_rgba; use bread_theme::{load_palette, hex_to_rgba, gtk::apply_user_css} - bakery.toml: describes breadbox for bakery install - release.yml: builds on hestia self-hosted runner, publishes binaries to dl.breadway.dev and GitHub Releases on v* tags Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 62 +++++++++++++ Cargo.lock | 160 ++++++++++++++++++++++++++++++++-- bakery.toml | 18 ++++ breadbox/Cargo.toml | 3 + breadbox/src/main.rs | 81 ++++------------- 5 files changed, 251 insertions(+), 73 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 bakery.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7d57942 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: release + +on: + push: + tags: ["v*"] + +env: + DL_DIR: /srv/breadway-dl + ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + + - name: install system deps + run: sudo pacman -S --noconfirm gtk4 gtk4-layer-shell librsvg 2>/dev/null || true + + - name: build + run: cargo build --release --locked + + - name: prepare artifacts + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadbox/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadbox breadbox-sync; do + cp "target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp packaging/breadbox-sync.service "${PKG_DIR}/" + cp config.example.toml "${PKG_DIR}/" + cp bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${PKG_DIR}" "${DL_DIR}/breadbox/latest" + + - name: ensure bread-ecosystem + run: | + if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then + git -C "${ECOSYSTEM_DIR}" pull --ff-only + else + mkdir -p "$(dirname "${ECOSYSTEM_DIR}")" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + fi + + - name: regenerate index.json + run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" + + - name: upload to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadbox/${VERSION}" + gh release upload "${GITHUB_REF_NAME}" \ + "${PKG_DIR}/breadbox-x86_64" \ + "${PKG_DIR}/breadbox-sync-x86_64" \ + "${PKG_DIR}/breadbox-x86_64.sha256" \ + "${PKG_DIR}/breadbox-sync-x86_64.sha256" \ + --clobber diff --git a/Cargo.lock b/Cargo.lock index 0e8046d..d72548f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,10 +26,21 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bread-theme" +version = "0.1.0" +dependencies = [ + "dirs", + "gtk4", + "serde", + "serde_json", +] + [[package]] name = "breadbox" version = "0.1.0" dependencies = [ + "bread-theme", "breadbox-shared", "gtk4", "gtk4-layer-shell", @@ -111,6 +122,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -675,6 +707,15 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + [[package]] name = "litemap" version = "0.8.2" @@ -718,6 +759,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "pango" version = "0.22.6" @@ -796,6 +843,17 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "ring" version = "0.17.14" @@ -998,6 +1056,26 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1182,13 +1260,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1200,34 +1287,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1240,24 +1360,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..1f16c1f --- /dev/null +++ b/bakery.toml @@ -0,0 +1,18 @@ +name = "breadbox" +description = "App launcher for Hyprland / Wayland" +binaries = ["breadbox", "breadbox-sync"] +system_deps = ["gtk4", "gtk4-layer-shell", "librsvg"] +bread_deps = [] + +[[service]] +unit = "breadbox-sync.service" +enable = true + +[config] +dir = "~/.config/breadbox" +example = "config.example.toml" + +[install] +post_install = [ + "systemctl --user start breadbox-sync.service 2>/dev/null || breadbox-sync", +] diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 343be40..47ca6ee 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -9,6 +9,9 @@ name = "breadbox" path = "src/main.rs" [dependencies] +# Path dep for local dev; replace with git dep on first tag: +# bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "theme-v0.1.0", features = ["gtk"] } +bread-theme = { path = "../../bread-ecosystem/bread-theme", features = ["gtk"] } breadbox-shared = { path = "../breadbox-shared" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" diff --git a/breadbox/src/main.rs b/breadbox/src/main.rs index 8b63052..29338a9 100644 --- a/breadbox/src/main.rs +++ b/breadbox/src/main.rs @@ -1,3 +1,4 @@ +use bread_theme::{hex_to_rgba, load_palette, Palette}; use std::{ collections::HashMap, env, @@ -10,7 +11,7 @@ use std::{ }; use breadbox_shared::{ - config_dir, home_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, + config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, }; use gtk4::{ gdk::Display, @@ -124,74 +125,28 @@ fn matches_term(field: &str, term: &str) -> bool { // ---- Theming ---------------------------------------------------------------- -#[derive(Debug)] -struct Palette { - bg: String, - surface: String, - fg: String, - accent: String, -} - -impl Palette { - fn catppuccin_mocha() -> Self { - Palette { - bg: "#1e1e2e".into(), - surface: "#181825".into(), - fg: "#cdd6f4".into(), - accent: "#89b4fa".into(), - } - } - - fn from_wal() -> Option { - let path = env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")) - .join("wal/colors.json"); - let content = fs::read_to_string(&path).ok()?; - let v: serde_json::Value = serde_json::from_str(&content).ok()?; - - let spec = &v["special"]; - let cols = &v["colors"]; - - let bg = spec["background"].as_str()?.to_string(); - let surface = cols["color0"].as_str().unwrap_or(&bg).to_string(); - let fg = cols["color15"].as_str().unwrap_or("#cdd6f4").to_string(); - let accent = cols["color1"].as_str().unwrap_or("#89b4fa").to_string(); - - Some(Palette { bg, surface, fg, accent }) - } -} - -fn hex_to_rgba(hex: &str, alpha: f32) -> String { - let h = hex.trim_start_matches('#'); - let r = u8::from_str_radix(h.get(0..2).unwrap_or("00"), 16).unwrap_or(0); - let g = u8::from_str_radix(h.get(2..4).unwrap_or("00"), 16).unwrap_or(0); - let b = u8::from_str_radix(h.get(4..6).unwrap_or("00"), 16).unwrap_or(0); - format!("rgba({r}, {g}, {b}, {alpha})") -} - fn build_css(p: &Palette) -> String { - let bg_panel = hex_to_rgba(&p.bg, 0.60); + let bg_panel = hex_to_rgba(&p.background, 0.60); format!( - "* {{ font-family: 'JetBrainsMono Nerd Font Mono', monospace; font-size: 14px; }}\ + "* {{ font-family: 'Varela Round', sans-serif; font-size: 14px; }}\ window {{ background-color: transparent; }}\ .launcher-bg {{ background-color: {bg_panel}; border-radius: 8px;\ box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\ searchentry {{ background-color: {surface}; color: {fg}; caret-color: {accent};\ border: none; outline: none; box-shadow: none;\ - padding: 12px 16px; border-radius: 4px 4px 0 0; }}\ + padding: 12px 16px; border-radius: 6px 6px 0 0; }}\ listbox {{ background-color: transparent; padding: 4px; }}\ - row {{ padding: 5px 10px; color: {fg}; background-color: transparent;\ - border-radius: 4px; }}\ + row {{ padding: 8px 12px; color: {fg}; background-color: transparent;\ + border-radius: 6px; }}\ row:hover {{ background-color: {surface}; }}\ row:selected {{ background-color: {surface}; }}\ .app-name {{ font-size: 14px; }}\ .app-muted {{ color: {fg}; opacity: 0.6; font-size: 12px; }}\ image {{ margin-right: 8px; }}", bg_panel = bg_panel, - surface = p.surface, - fg = p.fg, - accent = p.accent, + surface = p.color0, + fg = p.foreground, + accent = p.color4, ) } @@ -334,15 +289,11 @@ fn run_ui(entries: Vec, css: String) { ); // User CSS override - let user_css_path = config_dir().join("style.css"); - if user_css_path.exists() { - let user_provider = CssProvider::new(); - user_provider.load_from_path(&user_css_path); - gtk4::style_context_add_provider_for_display( - &Display::default().expect("no display"), - &user_provider, - gtk4::STYLE_PROVIDER_PRIORITY_USER, - ); + { + use std::cell::RefCell; + let user_css_path = config_dir().join("style.css"); + let user_cell: RefCell> = RefCell::new(None); + bread_theme::gtk::apply_user_css(&user_css_path, &user_cell); } // Full-screen transparent window; clicks outside the launcher panel close it. @@ -557,7 +508,7 @@ fn main() { let manifest = load_manifest(); let entries = load_sorted_entries(&manifest, &priority); - let palette = Palette::from_wal().unwrap_or_else(Palette::catppuccin_mocha); + let palette = load_palette(); let css = build_css(&palette); run_ui(entries, css); From b066198187bb1e5fb2dcd66020f0b038d32300a3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:47:41 +0800 Subject: [PATCH 17/60] fix: use apt-get on hestia runner (Ubuntu, not Arch) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d57942..ee29d61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - name: install system deps - run: sudo pacman -S --noconfirm gtk4 gtk4-layer-shell librsvg 2>/dev/null || true + run: sudo apt-get install -y libgtk-4-dev librsvg2-dev 2>/dev/null || true - name: build run: cargo build --release --locked From 1d1b7f166884020bce5296800eccc337566b95a6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:47:41 +0800 Subject: [PATCH 18/60] fix: use apt-get on hestia runner (Ubuntu, not Arch) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d57942..ee29d61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - name: install system deps - run: sudo pacman -S --noconfirm gtk4 gtk4-layer-shell librsvg 2>/dev/null || true + run: sudo apt-get install -y libgtk-4-dev librsvg2-dev 2>/dev/null || true - name: build run: cargo build --release --locked From 7ffd5f785bec422dc165550654594ff5e558232b Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:19:57 +0800 Subject: [PATCH 19/60] fix: add missing build deps for hestia (Ubuntu) runner --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee29d61..3dcb2f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: install system deps - run: sudo apt-get install -y libgtk-4-dev librsvg2-dev 2>/dev/null || true + - name: install build deps + run: sudo apt-get install -y libgtk-4-dev librsvg2-dev libdbus-1-dev pkg-config 2>/dev/null || true - name: build run: cargo build --release --locked From 9f4f0647c80d323834837b76b485b824433df06e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:19:57 +0800 Subject: [PATCH 20/60] fix: add missing build deps for hestia (Ubuntu) runner --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee29d61..3dcb2f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: install system deps - run: sudo apt-get install -y libgtk-4-dev librsvg2-dev 2>/dev/null || true + - name: install build deps + run: sudo apt-get install -y libgtk-4-dev librsvg2-dev libdbus-1-dev pkg-config 2>/dev/null || true - name: build run: cargo build --release --locked From e3ea3307d26bda04cddbe340d18809014160c5ed Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:26:49 +0800 Subject: [PATCH 21/60] fix: switch bread-theme to git dep (v0.1.0) for CI --- Cargo.lock | 43 ++++++++++++++++++++++--------------------- breadbox/Cargo.toml | 4 +--- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d72548f..cf7328f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,13 +22,14 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" version = "0.1.0" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.1.0#6b5f4f475f66a645b08cb865e6dda8228d23679b" dependencies = [ "dirs", "gtk4", @@ -89,9 +90,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -99,9 +100,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.7" +version = "0.20.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" dependencies = [ "smallvec", "target-lexicon", @@ -145,9 +146,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -724,15 +725,15 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memoffset" @@ -822,7 +823,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -981,9 +982,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simd-adler32" @@ -1052,9 +1053,9 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "thiserror" @@ -1147,9 +1148,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", @@ -1440,9 +1441,9 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 47ca6ee..8dc7f3b 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -9,9 +9,7 @@ name = "breadbox" path = "src/main.rs" [dependencies] -# Path dep for local dev; replace with git dep on first tag: -# bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "theme-v0.1.0", features = ["gtk"] } -bread-theme = { path = "../../bread-ecosystem/bread-theme", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.1.0", features = ["gtk"] } breadbox-shared = { path = "../breadbox-shared" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" From d6cee01697aef423be94b8fb0bdf06b74ed32d8d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:26:49 +0800 Subject: [PATCH 22/60] fix: switch bread-theme to git dep (v0.1.0) for CI --- Cargo.lock | 43 ++++++++++++++++++++++--------------------- breadbox/Cargo.toml | 4 +--- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d72548f..cf7328f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,13 +22,14 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" version = "0.1.0" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.1.0#6b5f4f475f66a645b08cb865e6dda8228d23679b" dependencies = [ "dirs", "gtk4", @@ -89,9 +90,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -99,9 +100,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.7" +version = "0.20.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" dependencies = [ "smallvec", "target-lexicon", @@ -145,9 +146,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -724,15 +725,15 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memoffset" @@ -822,7 +823,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -981,9 +982,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simd-adler32" @@ -1052,9 +1053,9 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "thiserror" @@ -1147,9 +1148,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", @@ -1440,9 +1441,9 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 47ca6ee..8dc7f3b 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -9,9 +9,7 @@ name = "breadbox" path = "src/main.rs" [dependencies] -# Path dep for local dev; replace with git dep on first tag: -# bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "theme-v0.1.0", features = ["gtk"] } -bread-theme = { path = "../../bread-ecosystem/bread-theme", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.1.0", features = ["gtk"] } breadbox-shared = { path = "../breadbox-shared" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" From 0432c6d73a5c13654042b56f4514c4cf56939d2c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:52:44 +0800 Subject: [PATCH 23/60] fix: create GitHub Release before uploading artifacts --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3dcb2f4..3bfeaf2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,8 @@ jobs: run: | VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="${DL_DIR}/breadbox/${VERSION}" + gh release create "${GITHUB_REF_NAME}" \ + --title "breadbox v${VERSION}" --generate-notes 2>/dev/null || true gh release upload "${GITHUB_REF_NAME}" \ "${PKG_DIR}/breadbox-x86_64" \ "${PKG_DIR}/breadbox-sync-x86_64" \ From 15a45449e1a3d4bf7a105f7e21297ab13d15c249 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:52:44 +0800 Subject: [PATCH 24/60] fix: create GitHub Release before uploading artifacts --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3dcb2f4..3bfeaf2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,8 @@ jobs: run: | VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="${DL_DIR}/breadbox/${VERSION}" + gh release create "${GITHUB_REF_NAME}" \ + --title "breadbox v${VERSION}" --generate-notes 2>/dev/null || true gh release upload "${GITHUB_REF_NAME}" \ "${PKG_DIR}/breadbox-x86_64" \ "${PKG_DIR}/breadbox-sync-x86_64" \ From efc2a81643519c42bd86a5571910ac6ea679e901 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 00:00:49 +0800 Subject: [PATCH 25/60] fix: add contents: write permission for GitHub Release creation --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bfeaf2..73a1aee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: tags: ["v*"] +permissions: + contents: write + env: DL_DIR: /srv/breadway-dl ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem From 765f833f338f45ccd6eeb99d17f946658ff4aa35 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 00:00:49 +0800 Subject: [PATCH 26/60] fix: add contents: write permission for GitHub Release creation Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bfeaf2..73a1aee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: tags: ["v*"] +permissions: + contents: write + env: DL_DIR: /srv/breadway-dl ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem From fde5fe7c64413ebec78de47a50c9160d6eee01ba Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 09:02:38 +0800 Subject: [PATCH 27/60] fix: use relative symlink for latest to work inside Docker containers --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73a1aee..71c0ca3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: cp packaging/breadbox-sync.service "${PKG_DIR}/" cp config.example.toml "${PKG_DIR}/" cp bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${PKG_DIR}" "${DL_DIR}/breadbox/latest" + ln -sfn "${VERSION}" "${DL_DIR}/breadbox/latest" - name: ensure bread-ecosystem run: | From 3ebadee4f43b0f6189003c4c7e5b92a7a63026b5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 09:02:38 +0800 Subject: [PATCH 28/60] fix: use relative symlink for latest to work inside Docker containers Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73a1aee..71c0ca3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: cp packaging/breadbox-sync.service "${PKG_DIR}/" cp config.example.toml "${PKG_DIR}/" cp bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${PKG_DIR}" "${DL_DIR}/breadbox/latest" + ln -sfn "${VERSION}" "${DL_DIR}/breadbox/latest" - name: ensure bread-ecosystem run: | From 423d00383b109dfbca5d6c4c040d0bd567f93397 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 14:35:06 +0800 Subject: [PATCH 29/60] feat: rank search results by match quality and launch frequency Track per-app launch counts in ~/.cache/breadbox/history.json. When a query is active, sort visible results by fuzzy match quality (exact > prefix > contains > subsequence) then by launch count descending, so the most relevant and most-used app rises to the top. The base list (no query) also surfaces most-launched apps above unvisited ones. --- Cargo.lock | 7 ++-- breadbox-shared/Cargo.toml | 3 +- breadbox-shared/src/lib.rs | 33 +++++++++++++++++ breadbox-sync/Cargo.toml | 2 +- breadbox/Cargo.toml | 2 +- breadbox/src/main.rs | 76 ++++++++++++++++++++++++++++++++------ 6 files changed, 106 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf7328f..e484fcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,7 +39,7 @@ dependencies = [ [[package]] name = "breadbox" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bread-theme", "breadbox-shared", @@ -50,15 +50,16 @@ dependencies = [ [[package]] name = "breadbox-shared" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", + "serde_json", "toml 0.8.23", ] [[package]] name = "breadbox-sync" -version = "0.1.0" +version = "0.2.0" dependencies = [ "breadbox-shared", "serde_json", diff --git a/breadbox-shared/Cargo.toml b/breadbox-shared/Cargo.toml index 1600acd..0edc06a 100644 --- a/breadbox-shared/Cargo.toml +++ b/breadbox-shared/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "breadbox-shared" -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "MIT" [dependencies] serde = { version = "1", features = ["derive"] } +serde_json = "1" toml = "0.8" diff --git a/breadbox-shared/src/lib.rs b/breadbox-shared/src/lib.rs index 8ce6f7d..5286729 100644 --- a/breadbox-shared/src/lib.rs +++ b/breadbox-shared/src/lib.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, env, fs::{self, File}, io::{BufRead, BufReader}, @@ -215,6 +216,38 @@ impl Default for IconCache { } } +// ---- Launch history --------------------------------------------------------- + +pub struct LaunchHistory { + counts: HashMap, + 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)] diff --git a/breadbox-sync/Cargo.toml b/breadbox-sync/Cargo.toml index 1c68856..5938acb 100644 --- a/breadbox-sync/Cargo.toml +++ b/breadbox-sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-sync" -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "MIT" diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 8dc7f3b..2f09d31 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox" -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "MIT" diff --git a/breadbox/src/main.rs b/breadbox/src/main.rs index 29338a9..0fbab50 100644 --- a/breadbox/src/main.rs +++ b/breadbox/src/main.rs @@ -1,5 +1,6 @@ use bread_theme::{hex_to_rgba, load_palette, Palette}; use std::{ + cell::RefCell, collections::HashMap, env, fs, @@ -11,7 +12,7 @@ use std::{ }; use breadbox_shared::{ - config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, + config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, LaunchHistory, }; use gtk4::{ gdk::Display, @@ -58,6 +59,7 @@ fn load_manifest() -> HashMap { fn load_sorted_entries( manifest: &HashMap, priority: &[String], + history: &LaunchHistory, ) -> Vec { let mut entries = load_all_desktop_entries(); @@ -79,7 +81,11 @@ fn load_sorted_entries( (Some(i), Some(j)) => i.cmp(&j), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + (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())) + } } }); @@ -230,6 +236,17 @@ fn fuzzy_matches(pattern: &str, text: &str) -> bool { true } +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 +} + // ---- PID file toggle -------------------------------------------------------- fn pid_file() -> PathBuf { @@ -273,11 +290,14 @@ fn get_row_entry(row: >k4::ListBoxRow) -> Option { } } -fn run_ui(entries: Vec, css: String) { +fn run_ui(entries: Vec, css: String, history: LaunchHistory) { let app = Application::builder() .application_id("com.breadway.breadbox") .build(); + let history_rc = Rc::new(RefCell::new(history)); + let query_rc: Rc> = Rc::new(RefCell::new(String::new())); + app.connect_activate(move |app| { // Base CSS let provider = CssProvider::new(); @@ -290,7 +310,6 @@ fn run_ui(entries: Vec, css: String) { // User CSS override { - use std::cell::RefCell; let user_css_path = config_dir().join("style.css"); let user_cell: RefCell> = RefCell::new(None); bread_theme::gtk::apply_user_css(&user_css_path, &user_cell); @@ -334,7 +353,7 @@ fn run_ui(entries: Vec, css: String) { let list = ListBox::new(); list.set_selection_mode(SelectionMode::Browse); - for entry in &entries { + for (idx, entry) in entries.iter().enumerate() { let row = gtk4::ListBoxRow::new(); let hbox = GBox::new(Orientation::Horizontal, 0); hbox.set_margin_start(6); @@ -360,9 +379,35 @@ fn run_ui(entries: Vec, css: String) { 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::("initial_order").map_or(u32::MAX, |p| *p.as_ref()) }; + let ob = unsafe { row_b.data::("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)); } @@ -373,10 +418,11 @@ fn run_ui(entries: Vec, css: String) { // Filter on keystroke let list_f = list.clone(); + let filter_query = Rc::clone(&query_rc); search.connect_changed(move |entry| { let text = entry.text(); let query = text.as_str(); - let mut first_vis: Option = None; + *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) @@ -389,11 +435,12 @@ fn run_ui(entries: Vec, css: String) { }) .unwrap_or(false); row.set_visible(vis); - if vis && first_vis.is_none() { - first_vis = Some(row); - } 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()); }); @@ -402,6 +449,7 @@ fn run_ui(entries: Vec, css: String) { key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); let close_k = Rc::clone(&close_all); let list_k = list.clone(); + let history_k = Rc::clone(&history_rc); key_ctrl.connect_key_pressed(move |_, key, _, _| { use gtk4::gdk::Key; match key { @@ -412,6 +460,8 @@ fn run_ui(entries: Vec, css: String) { Key::Return | Key::KP_Enter => { if let Some(row) = list_k.selected_row() { if let Some(entry) = get_row_entry(&row) { + history_k.borrow_mut().increment(&entry.name); + history_k.borrow().save(); do_launch(&entry); close_k(); } @@ -458,8 +508,11 @@ fn run_ui(entries: Vec, css: String) { // Row click launches let close_a = Rc::clone(&close_all); + let history_a = Rc::clone(&history_rc); list.connect_row_activated(move |_, row| { if let Some(entry) = get_row_entry(row) { + history_a.borrow_mut().increment(&entry.name); + history_a.borrow().save(); do_launch(&entry); close_a(); } @@ -505,11 +558,12 @@ fn main() { .map(|c| c.priority.clone()) .unwrap_or_default(); + let history = LaunchHistory::load(); let manifest = load_manifest(); - let entries = load_sorted_entries(&manifest, &priority); + let entries = load_sorted_entries(&manifest, &priority, &history); let palette = load_palette(); let css = build_css(&palette); - run_ui(entries, css); + run_ui(entries, css, history); } From d2549e56dd0331de75bca627c1c11b86cb829428 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 14:35:06 +0800 Subject: [PATCH 30/60] feat: rank search results by match quality and launch frequency Track per-app launch counts in ~/.cache/breadbox/history.json. When a query is active, sort visible results by fuzzy match quality (exact > prefix > contains > subsequence) then by launch count descending, so the most relevant and most-used app rises to the top. The base list (no query) also surfaces most-launched apps above unvisited ones. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 7 ++-- breadbox-shared/Cargo.toml | 3 +- breadbox-shared/src/lib.rs | 33 +++++++++++++++++ breadbox-sync/Cargo.toml | 2 +- breadbox/Cargo.toml | 2 +- breadbox/src/main.rs | 76 ++++++++++++++++++++++++++++++++------ 6 files changed, 106 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf7328f..e484fcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,7 +39,7 @@ dependencies = [ [[package]] name = "breadbox" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bread-theme", "breadbox-shared", @@ -50,15 +50,16 @@ dependencies = [ [[package]] name = "breadbox-shared" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", + "serde_json", "toml 0.8.23", ] [[package]] name = "breadbox-sync" -version = "0.1.0" +version = "0.2.0" dependencies = [ "breadbox-shared", "serde_json", diff --git a/breadbox-shared/Cargo.toml b/breadbox-shared/Cargo.toml index 1600acd..0edc06a 100644 --- a/breadbox-shared/Cargo.toml +++ b/breadbox-shared/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "breadbox-shared" -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "MIT" [dependencies] serde = { version = "1", features = ["derive"] } +serde_json = "1" toml = "0.8" diff --git a/breadbox-shared/src/lib.rs b/breadbox-shared/src/lib.rs index 8ce6f7d..5286729 100644 --- a/breadbox-shared/src/lib.rs +++ b/breadbox-shared/src/lib.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, env, fs::{self, File}, io::{BufRead, BufReader}, @@ -215,6 +216,38 @@ impl Default for IconCache { } } +// ---- Launch history --------------------------------------------------------- + +pub struct LaunchHistory { + counts: HashMap, + 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)] diff --git a/breadbox-sync/Cargo.toml b/breadbox-sync/Cargo.toml index 1c68856..5938acb 100644 --- a/breadbox-sync/Cargo.toml +++ b/breadbox-sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-sync" -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "MIT" diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 8dc7f3b..2f09d31 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox" -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "MIT" diff --git a/breadbox/src/main.rs b/breadbox/src/main.rs index 29338a9..0fbab50 100644 --- a/breadbox/src/main.rs +++ b/breadbox/src/main.rs @@ -1,5 +1,6 @@ use bread_theme::{hex_to_rgba, load_palette, Palette}; use std::{ + cell::RefCell, collections::HashMap, env, fs, @@ -11,7 +12,7 @@ use std::{ }; use breadbox_shared::{ - config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, + config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, LaunchHistory, }; use gtk4::{ gdk::Display, @@ -58,6 +59,7 @@ fn load_manifest() -> HashMap { fn load_sorted_entries( manifest: &HashMap, priority: &[String], + history: &LaunchHistory, ) -> Vec { let mut entries = load_all_desktop_entries(); @@ -79,7 +81,11 @@ fn load_sorted_entries( (Some(i), Some(j)) => i.cmp(&j), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + (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())) + } } }); @@ -230,6 +236,17 @@ fn fuzzy_matches(pattern: &str, text: &str) -> bool { true } +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 +} + // ---- PID file toggle -------------------------------------------------------- fn pid_file() -> PathBuf { @@ -273,11 +290,14 @@ fn get_row_entry(row: >k4::ListBoxRow) -> Option { } } -fn run_ui(entries: Vec, css: String) { +fn run_ui(entries: Vec, css: String, history: LaunchHistory) { let app = Application::builder() .application_id("com.breadway.breadbox") .build(); + let history_rc = Rc::new(RefCell::new(history)); + let query_rc: Rc> = Rc::new(RefCell::new(String::new())); + app.connect_activate(move |app| { // Base CSS let provider = CssProvider::new(); @@ -290,7 +310,6 @@ fn run_ui(entries: Vec, css: String) { // User CSS override { - use std::cell::RefCell; let user_css_path = config_dir().join("style.css"); let user_cell: RefCell> = RefCell::new(None); bread_theme::gtk::apply_user_css(&user_css_path, &user_cell); @@ -334,7 +353,7 @@ fn run_ui(entries: Vec, css: String) { let list = ListBox::new(); list.set_selection_mode(SelectionMode::Browse); - for entry in &entries { + for (idx, entry) in entries.iter().enumerate() { let row = gtk4::ListBoxRow::new(); let hbox = GBox::new(Orientation::Horizontal, 0); hbox.set_margin_start(6); @@ -360,9 +379,35 @@ fn run_ui(entries: Vec, css: String) { 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::("initial_order").map_or(u32::MAX, |p| *p.as_ref()) }; + let ob = unsafe { row_b.data::("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)); } @@ -373,10 +418,11 @@ fn run_ui(entries: Vec, css: String) { // Filter on keystroke let list_f = list.clone(); + let filter_query = Rc::clone(&query_rc); search.connect_changed(move |entry| { let text = entry.text(); let query = text.as_str(); - let mut first_vis: Option = None; + *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) @@ -389,11 +435,12 @@ fn run_ui(entries: Vec, css: String) { }) .unwrap_or(false); row.set_visible(vis); - if vis && first_vis.is_none() { - first_vis = Some(row); - } 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()); }); @@ -402,6 +449,7 @@ fn run_ui(entries: Vec, css: String) { key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); let close_k = Rc::clone(&close_all); let list_k = list.clone(); + let history_k = Rc::clone(&history_rc); key_ctrl.connect_key_pressed(move |_, key, _, _| { use gtk4::gdk::Key; match key { @@ -412,6 +460,8 @@ fn run_ui(entries: Vec, css: String) { Key::Return | Key::KP_Enter => { if let Some(row) = list_k.selected_row() { if let Some(entry) = get_row_entry(&row) { + history_k.borrow_mut().increment(&entry.name); + history_k.borrow().save(); do_launch(&entry); close_k(); } @@ -458,8 +508,11 @@ fn run_ui(entries: Vec, css: String) { // Row click launches let close_a = Rc::clone(&close_all); + let history_a = Rc::clone(&history_rc); list.connect_row_activated(move |_, row| { if let Some(entry) = get_row_entry(row) { + history_a.borrow_mut().increment(&entry.name); + history_a.borrow().save(); do_launch(&entry); close_a(); } @@ -505,11 +558,12 @@ fn main() { .map(|c| c.priority.clone()) .unwrap_or_default(); + let history = LaunchHistory::load(); let manifest = load_manifest(); - let entries = load_sorted_entries(&manifest, &priority); + let entries = load_sorted_entries(&manifest, &priority, &history); let palette = load_palette(); let css = build_css(&palette); - run_ui(entries, css); + run_ui(entries, css, history); } From c01cb67aa55eeea15953ede78bc9aa082463277c Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 13:37:49 +0800 Subject: [PATCH 31/60] fix: add optional_system_deps (hyprland) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hyprland is used via IPC but not a linked dep — optional now. --- bakery.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/bakery.toml b/bakery.toml index 1f16c1f..f2abae4 100644 --- a/bakery.toml +++ b/bakery.toml @@ -2,6 +2,7 @@ name = "breadbox" description = "App launcher for Hyprland / Wayland" binaries = ["breadbox", "breadbox-sync"] system_deps = ["gtk4", "gtk4-layer-shell", "librsvg"] +optional_system_deps = ["hyprland"] bread_deps = [] [[service]] From d566127627c9d56277d8e2817222d0039dfdfe0b Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 13:37:49 +0800 Subject: [PATCH 32/60] fix: add optional_system_deps (hyprland) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hyprland is used via IPC but not a linked dep — optional now. Co-Authored-By: Claude Sonnet 4.6 --- bakery.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/bakery.toml b/bakery.toml index 1f16c1f..f2abae4 100644 --- a/bakery.toml +++ b/bakery.toml @@ -2,6 +2,7 @@ name = "breadbox" description = "App launcher for Hyprland / Wayland" binaries = ["breadbox", "breadbox-sync"] system_deps = ["gtk4", "gtk4-layer-shell", "librsvg"] +optional_system_deps = ["hyprland"] bread_deps = [] [[service]] From 311f0d261f7e530ebfaaf10524a1b2ce7743d676 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:21:31 +0800 Subject: [PATCH 33/60] chore: bump version to 0.2.1 --- breadbox-shared/Cargo.toml | 2 +- breadbox-sync/Cargo.toml | 2 +- breadbox/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/breadbox-shared/Cargo.toml b/breadbox-shared/Cargo.toml index 0edc06a..a47dc19 100644 --- a/breadbox-shared/Cargo.toml +++ b/breadbox-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-shared" -version = "0.2.0" +version = "0.2.1" edition = "2021" license = "MIT" diff --git a/breadbox-sync/Cargo.toml b/breadbox-sync/Cargo.toml index 5938acb..93b7392 100644 --- a/breadbox-sync/Cargo.toml +++ b/breadbox-sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-sync" -version = "0.2.0" +version = "0.2.1" edition = "2021" license = "MIT" diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 2f09d31..1de3b4e 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox" -version = "0.2.0" +version = "0.2.1" edition = "2021" license = "MIT" From cc1658b5f4d37142456cc1af6972d50110519ca3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:21:31 +0800 Subject: [PATCH 34/60] chore: bump version to 0.2.1 --- breadbox-shared/Cargo.toml | 2 +- breadbox-sync/Cargo.toml | 2 +- breadbox/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/breadbox-shared/Cargo.toml b/breadbox-shared/Cargo.toml index 0edc06a..a47dc19 100644 --- a/breadbox-shared/Cargo.toml +++ b/breadbox-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-shared" -version = "0.2.0" +version = "0.2.1" edition = "2021" license = "MIT" diff --git a/breadbox-sync/Cargo.toml b/breadbox-sync/Cargo.toml index 5938acb..93b7392 100644 --- a/breadbox-sync/Cargo.toml +++ b/breadbox-sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-sync" -version = "0.2.0" +version = "0.2.1" edition = "2021" license = "MIT" diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 2f09d31..1de3b4e 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox" -version = "0.2.0" +version = "0.2.1" edition = "2021" license = "MIT" From d2ef12551df4cb49c96d5573443bf06db54d2bdb Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:28:01 +0800 Subject: [PATCH 35/60] chore: update Cargo.lock for v0.2.1 --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e484fcb..77c0113 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,7 +39,7 @@ dependencies = [ [[package]] name = "breadbox" -version = "0.2.0" +version = "0.2.1" dependencies = [ "bread-theme", "breadbox-shared", @@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "breadbox-shared" -version = "0.2.0" +version = "0.2.1" dependencies = [ "serde", "serde_json", @@ -59,7 +59,7 @@ dependencies = [ [[package]] name = "breadbox-sync" -version = "0.2.0" +version = "0.2.1" dependencies = [ "breadbox-shared", "serde_json", From a38b8675835aae588814517f5bf482806ab44a51 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:28:01 +0800 Subject: [PATCH 36/60] chore: update Cargo.lock for v0.2.1 --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e484fcb..77c0113 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,7 +39,7 @@ dependencies = [ [[package]] name = "breadbox" -version = "0.2.0" +version = "0.2.1" dependencies = [ "bread-theme", "breadbox-shared", @@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "breadbox-shared" -version = "0.2.0" +version = "0.2.1" dependencies = [ "serde", "serde_json", @@ -59,7 +59,7 @@ dependencies = [ [[package]] name = "breadbox-sync" -version = "0.2.0" +version = "0.2.1" dependencies = [ "breadbox-shared", "serde_json", From f93596adf299712eb3199af364667bcfcb84e1c3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 12:12:42 +0800 Subject: [PATCH 37/60] Add packaging/arch PKGBUILD and Forgejo Actions workflows - packaging/arch/PKGBUILD: builds and publishes breadbox to [breadway] repo - .forgejo/workflows/mirror.yml: mirrors every push/tag to GitHub - .forgejo/workflows/package.yml: builds on tag, publishes to Forgejo registry Requires FORGEJO_TOKEN and GITHUB_MIRROR_TOKEN secrets in Forgejo. --- .forgejo/workflows/mirror.yml | 20 +++++++++++++++ .forgejo/workflows/package.yml | 46 ++++++++++++++++++++++++++++++++++ packaging/arch/PKGBUILD | 35 ++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 .forgejo/workflows/mirror.yml create mode 100644 .forgejo/workflows/package.yml create mode 100644 packaging/arch/PKGBUILD diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..ab5700f --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,20 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Push to GitHub + run: | + git remote add github \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadbox.git" + git push github --mirror diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..246126b --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -0,0 +1,46 @@ +name: Build and publish package + +on: + push: + tags: ['v*'] + +jobs: + package: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + options: --privileged + + steps: + - uses: actions/checkout@v4 + + - name: Set version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV + + - name: Install build dependencies + run: pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell librsvg + + - name: Create builder user + run: useradd -m builder + + - name: Prepare source + run: | + git archive --format=tar.gz \ + --prefix=breadbox-${VERSION}/ \ + HEAD > packaging/arch/breadbox-${VERSION}.tar.gz + SHA=$(sha256sum packaging/arch/breadbox-${VERSION}.tar.gz | awk '{print $1}') + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD + cp -r . /home/builder/src + chown -R builder:builder /home/builder/src + + - name: Build package + run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" + + - name: Publish to Forgejo registry + run: | + PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ + --upload-file "${PKG}" \ + "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..b0f0b1d --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,35 @@ +# Maintainer: Breadway + +pkgname=breadbox +pkgver=0.1.0 +pkgrel=1 +pkgdesc="App launcher for Hyprland / Wayland" +arch=('x86_64') +url="https://github.com/Breadway/breadbox" +license=('MIT') +depends=('gtk4' 'gtk4-layer-shell' 'librsvg') +optdepends=( + 'hyprland: window and workspace integration' +) +makedepends=('rust' 'cargo') +source=("${pkgname}-${pkgver}.tar.gz") +sha256sums=('SKIP') + +build() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo build --release --locked +} + +check() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo test --release --locked --workspace +} + +package() { + cd "${srcdir}/${pkgname}-${pkgver}" + install -Dm755 target/release/breadbox "${pkgdir}/usr/bin/breadbox" + install -Dm755 target/release/breadbox-sync "${pkgdir}/usr/bin/breadbox-sync" + install -Dm644 packaging/breadbox-sync.service \ + "${pkgdir}/usr/lib/systemd/user/breadbox-sync.service" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} From 6a2bd2864b8e94601411781ec0947d679c53372c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 12:12:42 +0800 Subject: [PATCH 38/60] Add packaging/arch PKGBUILD and Forgejo Actions workflows - packaging/arch/PKGBUILD: builds and publishes breadbox to [breadway] repo - .forgejo/workflows/mirror.yml: mirrors every push/tag to GitHub - .forgejo/workflows/package.yml: builds on tag, publishes to Forgejo registry Requires FORGEJO_TOKEN and GITHUB_MIRROR_TOKEN secrets in Forgejo. Co-Authored-By: Claude Sonnet 4.6 --- .forgejo/workflows/mirror.yml | 20 +++++++++++++++ .forgejo/workflows/package.yml | 46 ++++++++++++++++++++++++++++++++++ packaging/arch/PKGBUILD | 35 ++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 .forgejo/workflows/mirror.yml create mode 100644 .forgejo/workflows/package.yml create mode 100644 packaging/arch/PKGBUILD diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..ab5700f --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,20 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Push to GitHub + run: | + git remote add github \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadbox.git" + git push github --mirror diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..246126b --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -0,0 +1,46 @@ +name: Build and publish package + +on: + push: + tags: ['v*'] + +jobs: + package: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + options: --privileged + + steps: + - uses: actions/checkout@v4 + + - name: Set version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV + + - name: Install build dependencies + run: pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell librsvg + + - name: Create builder user + run: useradd -m builder + + - name: Prepare source + run: | + git archive --format=tar.gz \ + --prefix=breadbox-${VERSION}/ \ + HEAD > packaging/arch/breadbox-${VERSION}.tar.gz + SHA=$(sha256sum packaging/arch/breadbox-${VERSION}.tar.gz | awk '{print $1}') + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD + cp -r . /home/builder/src + chown -R builder:builder /home/builder/src + + - name: Build package + run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" + + - name: Publish to Forgejo registry + run: | + PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ + --upload-file "${PKG}" \ + "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..b0f0b1d --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,35 @@ +# Maintainer: Breadway + +pkgname=breadbox +pkgver=0.1.0 +pkgrel=1 +pkgdesc="App launcher for Hyprland / Wayland" +arch=('x86_64') +url="https://github.com/Breadway/breadbox" +license=('MIT') +depends=('gtk4' 'gtk4-layer-shell' 'librsvg') +optdepends=( + 'hyprland: window and workspace integration' +) +makedepends=('rust' 'cargo') +source=("${pkgname}-${pkgver}.tar.gz") +sha256sums=('SKIP') + +build() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo build --release --locked +} + +check() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo test --release --locked --workspace +} + +package() { + cd "${srcdir}/${pkgname}-${pkgver}" + install -Dm755 target/release/breadbox "${pkgdir}/usr/bin/breadbox" + install -Dm755 target/release/breadbox-sync "${pkgdir}/usr/bin/breadbox-sync" + install -Dm644 packaging/breadbox-sync.service \ + "${pkgdir}/usr/lib/systemd/user/breadbox-sync.service" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} From 43df888aa1ddbae60b1dce20f2a81069370ed395 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:02:16 +0800 Subject: [PATCH 39/60] Fix Forgejo workflows for the actual server capabilities - package.yml: correct Arch registry upload (octet-stream + binary body), drop --privileged, manual shell clone (archlinux image has no Node), built-in Actions token, --nocheck - mirror.yml: clone --mirror + explicit refs push with --prune --- .forgejo/workflows/mirror.yml | 17 ++++++------ .forgejo/workflows/package.yml | 48 +++++++++++++++------------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index ab5700f..807e3ab 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -9,12 +9,13 @@ jobs: mirror: runs-on: [self-hosted, hestia] steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Push to GitHub + - name: Mirror to GitHub run: | - git remote add github \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadbox.git" - git push github --mirror + set -euo pipefail + git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); + # --prune deletes GitHub refs that no longer exist on Forgejo. + git push --prune \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadbox.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 246126b..f88284d 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -9,38 +9,32 @@ jobs: runs-on: [self-hosted, hestia] container: image: archlinux:latest - options: --privileged - steps: - - uses: actions/checkout@v4 - - - name: Set version - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV - - - name: Install build dependencies - run: pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell librsvg - - - name: Create builder user - run: useradd -m builder - - - name: Prepare source + # Note: no actions/checkout — the archlinux image has no Node, which JS + # actions require. Everything runs as shell steps and clones manually. + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - git archive --format=tar.gz \ - --prefix=breadbox-${VERSION}/ \ - HEAD > packaging/arch/breadbox-${VERSION}.tar.gz + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell librsvg + useradd -m builder + git config --global --add safe.directory '*' + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + cd /home/builder/src + git archive --format=tar.gz --prefix="breadbox-${VERSION}/" HEAD \ + > packaging/arch/breadbox-${VERSION}.tar.gz SHA=$(sha256sum packaging/arch/breadbox-${VERSION}.tar.gz | awk '{print $1}') sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - cp -r . /home/builder/src chown -R builder:builder /home/builder/src - - - name: Build package - run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" - - - name: Publish to Forgejo registry - run: | + # --nocheck: packaging builds the artifact; tests belong in a CI job. + su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) curl -fsS -X PUT \ - -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ - --upload-file "${PKG}" \ - "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" From b6c596fb0dca0aaf5b81eec17d6d400328a69cfa Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:02:16 +0800 Subject: [PATCH 40/60] Fix Forgejo workflows for the actual server capabilities - package.yml: correct Arch registry upload (octet-stream + binary body), drop --privileged, manual shell clone (archlinux image has no Node), built-in Actions token, --nocheck - mirror.yml: clone --mirror + explicit refs push with --prune Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/mirror.yml | 17 ++++++------ .forgejo/workflows/package.yml | 48 +++++++++++++++------------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index ab5700f..807e3ab 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -9,12 +9,13 @@ jobs: mirror: runs-on: [self-hosted, hestia] steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Push to GitHub + - name: Mirror to GitHub run: | - git remote add github \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadbox.git" - git push github --mirror + set -euo pipefail + git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); + # --prune deletes GitHub refs that no longer exist on Forgejo. + git push --prune \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadbox.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 246126b..f88284d 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -9,38 +9,32 @@ jobs: runs-on: [self-hosted, hestia] container: image: archlinux:latest - options: --privileged - steps: - - uses: actions/checkout@v4 - - - name: Set version - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV - - - name: Install build dependencies - run: pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell librsvg - - - name: Create builder user - run: useradd -m builder - - - name: Prepare source + # Note: no actions/checkout — the archlinux image has no Node, which JS + # actions require. Everything runs as shell steps and clones manually. + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - git archive --format=tar.gz \ - --prefix=breadbox-${VERSION}/ \ - HEAD > packaging/arch/breadbox-${VERSION}.tar.gz + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell librsvg + useradd -m builder + git config --global --add safe.directory '*' + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + cd /home/builder/src + git archive --format=tar.gz --prefix="breadbox-${VERSION}/" HEAD \ + > packaging/arch/breadbox-${VERSION}.tar.gz SHA=$(sha256sum packaging/arch/breadbox-${VERSION}.tar.gz | awk '{print $1}') sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - cp -r . /home/builder/src chown -R builder:builder /home/builder/src - - - name: Build package - run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" - - - name: Publish to Forgejo registry - run: | + # --nocheck: packaging builds the artifact; tests belong in a CI job. + su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) curl -fsS -X PUT \ - -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ - --upload-file "${PKG}" \ - "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" From cbb1cf03d65d2824a6425102289442842661618c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:10:50 +0800 Subject: [PATCH 41/60] Rename mirror secret to MIRROR_TOKEN (GITHUB_ prefix is reserved) Forgejo/gitea rejects user secret names starting with GITHUB_. --- .forgejo/workflows/mirror.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 807e3ab..640d656 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -17,5 +17,5 @@ jobs: # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. git push --prune \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadbox.git" \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadbox.git" \ '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From 00f4d19e7896a791d0f5944218e6676483433e98 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:10:50 +0800 Subject: [PATCH 42/60] Rename mirror secret to MIRROR_TOKEN (GITHUB_ prefix is reserved) Forgejo/gitea rejects user secret names starting with GITHUB_. Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/mirror.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 807e3ab..640d656 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -17,5 +17,5 @@ jobs: # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. git push --prune \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadbox.git" \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadbox.git" \ '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From 8bc185f40c1018c285aae241c0cb267081787e24 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:14:14 +0800 Subject: [PATCH 43/60] Clone from public URL, not GITHUB_SERVER_URL (resolves to localhost in runner) The Forgejo runner injects GITHUB_SERVER_URL as http://localhost:3002, which is unreachable from inside the job container. Use the public URL instead. --- .forgejo/workflows/mirror.yml | 2 +- .forgejo/workflows/package.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 640d656..6f40c04 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -12,7 +12,7 @@ jobs: - name: Mirror to GitHub run: | set -euo pipefail - git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git cd repo.git # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index f88284d..7502e73 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -22,7 +22,7 @@ jobs: useradd -m builder git config --global --add safe.directory '*' git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src cd /home/builder/src git archive --format=tar.gz --prefix="breadbox-${VERSION}/" HEAD \ > packaging/arch/breadbox-${VERSION}.tar.gz From f7ad3b494f1a520be4298d0227483770e909402e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:14:14 +0800 Subject: [PATCH 44/60] Clone from public URL, not GITHUB_SERVER_URL (resolves to localhost in runner) The Forgejo runner injects GITHUB_SERVER_URL as http://localhost:3002, which is unreachable from inside the job container. Use the public URL instead. Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/mirror.yml | 2 +- .forgejo/workflows/package.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 640d656..6f40c04 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -12,7 +12,7 @@ jobs: - name: Mirror to GitHub run: | set -euo pipefail - git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git cd repo.git # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index f88284d..7502e73 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -22,7 +22,7 @@ jobs: useradd -m builder git config --global --add safe.directory '*' git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src cd /home/builder/src git archive --format=tar.gz --prefix="breadbox-${VERSION}/" HEAD \ > packaging/arch/breadbox-${VERSION}.tar.gz From b80e06b253b9937f7755b0ba2d252d2281a21adf Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 17:06:55 +0800 Subject: [PATCH 45/60] Disable LTO in PKGBUILD (vendored ring/mlua static libs vs makepkg -flto) --- packaging/arch/PKGBUILD | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index b0f0b1d..bca6391 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -7,6 +7,10 @@ pkgdesc="App launcher for Hyprland / Wayland" arch=('x86_64') url="https://github.com/Breadway/breadbox" license=('MIT') +# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's +# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, +# causing undefined-symbol errors. Disable LTO. +options=(!lto) depends=('gtk4' 'gtk4-layer-shell' 'librsvg') optdepends=( 'hyprland: window and workspace integration' From f586360101ff1fdebdcb9e4d4794409be8731016 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 17:06:55 +0800 Subject: [PATCH 46/60] Disable LTO in PKGBUILD (vendored ring/mlua static libs vs makepkg -flto) Co-Authored-By: Claude Opus 4.8 --- packaging/arch/PKGBUILD | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index b0f0b1d..bca6391 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -7,6 +7,10 @@ pkgdesc="App launcher for Hyprland / Wayland" arch=('x86_64') url="https://github.com/Breadway/breadbox" license=('MIT') +# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's +# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, +# causing undefined-symbol errors. Disable LTO. +options=(!lto) depends=('gtk4' 'gtk4-layer-shell' 'librsvg') optdepends=( 'hyprland: window and workspace integration' From 21392645cd2eda30a5b0a5d07150abcf48b5c71c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 22:55:41 +0800 Subject: [PATCH 47/60] Use REGISTRY_TOKEN (scoped write:package) for registry publish --- .forgejo/workflows/package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 7502e73..fecfe9d 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -14,7 +14,7 @@ jobs: # actions require. Everything runs as shell steps and clones manually. - name: Build and publish env: - PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" From 95c457e76c46d4fe8c1afa7279c9d250619c65fd Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 22:55:41 +0800 Subject: [PATCH 48/60] Use REGISTRY_TOKEN (scoped write:package) for registry publish Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 7502e73..fecfe9d 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -14,7 +14,7 @@ jobs: # actions require. Everything runs as shell steps and clones manually. - name: Build and publish env: - PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" From a6007e9a6a7cc5b7521efe611059f330990acfa9 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 23:00:50 +0800 Subject: [PATCH 49/60] Disable debug package so the main package publishes correctly makepkg's debug split produced a -debug pkg; the upload's head -1 could grab it instead of the main package. !debug yields a single package. --- packaging/arch/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index bca6391..1df5ba5 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -10,7 +10,7 @@ license=('MIT') # Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's # default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, # causing undefined-symbol errors. Disable LTO. -options=(!lto) +options=(!lto !debug) depends=('gtk4' 'gtk4-layer-shell' 'librsvg') optdepends=( 'hyprland: window and workspace integration' From 5ccf050f31e71b92e3e752b0332810a207f553fa Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 23:00:50 +0800 Subject: [PATCH 50/60] Disable debug package so the main package publishes correctly makepkg's debug split produced a -debug pkg; the upload's head -1 could grab it instead of the main package. !debug yields a single package. Co-Authored-By: Claude Opus 4.8 --- packaging/arch/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index bca6391..1df5ba5 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -10,7 +10,7 @@ license=('MIT') # Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's # default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, # causing undefined-symbol errors. Disable LTO. -options=(!lto) +options=(!lto !debug) depends=('gtk4' 'gtk4-layer-shell' 'librsvg') optdepends=( 'hyprland: window and workspace integration' From d464689a184a9fb846aa77b27ab727a742113bf0 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 16:57:12 +0800 Subject: [PATCH 51/60] theme: load the shared bread-theme stylesheet Apply bread_theme::gtk::apply_shared() before breadbox's own CSS so fonts, palette, and generic widgets come from the shared ecosystem stylesheet. Keep only launcher-specific rules (launcher-bg, searchentry, rows). Bump bread-theme dep to v0.2.6. --- breadbox/Cargo.toml | 2 +- breadbox/src/main.rs | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 1de3b4e..f3965f5 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -9,7 +9,7 @@ name = "breadbox" path = "src/main.rs" [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.1.0", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.6", features = ["gtk"] } breadbox-shared = { path = "../breadbox-shared" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" diff --git a/breadbox/src/main.rs b/breadbox/src/main.rs index 0fbab50..68627d7 100644 --- a/breadbox/src/main.rs +++ b/breadbox/src/main.rs @@ -133,9 +133,10 @@ fn matches_term(field: &str, term: &str) -> bool { fn build_css(p: &Palette) -> String { let bg_panel = hex_to_rgba(&p.background, 0.60); + // breadbox-specific rules only — fonts, palette, and generic widgets come + // from the shared ecosystem stylesheet (applied first in connect_activate). format!( - "* {{ font-family: 'Varela Round', sans-serif; font-size: 14px; }}\ - window {{ background-color: transparent; }}\ + "window {{ background-color: transparent; }}\ .launcher-bg {{ background-color: {bg_panel}; border-radius: 8px;\ box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\ searchentry {{ background-color: {surface}; color: {fg}; caret-color: {accent};\ @@ -299,7 +300,10 @@ fn run_ui(entries: Vec, css: String, history: LaunchHistory) { let query_rc: Rc> = Rc::new(RefCell::new(String::new())); app.connect_activate(move |app| { - // Base CSS + // Shared ecosystem base (fonts, palette, generic widgets) first, then + // breadbox-specific CSS layered on top at APPLICATION priority. + bread_theme::gtk::apply_shared(); + let provider = CssProvider::new(); provider.load_from_string(&css); gtk4::style_context_add_provider_for_display( From c5651b5a9b318d89f2fb2b3e07700f20a6d51a03 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 16:57:12 +0800 Subject: [PATCH 52/60] theme: load the shared bread-theme stylesheet Apply bread_theme::gtk::apply_shared() before breadbox's own CSS so fonts, palette, and generic widgets come from the shared ecosystem stylesheet. Keep only launcher-specific rules (launcher-bg, searchentry, rows). Bump bread-theme dep to v0.2.6. Co-Authored-By: Claude Opus 4.8 --- breadbox/Cargo.toml | 2 +- breadbox/src/main.rs | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 1de3b4e..f3965f5 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -9,7 +9,7 @@ name = "breadbox" path = "src/main.rs" [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.1.0", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.6", features = ["gtk"] } breadbox-shared = { path = "../breadbox-shared" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" diff --git a/breadbox/src/main.rs b/breadbox/src/main.rs index 0fbab50..68627d7 100644 --- a/breadbox/src/main.rs +++ b/breadbox/src/main.rs @@ -133,9 +133,10 @@ fn matches_term(field: &str, term: &str) -> bool { fn build_css(p: &Palette) -> String { let bg_panel = hex_to_rgba(&p.background, 0.60); + // breadbox-specific rules only — fonts, palette, and generic widgets come + // from the shared ecosystem stylesheet (applied first in connect_activate). format!( - "* {{ font-family: 'Varela Round', sans-serif; font-size: 14px; }}\ - window {{ background-color: transparent; }}\ + "window {{ background-color: transparent; }}\ .launcher-bg {{ background-color: {bg_panel}; border-radius: 8px;\ box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\ searchentry {{ background-color: {surface}; color: {fg}; caret-color: {accent};\ @@ -299,7 +300,10 @@ fn run_ui(entries: Vec, css: String, history: LaunchHistory) { let query_rc: Rc> = Rc::new(RefCell::new(String::new())); app.connect_activate(move |app| { - // Base CSS + // Shared ecosystem base (fonts, palette, generic widgets) first, then + // breadbox-specific CSS layered on top at APPLICATION priority. + bread_theme::gtk::apply_shared(); + let provider = CssProvider::new(); provider.load_from_string(&css); gtk4::style_context_add_provider_for_display( From 732c3126ae50941d7b4ccc1442a8d8d4fe0930cb Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 18:32:12 +0800 Subject: [PATCH 53/60] Release 0.2.2: shared bread-theme stylesheet Pin bread-theme v0.2.6 and load the shared ecosystem stylesheet. --- Cargo.lock | 10 +++++----- breadbox-shared/Cargo.toml | 2 +- breadbox-sync/Cargo.toml | 2 +- breadbox/Cargo.toml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 77c0113..88fcfd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,8 +28,8 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" -version = "0.1.0" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.1.0#6b5f4f475f66a645b08cb865e6dda8228d23679b" +version = "0.2.3" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.6#0c8c5c00e435fedff4f81e36d603424c153519a9" dependencies = [ "dirs", "gtk4", @@ -39,7 +39,7 @@ dependencies = [ [[package]] name = "breadbox" -version = "0.2.1" +version = "0.2.2" dependencies = [ "bread-theme", "breadbox-shared", @@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "breadbox-shared" -version = "0.2.1" +version = "0.2.2" dependencies = [ "serde", "serde_json", @@ -59,7 +59,7 @@ dependencies = [ [[package]] name = "breadbox-sync" -version = "0.2.1" +version = "0.2.2" dependencies = [ "breadbox-shared", "serde_json", diff --git a/breadbox-shared/Cargo.toml b/breadbox-shared/Cargo.toml index a47dc19..9a3de5c 100644 --- a/breadbox-shared/Cargo.toml +++ b/breadbox-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-shared" -version = "0.2.1" +version = "0.2.2" edition = "2021" license = "MIT" diff --git a/breadbox-sync/Cargo.toml b/breadbox-sync/Cargo.toml index 93b7392..d60c2fa 100644 --- a/breadbox-sync/Cargo.toml +++ b/breadbox-sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-sync" -version = "0.2.1" +version = "0.2.2" edition = "2021" license = "MIT" diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index f3965f5..f5784c8 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox" -version = "0.2.1" +version = "0.2.2" edition = "2021" license = "MIT" From 5da3fd8468d70c53e7ff3e7710cbdc76131830f0 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 18:32:12 +0800 Subject: [PATCH 54/60] Release 0.2.2: shared bread-theme stylesheet Pin bread-theme v0.2.6 and load the shared ecosystem stylesheet. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 10 +++++----- breadbox-shared/Cargo.toml | 2 +- breadbox-sync/Cargo.toml | 2 +- breadbox/Cargo.toml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 77c0113..88fcfd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,8 +28,8 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" -version = "0.1.0" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.1.0#6b5f4f475f66a645b08cb865e6dda8228d23679b" +version = "0.2.3" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.6#0c8c5c00e435fedff4f81e36d603424c153519a9" dependencies = [ "dirs", "gtk4", @@ -39,7 +39,7 @@ dependencies = [ [[package]] name = "breadbox" -version = "0.2.1" +version = "0.2.2" dependencies = [ "bread-theme", "breadbox-shared", @@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "breadbox-shared" -version = "0.2.1" +version = "0.2.2" dependencies = [ "serde", "serde_json", @@ -59,7 +59,7 @@ dependencies = [ [[package]] name = "breadbox-sync" -version = "0.2.1" +version = "0.2.2" dependencies = [ "breadbox-shared", "serde_json", diff --git a/breadbox-shared/Cargo.toml b/breadbox-shared/Cargo.toml index a47dc19..9a3de5c 100644 --- a/breadbox-shared/Cargo.toml +++ b/breadbox-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-shared" -version = "0.2.1" +version = "0.2.2" edition = "2021" license = "MIT" diff --git a/breadbox-sync/Cargo.toml b/breadbox-sync/Cargo.toml index 93b7392..d60c2fa 100644 --- a/breadbox-sync/Cargo.toml +++ b/breadbox-sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-sync" -version = "0.2.1" +version = "0.2.2" edition = "2021" license = "MIT" diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index f3965f5..f5784c8 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox" -version = "0.2.1" +version = "0.2.2" edition = "2021" license = "MIT" From 8c64ec1bf2b6b60fc953fb4169e7a5de9afa0a33 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 18:32:48 +0800 Subject: [PATCH 55/60] Release 0.2.4: shared bread-theme stylesheet --- Cargo.lock | 6 +++--- breadbox-shared/Cargo.toml | 2 +- breadbox-sync/Cargo.toml | 2 +- breadbox/Cargo.toml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 88fcfd0..18d6e7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,7 +39,7 @@ dependencies = [ [[package]] name = "breadbox" -version = "0.2.2" +version = "0.2.4" dependencies = [ "bread-theme", "breadbox-shared", @@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "breadbox-shared" -version = "0.2.2" +version = "0.2.4" dependencies = [ "serde", "serde_json", @@ -59,7 +59,7 @@ dependencies = [ [[package]] name = "breadbox-sync" -version = "0.2.2" +version = "0.2.4" dependencies = [ "breadbox-shared", "serde_json", diff --git a/breadbox-shared/Cargo.toml b/breadbox-shared/Cargo.toml index 9a3de5c..ec62ed1 100644 --- a/breadbox-shared/Cargo.toml +++ b/breadbox-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-shared" -version = "0.2.2" +version = "0.2.4" edition = "2021" license = "MIT" diff --git a/breadbox-sync/Cargo.toml b/breadbox-sync/Cargo.toml index d60c2fa..bdcab16 100644 --- a/breadbox-sync/Cargo.toml +++ b/breadbox-sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-sync" -version = "0.2.2" +version = "0.2.4" edition = "2021" license = "MIT" diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index f5784c8..6f23930 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox" -version = "0.2.2" +version = "0.2.4" edition = "2021" license = "MIT" From 69c982eef9c891e794b4cf5fa07ba61620dca152 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 18:32:48 +0800 Subject: [PATCH 56/60] Release 0.2.4: shared bread-theme stylesheet Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 6 +++--- breadbox-shared/Cargo.toml | 2 +- breadbox-sync/Cargo.toml | 2 +- breadbox/Cargo.toml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 88fcfd0..18d6e7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,7 +39,7 @@ dependencies = [ [[package]] name = "breadbox" -version = "0.2.2" +version = "0.2.4" dependencies = [ "bread-theme", "breadbox-shared", @@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "breadbox-shared" -version = "0.2.2" +version = "0.2.4" dependencies = [ "serde", "serde_json", @@ -59,7 +59,7 @@ dependencies = [ [[package]] name = "breadbox-sync" -version = "0.2.2" +version = "0.2.4" dependencies = [ "breadbox-shared", "serde_json", diff --git a/breadbox-shared/Cargo.toml b/breadbox-shared/Cargo.toml index 9a3de5c..ec62ed1 100644 --- a/breadbox-shared/Cargo.toml +++ b/breadbox-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-shared" -version = "0.2.2" +version = "0.2.4" edition = "2021" license = "MIT" diff --git a/breadbox-sync/Cargo.toml b/breadbox-sync/Cargo.toml index d60c2fa..bdcab16 100644 --- a/breadbox-sync/Cargo.toml +++ b/breadbox-sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox-sync" -version = "0.2.2" +version = "0.2.4" edition = "2021" license = "MIT" diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index f5784c8..6f23930 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbox" -version = "0.2.2" +version = "0.2.4" edition = "2021" license = "MIT" From 83b9fd908ea6bab8d089b2fee24b8bd149e8e48b Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 17 Jun 2026 12:41:30 +0800 Subject: [PATCH 57/60] Fix illegible text on light pywal palettes + hot-reload Use bread-theme 0.2.7's luminance-picked ink (@on-*): a selected/hovered row set its background to `surface` but kept the pywal foreground, so the highlighted item went unreadable when `surface` came out light. Colour is now set per surface (panel, search box, row states) and labels inherit it. Switch to bread_theme::gtk::apply_app_css so breadbox picks up `bread-theme reload` (and rebuilds from the fresh palette on each launch). --- Cargo.lock | 2 +- breadbox/Cargo.toml | 2 +- breadbox/src/main.rs | 47 ++++++++++++++++++++------------------------ 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18d6e7c..ca7661c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,7 +29,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.6#0c8c5c00e435fedff4f81e36d603424c153519a9" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.7#ea87083c0615fc9141b0ae4c99f833748a0189d1" dependencies = [ "dirs", "gtk4", diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 6f23930..6b71c50 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -9,7 +9,7 @@ name = "breadbox" path = "src/main.rs" [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.6", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.7", features = ["gtk"] } breadbox-shared = { path = "../breadbox-shared" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" diff --git a/breadbox/src/main.rs b/breadbox/src/main.rs index 68627d7..17f72ac 100644 --- a/breadbox/src/main.rs +++ b/breadbox/src/main.rs @@ -1,4 +1,4 @@ -use bread_theme::{hex_to_rgba, load_palette, Palette}; +use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette}; use std::{ cell::RefCell, collections::HashMap, @@ -15,7 +15,6 @@ use breadbox_shared::{ config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, LaunchHistory, }; use gtk4::{ - gdk::Display, glib, pango::EllipsizeMode, prelude::*, @@ -135,25 +134,30 @@ fn build_css(p: &Palette) -> String { let bg_panel = hex_to_rgba(&p.background, 0.60); // breadbox-specific rules only — fonts, palette, and generic widgets come // from the shared ecosystem stylesheet (applied first in connect_activate). + // 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. format!( "window {{ background-color: transparent; }}\ - .launcher-bg {{ background-color: {bg_panel}; border-radius: 8px;\ + .launcher-bg {{ background-color: {bg_panel}; color: {on_bg}; border-radius: 8px;\ box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\ - searchentry {{ background-color: {surface}; color: {fg}; caret-color: {accent};\ + searchentry {{ background-color: {surface}; color: {on_surface}; caret-color: {accent};\ border: none; outline: none; box-shadow: none;\ padding: 12px 16px; border-radius: 6px 6px 0 0; }}\ listbox {{ background-color: transparent; padding: 4px; }}\ - row {{ padding: 8px 12px; color: {fg}; background-color: transparent;\ + row {{ padding: 8px 12px; color: {on_bg}; background-color: transparent;\ border-radius: 6px; }}\ - row:hover {{ background-color: {surface}; }}\ - row:selected {{ background-color: {surface}; }}\ + row:hover {{ background-color: {surface}; color: {on_surface}; }}\ + row:selected {{ background-color: {surface}; color: {on_surface}; }}\ .app-name {{ font-size: 14px; }}\ - .app-muted {{ color: {fg}; opacity: 0.6; font-size: 12px; }}\ + .app-muted {{ opacity: 0.6; font-size: 12px; }}\ image {{ margin-right: 8px; }}", - bg_panel = bg_panel, - surface = p.color0, - fg = p.foreground, - accent = p.color4, + bg_panel = bg_panel, + surface = p.color0, + accent = p.color4, + on_bg = ink_on(&p.background), + on_surface = ink_on(&p.color0), ) } @@ -291,7 +295,7 @@ fn get_row_entry(row: >k4::ListBoxRow) -> Option { } } -fn run_ui(entries: Vec, css: String, history: LaunchHistory) { +fn run_ui(entries: Vec, history: LaunchHistory) { let app = Application::builder() .application_id("com.breadway.breadbox") .build(); @@ -301,16 +305,10 @@ fn run_ui(entries: Vec, css: String, history: LaunchHistory) { app.connect_activate(move |app| { // Shared ecosystem base (fonts, palette, generic widgets) first, then - // breadbox-specific CSS layered on top at APPLICATION priority. + // breadbox-specific CSS layered on top — both hot-reload on + // `bread-theme reload` (the closure re-reads the pywal palette). bread_theme::gtk::apply_shared(); - - let provider = CssProvider::new(); - provider.load_from_string(&css); - gtk4::style_context_add_provider_for_display( - &Display::default().expect("no display"), - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); + bread_theme::gtk::apply_app_css(|| build_css(&load_palette())); // User CSS override { @@ -566,8 +564,5 @@ fn main() { let manifest = load_manifest(); let entries = load_sorted_entries(&manifest, &priority, &history); - let palette = load_palette(); - let css = build_css(&palette); - - run_ui(entries, css, history); + run_ui(entries, history); } From 79f09a5e45eedc05271f1c89ab37ff3098c8795e Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 17 Jun 2026 12:41:30 +0800 Subject: [PATCH 58/60] Fix illegible text on light pywal palettes + hot-reload Use bread-theme 0.2.7's luminance-picked ink (@on-*): a selected/hovered row set its background to `surface` but kept the pywal foreground, so the highlighted item went unreadable when `surface` came out light. Colour is now set per surface (panel, search box, row states) and labels inherit it. Switch to bread_theme::gtk::apply_app_css so breadbox picks up `bread-theme reload` (and rebuilds from the fresh palette on each launch). Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 2 +- breadbox/Cargo.toml | 2 +- breadbox/src/main.rs | 47 ++++++++++++++++++++------------------------ 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18d6e7c..ca7661c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,7 +29,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.6#0c8c5c00e435fedff4f81e36d603424c153519a9" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.7#ea87083c0615fc9141b0ae4c99f833748a0189d1" dependencies = [ "dirs", "gtk4", diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 6f23930..6b71c50 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -9,7 +9,7 @@ name = "breadbox" path = "src/main.rs" [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.6", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.7", features = ["gtk"] } breadbox-shared = { path = "../breadbox-shared" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" diff --git a/breadbox/src/main.rs b/breadbox/src/main.rs index 68627d7..17f72ac 100644 --- a/breadbox/src/main.rs +++ b/breadbox/src/main.rs @@ -1,4 +1,4 @@ -use bread_theme::{hex_to_rgba, load_palette, Palette}; +use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette}; use std::{ cell::RefCell, collections::HashMap, @@ -15,7 +15,6 @@ use breadbox_shared::{ config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, LaunchHistory, }; use gtk4::{ - gdk::Display, glib, pango::EllipsizeMode, prelude::*, @@ -135,25 +134,30 @@ fn build_css(p: &Palette) -> String { let bg_panel = hex_to_rgba(&p.background, 0.60); // breadbox-specific rules only — fonts, palette, and generic widgets come // from the shared ecosystem stylesheet (applied first in connect_activate). + // 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. format!( "window {{ background-color: transparent; }}\ - .launcher-bg {{ background-color: {bg_panel}; border-radius: 8px;\ + .launcher-bg {{ background-color: {bg_panel}; color: {on_bg}; border-radius: 8px;\ box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\ - searchentry {{ background-color: {surface}; color: {fg}; caret-color: {accent};\ + searchentry {{ background-color: {surface}; color: {on_surface}; caret-color: {accent};\ border: none; outline: none; box-shadow: none;\ padding: 12px 16px; border-radius: 6px 6px 0 0; }}\ listbox {{ background-color: transparent; padding: 4px; }}\ - row {{ padding: 8px 12px; color: {fg}; background-color: transparent;\ + row {{ padding: 8px 12px; color: {on_bg}; background-color: transparent;\ border-radius: 6px; }}\ - row:hover {{ background-color: {surface}; }}\ - row:selected {{ background-color: {surface}; }}\ + row:hover {{ background-color: {surface}; color: {on_surface}; }}\ + row:selected {{ background-color: {surface}; color: {on_surface}; }}\ .app-name {{ font-size: 14px; }}\ - .app-muted {{ color: {fg}; opacity: 0.6; font-size: 12px; }}\ + .app-muted {{ opacity: 0.6; font-size: 12px; }}\ image {{ margin-right: 8px; }}", - bg_panel = bg_panel, - surface = p.color0, - fg = p.foreground, - accent = p.color4, + bg_panel = bg_panel, + surface = p.color0, + accent = p.color4, + on_bg = ink_on(&p.background), + on_surface = ink_on(&p.color0), ) } @@ -291,7 +295,7 @@ fn get_row_entry(row: >k4::ListBoxRow) -> Option { } } -fn run_ui(entries: Vec, css: String, history: LaunchHistory) { +fn run_ui(entries: Vec, history: LaunchHistory) { let app = Application::builder() .application_id("com.breadway.breadbox") .build(); @@ -301,16 +305,10 @@ fn run_ui(entries: Vec, css: String, history: LaunchHistory) { app.connect_activate(move |app| { // Shared ecosystem base (fonts, palette, generic widgets) first, then - // breadbox-specific CSS layered on top at APPLICATION priority. + // breadbox-specific CSS layered on top — both hot-reload on + // `bread-theme reload` (the closure re-reads the pywal palette). bread_theme::gtk::apply_shared(); - - let provider = CssProvider::new(); - provider.load_from_string(&css); - gtk4::style_context_add_provider_for_display( - &Display::default().expect("no display"), - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); + bread_theme::gtk::apply_app_css(|| build_css(&load_palette())); // User CSS override { @@ -566,8 +564,5 @@ fn main() { let manifest = load_manifest(); let entries = load_sorted_entries(&manifest, &priority, &history); - let palette = load_palette(); - let css = build_css(&palette); - - run_ui(entries, css, history); + run_ui(entries, history); } From a33e979e5ee53cb051b80f7e03e95a19400be750 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 17 Jun 2026 12:55:52 +0800 Subject: [PATCH 59/60] Bump bread-theme to v0.2.8 (live-reload fix) --- Cargo.lock | 2 +- breadbox/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca7661c..4f3f9c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,7 +29,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.7#ea87083c0615fc9141b0ae4c99f833748a0189d1" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b" dependencies = [ "dirs", "gtk4", diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 6b71c50..9189abf 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -9,7 +9,7 @@ name = "breadbox" path = "src/main.rs" [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.7", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] } breadbox-shared = { path = "../breadbox-shared" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" From 649967a7251c235a495e0085b6894ed1d6ccfd65 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 17 Jun 2026 12:55:52 +0800 Subject: [PATCH 60/60] Bump bread-theme to v0.2.8 (live-reload fix) Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 2 +- breadbox/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca7661c..4f3f9c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,7 +29,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.7#ea87083c0615fc9141b0ae4c99f833748a0189d1" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b" dependencies = [ "dirs", "gtk4", diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml index 6b71c50..9189abf 100644 --- a/breadbox/Cargo.toml +++ b/breadbox/Cargo.toml @@ -9,7 +9,7 @@ name = "breadbox" path = "src/main.rs" [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.7", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] } breadbox-shared = { path = "../breadbox-shared" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8"