diff --git a/Cargo.lock b/Cargo.lock index 5af4c11..434d3a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 10d698a..ef5d936 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/bread-launcher/Cargo.toml b/bread-launcher/Cargo.toml new file mode 100644 index 0000000..c126c18 --- /dev/null +++ b/bread-launcher/Cargo.toml @@ -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..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"] diff --git a/bread-launcher/src/desktop.rs b/bread-launcher/src/desktop.rs new file mode 100644 index 0000000..1a205c9 --- /dev/null +++ b/bread-launcher/src/desktop.rs @@ -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, // 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(); + + 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 { + 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() +} diff --git a/bread-launcher/src/gtk.rs b/bread-launcher/src/gtk.rs new file mode 100644 index 0000000..e270511 --- /dev/null +++ b/bread-launcher/src/gtk.rs @@ -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 { + unsafe { row.data::("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>, + history: Rc>, +} + +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>) -> 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> = 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::("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)) = (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 { + 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(); + } +} diff --git a/bread-launcher/src/history.rs b/bread-launcher/src/history.rs new file mode 100644 index 0000000..816fd66 --- /dev/null +++ b/bread-launcher/src/history.rs @@ -0,0 +1,33 @@ +use std::{collections::HashMap, fs, path::PathBuf}; + +pub struct LaunchHistory { + counts: HashMap, + 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); + } + } +} diff --git a/bread-launcher/src/icon.rs b/bread-launcher/src/icon.rs new file mode 100644 index 0000000..f6ad616 --- /dev/null +++ b/bread-launcher/src/icon.rs @@ -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) + } +} diff --git a/bread-launcher/src/launch.rs b/bread-launcher/src/launch.rs new file mode 100644 index 0000000..410ad4c --- /dev/null +++ b/bread-launcher/src/launch.rs @@ -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 }), + ); +} diff --git a/bread-launcher/src/lib.rs b/bread-launcher/src/lib.rs new file mode 100644 index 0000000..182900f --- /dev/null +++ b/bread-launcher/src/lib.rs @@ -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}; diff --git a/bread-launcher/src/matching.rs b/bread-launcher/src/matching.rs new file mode 100644 index 0000000..7fde970 --- /dev/null +++ b/bread-launcher/src/matching.rs @@ -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 { + 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, + priority: &[String], + history: &LaunchHistory, +) -> 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) => { + // 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); + } +} diff --git a/bread-launcher/src/paths.rs b/bread-launcher/src/paths.rs new file mode 100644 index 0000000..5a87df0 --- /dev/null +++ b/bread-launcher/src/paths.rs @@ -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/` (or `~/.cache/`). `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/` (or `~/.config/`). 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 { + 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 +}