Skip to content

Commit 17fc4c8

Browse files
committed
settings(fuzzy): typo-tolerant search via Damerau-Levenshtein
The previous fuzzy_match was a pure left-to-right subsequence check, so 'pwoer' failed against 'Power' (o must come after w in the target, but the query has them swapped). Now runs three strategies in order: 1. Case-insensitive substring. 2. Subsequence (old behavior; kept so 'ppr' -> 'Power Profile' works). 3. Damerau-Levenshtein distance against each word in the label, capped at 1 + query.len()/4 edits. Strategy 3 handles the reported case (pwoer -> Power, distance 1 via one adjacent transposition) plus substitutions (poqer), deletions (powr), and insertions (poweer). The threshold scales with query length so noise like 'wifi' never matches 'Power'. Covered by 8 unit tests in settings::fuzzy_tests. Bumps workspace version 0.8.14 -> 0.8.15.
1 parent 69628f6 commit 17fc4c8

4 files changed

Lines changed: 190 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@ All notable changes to smpl-apps are documented here.
88

99
### Fixed
1010

11+
- **settings: Search now tolerates typos and adjacent-letter transpositions.**
12+
The previous `fuzzy_match` was a pure left-to-right subsequence check, so
13+
`pwoer` failed against `Power` (the `o` has to come *after* the `w` in the
14+
target, but the query had them swapped). The matcher now runs three
15+
strategies in order: (1) case-insensitive substring, (2) subsequence (the
16+
old behavior — kept so abbreviations like `ppr` still hit `Power Profile`),
17+
and (3) Damerau–Levenshtein edit distance against each word in the label,
18+
capped at `1 + query.len()/4` edits. That last strategy handles the reported
19+
case (`pwoer``Power`, distance 1 via one adjacent transposition) plus
20+
substitutions (`poqer`), deletions (`powr`), and insertions (`poweer`).
21+
The threshold scales with query length so noise like `wifi` never matches
22+
`Power`. Covered by 8 unit tests in `settings::fuzzy_tests`.
23+
24+
---
25+
26+
## v0.8.14
27+
28+
### Fixed
29+
1130
- **calendar: "Details" button now actually opens the full calendar view.**
1231
The previous implementation flipped the layout to details mode and then
1332
resized the compact popup in place via

Cargo.lock

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ members = [
1212
resolver = "2"
1313

1414
[workspace.package]
15-
version = "0.8.14"
15+
version = "0.8.15"
1616
edition = "2021"
1717
authors = ["smplOS <https://github.com/smpl-os>"]
1818
license = "MIT"

settings/src/main.rs

Lines changed: 162 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,60 @@ fn apply_theme(ui: &MainWindow) {
223223

224224
// ── Fuzzy search ─────────────────────────────────────────────────────────────
225225

226-
/// Simple fuzzy match: every character in the query must appear in order in the
227-
/// target string (case-insensitive). e.g. "ppr" matches "Power Profile".
226+
/// Match `query` against `target` for the Settings search box.
227+
///
228+
/// Three complementary strategies, tried in order (cheapest first):
229+
///
230+
/// 1. Case-insensitive substring — hits "Power" for "pow".
231+
/// 2. Subsequence — every char of `query` appears in `target` in order.
232+
/// This is the classic abbreviation match, e.g. "ppr" → "Power Profile".
233+
/// 3. Damerau–Levenshtein distance against each whitespace-separated word
234+
/// in `target`, allowing up to `1 + query.len()/4` edits. This is the
235+
/// one that handles typos and transpositions the previous impl missed:
236+
/// "pwoer" → "power" (one adjacent transposition, D-L distance 1).
237+
///
238+
/// A `true` from any strategy short-circuits, so cheap matches never pay
239+
/// the O(n·m) cost of the edit-distance pass.
228240
fn fuzzy_match(target: &str, query: &str) -> bool {
229-
let lower = target.to_lowercase();
230-
let mut target_chars = lower.chars();
241+
if query.is_empty() {
242+
return true;
243+
}
244+
let target_lc = target.to_lowercase();
245+
let query_lc = query.to_lowercase();
246+
247+
if target_lc.contains(&query_lc) {
248+
return true;
249+
}
250+
if subsequence_match(&target_lc, &query_lc) {
251+
return true;
252+
}
253+
254+
// Edit-distance tolerance scales with query length so a 3-char query
255+
// isn't matched by a 3-edit typo (which would match almost anything).
256+
let query_chars: Vec<char> = query_lc.chars().collect();
257+
let threshold = 1 + query_chars.len() / 4;
258+
259+
for word in target_lc.split(|c: char| !c.is_alphanumeric()) {
260+
if word.is_empty() {
261+
continue;
262+
}
263+
let word_chars: Vec<char> = word.chars().collect();
264+
// Skip words whose length is too different — no possible edit path.
265+
let len_diff = word_chars.len().abs_diff(query_chars.len());
266+
if len_diff > threshold {
267+
continue;
268+
}
269+
if damerau_levenshtein(&word_chars, &query_chars) <= threshold {
270+
return true;
271+
}
272+
}
273+
false
274+
}
275+
276+
/// Subsequence match: every char of `query` appears in `target` in order.
277+
/// Both inputs are expected to already be case-normalized.
278+
fn subsequence_match(target: &str, query: &str) -> bool {
279+
let mut target_chars = target.chars();
231280
for qc in query.chars() {
232281
loop {
233282
match target_chars.next() {
@@ -240,6 +289,41 @@ fn fuzzy_match(target: &str, query: &str) -> bool {
240289
true
241290
}
242291

292+
/// Optimal-String-Alignment (restricted Damerau–Levenshtein) distance.
293+
/// Counts insertions, deletions, substitutions, and single adjacent
294+
/// transpositions. This is what lets "pwoer" match "power" with distance 1:
295+
/// the swapped 'w' and 'o' count as one edit, not two.
296+
fn damerau_levenshtein(a: &[char], b: &[char]) -> usize {
297+
let (n, m) = (a.len(), b.len());
298+
if n == 0 {
299+
return m;
300+
}
301+
if m == 0 {
302+
return n;
303+
}
304+
// Only three rows are ever needed for OSA (i, i-1, i-2). Full 2-D matrix
305+
// is fine given Settings labels are short (typically < 40 chars).
306+
let mut d = vec![vec![0usize; m + 1]; n + 1];
307+
for (i, row) in d.iter_mut().enumerate().take(n + 1) {
308+
row[0] = i;
309+
}
310+
for (j, cell) in d[0].iter_mut().enumerate().take(m + 1) {
311+
*cell = j;
312+
}
313+
for i in 1..=n {
314+
for j in 1..=m {
315+
let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
316+
d[i][j] = (d[i - 1][j] + 1)
317+
.min(d[i][j - 1] + 1)
318+
.min(d[i - 1][j - 1] + cost);
319+
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
320+
d[i][j] = d[i][j].min(d[i - 2][j - 2] + 1);
321+
}
322+
}
323+
}
324+
d[n][m]
325+
}
326+
243327
// ── Keyboard helpers ─────────────────────────────────────────────────────────
244328

245329
fn to_key_model(keys: &[xkb_labels::KeyInfo]) -> slint::ModelRc<KeyData> {
@@ -3512,3 +3596,77 @@ fn main() -> Result<(), slint::PlatformError> {
35123596

35133597
ui.run()
35143598
}
3599+
3600+
#[cfg(test)]
3601+
mod fuzzy_tests {
3602+
use super::{damerau_levenshtein, fuzzy_match};
3603+
3604+
fn chars(s: &str) -> Vec<char> {
3605+
s.chars().collect()
3606+
}
3607+
3608+
#[test]
3609+
fn transposition_pwoer_matches_power() {
3610+
// The exact case the user reported: swapped adjacent letters must
3611+
// still find the setting. D-L distance is 1 for a single adjacent
3612+
// transposition, which is within the threshold for a 5-char query.
3613+
assert!(fuzzy_match("Power", "pwoer"));
3614+
assert!(fuzzy_match("Power Profile", "pwoer"));
3615+
}
3616+
3617+
#[test]
3618+
fn common_typos_match() {
3619+
// Substitution: "poqer" (q instead of w) — 1 edit.
3620+
assert!(fuzzy_match("Power", "poqer"));
3621+
// Deletion: "powr" (missing e) — 1 edit.
3622+
assert!(fuzzy_match("Power", "powr"));
3623+
// Insertion: "poweer" (extra e) — 1 edit.
3624+
assert!(fuzzy_match("Power", "poweer"));
3625+
}
3626+
3627+
#[test]
3628+
fn substring_and_subsequence_still_work() {
3629+
// Substring — cheapest path.
3630+
assert!(fuzzy_match("Power Profile", "pow"));
3631+
assert!(fuzzy_match("Power Profile", "prof"));
3632+
// Subsequence — the old behavior for abbreviations.
3633+
assert!(fuzzy_match("Power Profile", "ppr"));
3634+
assert!(fuzzy_match("Display Settings", "dsp"));
3635+
}
3636+
3637+
#[test]
3638+
fn threshold_scales_with_query_length() {
3639+
// A 3-char query allows 1 edit. "abd" vs "xyz" is 3 edits → no match.
3640+
assert!(!fuzzy_match("xyz", "abd"));
3641+
// A 12-char query allows 4 edits (1 + 12/4). Two typos still match.
3642+
assert!(fuzzy_match("Configuration", "cnofigurtaion"));
3643+
}
3644+
3645+
#[test]
3646+
fn empty_query_matches_everything() {
3647+
assert!(fuzzy_match("Anything", ""));
3648+
}
3649+
3650+
#[test]
3651+
fn unrelated_query_rejects() {
3652+
// Guard against the threshold being too generous — random noise
3653+
// must not match a specific label.
3654+
assert!(!fuzzy_match("Power", "wifi"));
3655+
assert!(!fuzzy_match("Bluetooth", "wallpaper"));
3656+
}
3657+
3658+
#[test]
3659+
fn multi_word_target_matches_any_word() {
3660+
// The query only has to be close to ONE word in the label.
3661+
assert!(fuzzy_match("Screen Brightness", "brihgtness"));
3662+
assert!(fuzzy_match("Night Light", "nihgt"));
3663+
}
3664+
3665+
#[test]
3666+
fn dl_distance_transposition_is_one() {
3667+
// Sanity check on the D-L helper itself — a single adjacent swap
3668+
// must cost 1 edit, not 2 like plain Levenshtein would report.
3669+
assert_eq!(damerau_levenshtein(&chars("pwoer"), &chars("power")), 1);
3670+
assert_eq!(damerau_levenshtein(&chars("abcd"), &chars("acbd")), 1);
3671+
}
3672+
}

0 commit comments

Comments
 (0)