bread-ecosystem/bread-launcher/src/matching.rs
Breadway 6ffd689a4d 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.
2026-08-31 15:14:44 +08:00

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