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:
Breadway 2026-08-24 23:40:38 +08:00
parent 96fa79c1d4
commit 6ffd689a4d
11 changed files with 883 additions and 1 deletions

9
Cargo.lock generated
View file

@ -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"

View file

@ -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
View 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"]

View 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
View 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();
}
}

View 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);
}
}
}

View 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)
}
}

View 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
View 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};

View 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);
}
}

View 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
}