Add bread-launcher: headless app-launcher core + GTK4 results widget
Extracts the app-launcher substance (desktop-entry parsing, fuzzy matching/ranking, launch history, launching) out of breadbox into a reusable ecosystem crate, so breadbar's future embedded capsule and breadbox's overlay window can share one implementation instead of two (THEME_SYSTEM_PLAN.md Phase 4/6a). The GTK4 results-list widget lives behind a `gtk` feature, mirroring bread-theme's `gtk`/`adw` gating, so a headless consumer isn't forced to link GTK. Adds unit tests for fuzzy_score/matches_term/priority_rank, which were previously untested pure functions inside breadbox's main.rs.
This commit is contained in:
parent
96fa79c1d4
commit
6ffd689a4d
11 changed files with 883 additions and 1 deletions
9
Cargo.lock
generated
9
Cargo.lock
generated
|
|
@ -202,6 +202,15 @@ dependencies = [
|
|||
"image",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-launcher"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"bread-utils",
|
||||
"gtk4",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-onnx"
|
||||
version = "0.7.4"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit"]
|
||||
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit", "bread-launcher"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
26
bread-launcher/Cargo.toml
Normal file
26
bread-launcher/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
name = "bread-launcher"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Headless app-launcher core (desktop-entry discovery, fuzzy matching/ranking, launch history, launching) plus an optional GTK4 results-list widget — the shared logic behind breadbox's overlay window and breadbar's embedded capsule"
|
||||
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
||||
keywords = ["launcher", "desktop-entry", "gtk4", "wayland"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
# `do_launch`/`emit_launched` publish a `bread.<app>.launched` event over
|
||||
# breadd's IPC socket after a successful spawn, fire-and-forget — this was
|
||||
# already breadbox's behaviour (`BreadClient::emit` never blocks or errors
|
||||
# the launching caller), just relocated. Not optional: launching is core,
|
||||
# headless functionality, unlike the GTK widget below.
|
||||
bread-utils = { path = "../bread-utils", features = ["bread-client"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
|
||||
|
||||
[features]
|
||||
# Enable the GTK4 results-list widget (`gtk` module): row building, fuzzy
|
||||
# filtering, match/history sorting, and keyboard-style selection movement.
|
||||
# Optional so a headless consumer of the matching/ranking/launch core (or a
|
||||
# future non-GTK host) doesn't have to pull in GTK4.
|
||||
gtk = ["dep:gtk4"]
|
||||
151
bread-launcher/src/desktop.rs
Normal file
151
bread-launcher/src/desktop.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
use std::{
|
||||
fs::{self, File},
|
||||
io::{BufRead, BufReader},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::paths::app_dirs;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DesktopEntry {
|
||||
/// Desktop file id (the `.desktop` filename, e.g. `firefox.desktop`).
|
||||
/// Empty only if the path had no file name; callers fall back to `exec`.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub exec: String,
|
||||
pub icon_name: String,
|
||||
pub icon_path: Option<PathBuf>, // resolved by caller from manifest
|
||||
pub categories: Vec<String>,
|
||||
pub wm_class: Option<String>,
|
||||
pub terminal: bool,
|
||||
}
|
||||
|
||||
pub fn strip_exec_codes(exec: &str) -> String {
|
||||
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<DesktopEntry> {
|
||||
let file = File::open(path).ok()?;
|
||||
let mut in_entry = false;
|
||||
let mut name: Option<String> = None;
|
||||
let mut exec: Option<String> = None;
|
||||
let mut icon: Option<String> = None;
|
||||
let mut categories: Option<String> = None;
|
||||
let mut wm_class: Option<String> = None;
|
||||
let mut app_type: Option<String> = None;
|
||||
let mut no_display = false;
|
||||
let mut hidden = false;
|
||||
let mut terminal = false;
|
||||
|
||||
for line in BufReader::new(file).lines() {
|
||||
let Ok(raw) = line else { continue };
|
||||
let s = raw.trim();
|
||||
if s.starts_with('#') || s.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if s.starts_with('[') {
|
||||
in_entry = s == "[Desktop Entry]";
|
||||
continue;
|
||||
}
|
||||
if !in_entry {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(v) = s.strip_prefix("Name=") {
|
||||
name.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("Exec=") {
|
||||
exec.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("Icon=") {
|
||||
icon.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("Categories=") {
|
||||
categories.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("StartupWMClass=") {
|
||||
wm_class.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("Type=") {
|
||||
app_type.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("NoDisplay=") {
|
||||
no_display = v == "true";
|
||||
} else if let Some(v) = s.strip_prefix("Hidden=") {
|
||||
hidden = v == "true";
|
||||
} else if let Some(v) = s.strip_prefix("Terminal=") {
|
||||
terminal = v == "true" || v == "1";
|
||||
}
|
||||
}
|
||||
|
||||
if no_display || hidden {
|
||||
return None;
|
||||
}
|
||||
if app_type.as_deref() != Some("Application") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = name?.trim().to_string();
|
||||
let exec = strip_exec_codes(exec?.trim()).trim().to_string();
|
||||
if name.is_empty() || exec.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let icon_name = icon.unwrap_or_default().trim().to_string();
|
||||
let cats = categories
|
||||
.unwrap_or_default()
|
||||
.split(';')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let id = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(DesktopEntry {
|
||||
id,
|
||||
name,
|
||||
exec,
|
||||
icon_name,
|
||||
icon_path: None,
|
||||
categories: cats,
|
||||
wm_class: wm_class.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
|
||||
terminal,
|
||||
})
|
||||
}
|
||||
|
||||
/// Walk all configured application directories and return deduplicated entries.
|
||||
/// Entries from later directories (user-local) override those from earlier ones.
|
||||
pub fn load_all_desktop_entries() -> Vec<DesktopEntry> {
|
||||
let mut seen: std::collections::HashMap<String, DesktopEntry> = std::collections::HashMap::new();
|
||||
for dir in app_dirs() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else { continue };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
|
||||
continue;
|
||||
}
|
||||
let key = entry.file_name().to_string_lossy().into_owned();
|
||||
if let Some(app) = parse_desktop(&path) {
|
||||
seen.insert(key, app);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen.into_values().collect()
|
||||
}
|
||||
221
bread-launcher/src/gtk.rs
Normal file
221
bread-launcher/src/gtk.rs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
//! GTK4 results-list widget: the "row-building half" of what used to be
|
||||
//! breadbox's `run_ui` (`THEME_SYSTEM_PLAN.md` §3) — desktop-entry rows,
|
||||
//! fuzzy filtering, match/history sorting, and keyboard-style selection
|
||||
//! movement, packaged as [`ResultsList`] so any host window can embed it.
|
||||
//! breadbox wraps it in a full-screen overlay window today; breadbar's
|
||||
//! embedded capsule (a later phase) puts the same widget in its drawer slot.
|
||||
|
||||
use std::{cell::RefCell, path::Path, rc::Rc};
|
||||
|
||||
use gtk4::{
|
||||
gdk, gio,
|
||||
pango::EllipsizeMode,
|
||||
prelude::*,
|
||||
Align, Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation, PolicyType,
|
||||
ScrolledWindow, SelectionMode,
|
||||
};
|
||||
|
||||
use crate::desktop::DesktopEntry;
|
||||
use crate::history::LaunchHistory;
|
||||
use crate::matching::{fuzzy_matches, fuzzy_score};
|
||||
|
||||
fn make_icon(icon_name: &str, icon_path: Option<&Path>, icon_px: i32) -> Image {
|
||||
// Try loading from resolved cached path via gio::File
|
||||
if let Some(path) = icon_path {
|
||||
let gio_file = gio::File::for_path(path);
|
||||
if let Ok(texture) = gdk::Texture::from_file(&gio_file) {
|
||||
let img = Image::new();
|
||||
img.set_paintable(Some(&texture));
|
||||
img.set_pixel_size(icon_px);
|
||||
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 = Image::from_icon_name(name);
|
||||
img.set_pixel_size(icon_px);
|
||||
img
|
||||
}
|
||||
|
||||
fn build_row(entry: &DesktopEntry, idx: u32, icon_px: i32) -> ListBoxRow {
|
||||
let row = ListBoxRow::new();
|
||||
let hbox = GBox::new(Orientation::Horizontal, 0);
|
||||
hbox.set_margin_start(6);
|
||||
hbox.set_margin_end(6);
|
||||
hbox.set_valign(Align::Center);
|
||||
|
||||
let icon = make_icon(&entry.icon_name, entry.icon_path.as_deref(), icon_px);
|
||||
hbox.append(&icon);
|
||||
|
||||
let name_lbl = Label::new(Some(&entry.name));
|
||||
name_lbl.add_css_class("app-name");
|
||||
name_lbl.set_xalign(0.0);
|
||||
name_lbl.set_hexpand(true);
|
||||
name_lbl.set_ellipsize(EllipsizeMode::End);
|
||||
hbox.append(&name_lbl);
|
||||
|
||||
if let Some(ref wm) = entry.wm_class {
|
||||
let wm_lbl = Label::new(Some(wm));
|
||||
wm_lbl.add_css_class("app-muted");
|
||||
wm_lbl.set_xalign(1.0);
|
||||
hbox.append(&wm_lbl);
|
||||
}
|
||||
|
||||
row.set_child(Some(&hbox));
|
||||
unsafe { row.set_data("entry", entry.clone()) };
|
||||
unsafe { row.set_data("initial_order", idx) };
|
||||
row
|
||||
}
|
||||
|
||||
/// Reads the [`DesktopEntry`] a row was built from — e.g. from a
|
||||
/// `ListBox::connect_row_activated` handler, which hands back a row
|
||||
/// reference rather than going through [`ResultsList::selected_entry`].
|
||||
pub fn row_entry(row: &ListBoxRow) -> Option<DesktopEntry> {
|
||||
unsafe { row.data::<DesktopEntry>("entry").map(|p| p.as_ref().clone()) }
|
||||
}
|
||||
|
||||
/// A scrollable, filterable, rankable list of desktop-entry rows — the
|
||||
/// widget breadbox's overlay wraps today and breadbar's capsule will embed
|
||||
/// next (`THEME_SYSTEM_PLAN.md` §7). A host drives it through
|
||||
/// [`set_query`](Self::set_query) (wire to a search entry's `changed`
|
||||
/// signal), [`select_next`](Self::select_next)/[`select_prev`](Self::select_prev)
|
||||
/// (wire to arrow keys), and reads the current pick via
|
||||
/// [`selected_entry`](Self::selected_entry) — `list`/`scroller` are exposed
|
||||
/// directly for anything else a host needs (e.g. `connect_row_activated`
|
||||
/// for click-to-launch, or placing `scroller` in a slot).
|
||||
#[derive(Clone)]
|
||||
pub struct ResultsList {
|
||||
pub scroller: ScrolledWindow,
|
||||
pub list: ListBox,
|
||||
query: Rc<RefCell<String>>,
|
||||
history: Rc<RefCell<LaunchHistory>>,
|
||||
}
|
||||
|
||||
impl ResultsList {
|
||||
/// Builds one row per entry (in `entries`' given order — that order is
|
||||
/// also the fallback sort when the query is empty) and wires up sorting
|
||||
/// against `history`'s launch counts.
|
||||
pub fn new(entries: &[DesktopEntry], icon_px: i32, history: Rc<RefCell<LaunchHistory>>) -> Self {
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(SelectionMode::Browse);
|
||||
|
||||
for (idx, entry) in entries.iter().enumerate() {
|
||||
list.append(&build_row(entry, idx as u32, icon_px));
|
||||
}
|
||||
|
||||
let query: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
|
||||
{
|
||||
let query = Rc::clone(&query);
|
||||
let history = Rc::clone(&history);
|
||||
list.set_sort_func(move |row_a, row_b| {
|
||||
let query = query.borrow();
|
||||
if query.is_empty() {
|
||||
let oa = unsafe {
|
||||
row_a.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref())
|
||||
};
|
||||
let ob = unsafe {
|
||||
row_b.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref())
|
||||
};
|
||||
return oa.cmp(&ob).into();
|
||||
}
|
||||
let (Some(ea), Some(eb)) = (row_entry(row_a), 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 = 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));
|
||||
}
|
||||
|
||||
let scroller = ScrolledWindow::new();
|
||||
scroller.set_policy(PolicyType::Never, PolicyType::Automatic);
|
||||
scroller.set_max_content_height(480);
|
||||
scroller.set_propagate_natural_height(true);
|
||||
scroller.set_child(Some(&list));
|
||||
|
||||
ResultsList { scroller, list, query, history }
|
||||
}
|
||||
|
||||
/// Re-filters (fuzzy match against name, `wm_class`, and `exec`) and
|
||||
/// re-sorts by `query`, then selects the first visible row.
|
||||
pub fn set_query(&self, query: &str) {
|
||||
*self.query.borrow_mut() = query.to_string();
|
||||
let mut i = 0i32;
|
||||
while let Some(row) = self.list.row_at_index(i) {
|
||||
let vis = row_entry(&row)
|
||||
.map(|e| {
|
||||
fuzzy_matches(query, &e.name)
|
||||
|| e.wm_class.as_deref().is_some_and(|w| fuzzy_matches(query, w))
|
||||
|| fuzzy_matches(query, &e.exec)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
row.set_visible(vis);
|
||||
i += 1;
|
||||
}
|
||||
self.list.invalidate_sort();
|
||||
let first_vis = (0i32..).find_map(|j| self.list.row_at_index(j).filter(|r| r.is_visible()));
|
||||
self.list.select_row(first_vis.as_ref());
|
||||
}
|
||||
|
||||
pub fn selected_entry(&self) -> Option<DesktopEntry> {
|
||||
self.list.selected_row().and_then(|r| row_entry(&r))
|
||||
}
|
||||
|
||||
/// Moves the selection to the next visible row, if any.
|
||||
pub fn select_next(&self) {
|
||||
let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(-1);
|
||||
let mut i = cur + 1;
|
||||
loop {
|
||||
match self.list.row_at_index(i) {
|
||||
Some(r) if r.is_visible() => {
|
||||
self.list.select_row(Some(&r));
|
||||
break;
|
||||
}
|
||||
Some(_) => i += 1,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the selection to the previous visible row, if any.
|
||||
pub fn select_prev(&self) {
|
||||
let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(0);
|
||||
let mut i = cur - 1;
|
||||
loop {
|
||||
if i < 0 {
|
||||
break;
|
||||
}
|
||||
match self.list.row_at_index(i) {
|
||||
Some(r) if r.is_visible() => {
|
||||
self.list.select_row(Some(&r));
|
||||
break;
|
||||
}
|
||||
Some(_) => i -= 1,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `entry` as launched in the shared history and persists it.
|
||||
/// Call before actually launching (matching breadbox's original
|
||||
/// increment-then-launch ordering) — history and launching are separate
|
||||
/// concerns, so this doesn't call [`crate::do_launch`] itself.
|
||||
pub fn record_launch(&self, entry: &DesktopEntry) {
|
||||
self.history.borrow_mut().increment(&entry.name);
|
||||
self.history.borrow().save();
|
||||
}
|
||||
}
|
||||
33
bread-launcher/src/history.rs
Normal file
33
bread-launcher/src/history.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
use std::{collections::HashMap, fs, path::PathBuf};
|
||||
|
||||
pub struct LaunchHistory {
|
||||
counts: HashMap<String, u32>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl LaunchHistory {
|
||||
/// `app` picks the cache subdirectory (see [`crate::cache_dir`]) the
|
||||
/// history file lives in.
|
||||
pub fn load(app: &str) -> Self {
|
||||
let path = crate::paths::cache_dir(app).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);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
bread-launcher/src/icon.rs
Normal file
26
bread-launcher/src/icon.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use std::{fs, path::PathBuf};
|
||||
|
||||
pub struct IconCache {
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
impl IconCache {
|
||||
/// `app` picks the cache subdirectory (see [`crate::cache_dir`]) — pass
|
||||
/// the same name across a process's calls so `path_for` and
|
||||
/// `manifest_path` agree on where icons live.
|
||||
pub fn new(app: &str) -> Self {
|
||||
IconCache { dir: crate::paths::cache_dir(app).join("icons") }
|
||||
}
|
||||
|
||||
pub fn path_for(&self, icon_name: &str) -> PathBuf {
|
||||
self.dir.join(format!("{}.png", icon_name))
|
||||
}
|
||||
|
||||
pub fn manifest_path(app: &str) -> PathBuf {
|
||||
crate::paths::cache_dir(app).join("manifest.json")
|
||||
}
|
||||
|
||||
pub fn ensure_dir(&self) -> std::io::Result<()> {
|
||||
fs::create_dir_all(&self.dir)
|
||||
}
|
||||
}
|
||||
65
bread-launcher/src/launch.rs
Normal file
65
bread-launcher/src/launch.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
use std::{
|
||||
env,
|
||||
path::Path,
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
use bread_utils::bread_client::BreadClient;
|
||||
|
||||
use crate::desktop::DesktopEntry;
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// Spawns `entry`'s command (through a terminal if `entry.terminal` is set)
|
||||
/// and, on a successful spawn, publishes `event` via [`emit_launched`].
|
||||
pub fn do_launch(entry: &DesktopEntry, app_id: &str, event: &str) {
|
||||
let cmd = entry.exec.trim();
|
||||
let spawned = if entry.terminal {
|
||||
let term = pick_terminal();
|
||||
Command::new(&term)
|
||||
.args(["-e", "bash", "-c", cmd])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
} else {
|
||||
Command::new("bash")
|
||||
.args(["-c", cmd])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
};
|
||||
if spawned.is_ok() {
|
||||
emit_launched(entry, app_id, event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes `event` (e.g. `bread.box.launched`) as `app_id` after a
|
||||
/// successful spawn. Fire-and-forget and non-fatal (`BreadClient::emit`
|
||||
/// never blocks or errors this caller) — breadd being absent must never
|
||||
/// affect launching itself.
|
||||
pub fn emit_launched(entry: &DesktopEntry, app_id: &str, event: &str) {
|
||||
let id = if entry.id.is_empty() {
|
||||
entry.exec.as_str()
|
||||
} else {
|
||||
entry.id.as_str()
|
||||
};
|
||||
BreadClient::connect(app_id).emit(
|
||||
event,
|
||||
serde_json::json!({ "id": id, "name": entry.name }),
|
||||
);
|
||||
}
|
||||
29
bread-launcher/src/lib.rs
Normal file
29
bread-launcher/src/lib.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
//! Headless app-launcher core — desktop-entry discovery, fuzzy matching and
|
||||
//! ranking, launch history, and process launching — plus an optional GTK4
|
||||
//! results-list widget behind the `gtk` feature.
|
||||
//!
|
||||
//! Lives in `bread-ecosystem`, not an app repo, so breadbar (which must not
|
||||
//! depend on an app repo) can embed the same launcher logic breadbox's
|
||||
//! overlay window already wraps: one implementation, two hosts
|
||||
//! (`THEME_SYSTEM_PLAN.md` §3, §7).
|
||||
//!
|
||||
//! Every path/cache/history entry point here takes an explicit `app: &str`
|
||||
//! rather than hardcoding an app name, so more than one host can use this
|
||||
//! crate without colliding — see [`cache_dir`]/[`config_dir`].
|
||||
|
||||
mod desktop;
|
||||
mod history;
|
||||
mod icon;
|
||||
mod launch;
|
||||
mod matching;
|
||||
mod paths;
|
||||
|
||||
#[cfg(feature = "gtk")]
|
||||
pub mod gtk;
|
||||
|
||||
pub use desktop::{load_all_desktop_entries, parse_desktop, strip_exec_codes, DesktopEntry};
|
||||
pub use history::LaunchHistory;
|
||||
pub use icon::IconCache;
|
||||
pub use launch::{do_launch, emit_launched};
|
||||
pub use matching::{fuzzy_matches, fuzzy_score, load_sorted_entries, matches_term, priority_rank};
|
||||
pub use paths::{app_dirs, cache_dir, config_dir, home_dir};
|
||||
272
bread-launcher/src/matching.rs
Normal file
272
bread-launcher/src/matching.rs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use crate::desktop::{load_all_desktop_entries, DesktopEntry};
|
||||
use crate::history::LaunchHistory;
|
||||
|
||||
// ---- Fuzzy matching (query filter) ------------------------------------------
|
||||
|
||||
/// Subsequence match used to *filter* rows as the user types: every char of
|
||||
/// `pattern`, in order, must appear somewhere in `text` (case-insensitive).
|
||||
/// Looser than [`fuzzy_score`], which ranks the rows that pass this filter.
|
||||
pub 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
|
||||
}
|
||||
|
||||
/// Ranks how well `query` matches `entry` — lower is better. Exact match (by
|
||||
/// name or `wm_class`) sorts first, then name-prefix, then name-contains,
|
||||
/// then `wm_class`-prefix/contains, then everything else that still passed
|
||||
/// [`fuzzy_matches`] (a subsequence match with no stronger relationship).
|
||||
pub 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
|
||||
}
|
||||
|
||||
// ---- Priority ranking (empty-query ordering) --------------------------------
|
||||
|
||||
/// Whole-word / exact match of `term` within `field` (both lowercase). Avoids
|
||||
/// "code" matching "vscodium" while still matching "Code", "code-oss", and
|
||||
/// "Visual Studio Code".
|
||||
pub 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
|
||||
}
|
||||
|
||||
/// Position of `entry` in the (already-lowercased) `priority` list, matched
|
||||
/// against either its name or `wm_class`. `None` if `entry` isn't named
|
||||
/// there at all.
|
||||
pub fn priority_rank(entry: &DesktopEntry, priority_lower: &[String]) -> Option<usize> {
|
||||
let name_l = entry.name.to_lowercase();
|
||||
let wm_l = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
|
||||
priority_lower
|
||||
.iter()
|
||||
.position(|p| matches_term(&name_l, p) || matches_term(&wm_l, p))
|
||||
}
|
||||
|
||||
/// Loads every known desktop entry, resolves each one's icon path from
|
||||
/// `manifest`, and sorts them: entries named in `priority` come first (in
|
||||
/// that order), then everything else by most-launched (via `history`), then
|
||||
/// alphabetically.
|
||||
pub fn load_sorted_entries(
|
||||
manifest: &HashMap<String, PathBuf>,
|
||||
priority: &[String],
|
||||
history: &LaunchHistory,
|
||||
) -> Vec<DesktopEntry> {
|
||||
let mut entries = load_all_desktop_entries();
|
||||
|
||||
// Populate icon_path from manifest
|
||||
for entry in &mut entries {
|
||||
if let Some(path) = manifest.get(&entry.icon_name) {
|
||||
if path.exists() {
|
||||
entry.icon_path = Some(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let priority_lower: Vec<String> = priority.iter().map(|s| s.to_lowercase()).collect();
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
let ai = priority_rank(a, &priority_lower);
|
||||
let bi = priority_rank(b, &priority_lower);
|
||||
match (ai, bi) {
|
||||
(Some(i), Some(j)) => i.cmp(&j),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => {
|
||||
// Most-launched first, then alphabetical
|
||||
history
|
||||
.count(&b.name)
|
||||
.cmp(&history.count(&a.name))
|
||||
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(name: &str, wm_class: Option<&str>) -> DesktopEntry {
|
||||
DesktopEntry {
|
||||
id: format!("{name}.desktop"),
|
||||
name: name.to_string(),
|
||||
exec: "true".to_string(),
|
||||
icon_name: String::new(),
|
||||
icon_path: None,
|
||||
categories: Vec::new(),
|
||||
wm_class: wm_class.map(|s| s.to_string()),
|
||||
terminal: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- fuzzy_matches -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_empty_pattern_matches_anything() {
|
||||
assert!(fuzzy_matches("", "Firefox"));
|
||||
assert!(fuzzy_matches("", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_in_order_subsequence() {
|
||||
assert!(fuzzy_matches("ffx", "Firefox"));
|
||||
assert!(fuzzy_matches("frfx", "Firefox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_is_case_insensitive() {
|
||||
assert!(fuzzy_matches("FIREFOX", "firefox"));
|
||||
assert!(fuzzy_matches("firefox", "FireFox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_rejects_out_of_order() {
|
||||
assert!(!fuzzy_matches("xfr", "Firefox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_rejects_missing_chars() {
|
||||
assert!(!fuzzy_matches("firefoxx", "Firefox"));
|
||||
}
|
||||
|
||||
// ---- fuzzy_score -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn fuzzy_score_exact_name_match_is_best() {
|
||||
let e = entry("Firefox", None);
|
||||
assert_eq!(fuzzy_score("firefox", &e), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_score_exact_wm_class_match_is_best() {
|
||||
let e = entry("Firefox Web Browser", Some("firefox"));
|
||||
assert_eq!(fuzzy_score("firefox", &e), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_score_name_prefix_beats_name_contains() {
|
||||
let prefix = entry("Firefox", None);
|
||||
let contains = entry("GNU IceCat (Firefox fork)", None);
|
||||
assert_eq!(fuzzy_score("fire", &prefix), 1);
|
||||
assert_eq!(fuzzy_score("fire", &contains), 2);
|
||||
assert!(fuzzy_score("fire", &prefix) < fuzzy_score("fire", &contains));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_score_wm_class_beats_pure_subsequence() {
|
||||
let wm_hit = entry("Web Browser", Some("firefox"));
|
||||
let subseq_only = entry("Fine Iris Reflex Editor for XML", None);
|
||||
assert_eq!(fuzzy_score("fire", &wm_hit), 3);
|
||||
assert_eq!(fuzzy_score("fire", &subseq_only), 4);
|
||||
}
|
||||
|
||||
// ---- matches_term ------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn matches_term_exact_field_matches() {
|
||||
assert!(matches_term("code", "code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_term_whole_word_within_longer_field() {
|
||||
assert!(matches_term("visual studio code", "code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_term_rejects_substring_of_a_larger_word() {
|
||||
// "code" must not match inside "vscodium" — this is the whole
|
||||
// reason matches_term exists instead of a plain `contains`.
|
||||
assert!(!matches_term("vscodium", "code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_term_matches_hyphenated_variant() {
|
||||
assert!(matches_term("code-oss", "code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_term_empty_term_or_field_never_matches() {
|
||||
assert!(!matches_term("code", ""));
|
||||
assert!(!matches_term("", "code"));
|
||||
}
|
||||
|
||||
// ---- priority_rank -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn priority_rank_matches_by_name() {
|
||||
let e = entry("Firefox", None);
|
||||
let priority = vec!["firefox".to_string(), "code".to_string()];
|
||||
assert_eq!(priority_rank(&e, &priority), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_rank_matches_by_wm_class() {
|
||||
let e = entry("Web Browser", Some("firefox"));
|
||||
let priority = vec!["code".to_string(), "firefox".to_string()];
|
||||
assert_eq!(priority_rank(&e, &priority), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_rank_none_when_unlisted() {
|
||||
let e = entry("Nautilus", None);
|
||||
let priority = vec!["firefox".to_string()];
|
||||
assert_eq!(priority_rank(&e, &priority), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_rank_does_not_match_substring_of_a_word() {
|
||||
let e = entry("VSCodium", None);
|
||||
let priority = vec!["code".to_string()];
|
||||
assert_eq!(priority_rank(&e, &priority), None);
|
||||
}
|
||||
}
|
||||
50
bread-launcher/src/paths.rs
Normal file
50
bread-launcher/src/paths.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
use std::{env, path::PathBuf};
|
||||
|
||||
// ---- XDG path helpers -------------------------------------------------------
|
||||
|
||||
pub fn home_dir() -> PathBuf {
|
||||
PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into()))
|
||||
}
|
||||
|
||||
/// `$XDG_CACHE_HOME/<app>` (or `~/.cache/<app>`). `app` is the caller's own
|
||||
/// name in this scheme — e.g. breadbox passes `"breadbox"` to keep using the
|
||||
/// on-disk layout it always has; a future host picks its own.
|
||||
pub fn cache_dir(app: &str) -> PathBuf {
|
||||
env::var("XDG_CACHE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| home_dir().join(".cache"))
|
||||
.join(app)
|
||||
}
|
||||
|
||||
/// `$XDG_CONFIG_HOME/<app>` (or `~/.config/<app>`). See [`cache_dir`].
|
||||
pub fn config_dir(app: &str) -> PathBuf {
|
||||
env::var("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| home_dir().join(".config"))
|
||||
.join(app)
|
||||
}
|
||||
|
||||
/// The `applications/` directories a `.desktop` file may live in, per the
|
||||
/// XDG base-directory spec (system-wide first, user-local last so later
|
||||
/// entries can override earlier ones on lookup by filename).
|
||||
pub fn app_dirs() -> Vec<PathBuf> {
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue