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.
272 lines
8.6 KiB
Rust
272 lines
8.6 KiB
Rust
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);
|
|
}
|
|
}
|