@@ -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.
228240fn 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
245329fn 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