Skip to main content

herogpui_components/
filter.rs

1//! `useFilter` — the string matchers v3 hands an Autocomplete or a ComboBox.
2//!
3//! v3 writes `const {contains} = useFilter({sensitivity: "base"})` and passes
4//! the function to `<Autocomplete.Filter filter={contains}>`. A hook is a
5//! closure factory, so the port is a value that owns the sensitivity and lends
6//! out the three comparisons.
7//!
8//! `sensitivity` is ECMA-402's collator strength, and the four levels are two
9//! independent questions -- does case matter, do accents matter -- which is
10//! what makes them implementable without CLDR data: the answer is a fold, not a
11//! locale-specific collation.
12
13/// `sensitivity` — which differences make two strings different.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub enum Sensitivity {
16    /// Only base letters differ: `a`, `A` and `á` all match. v3's default.
17    #[default]
18    Base,
19    /// Accents count, case does not: `a` matches `A` but not `á`.
20    Accent,
21    /// Case counts, accents do not: `a` matches `á` but not `A`.
22    Case,
23    /// Everything counts.
24    Variant,
25}
26
27impl Sensitivity {
28    /// Every sensitivity, in declaration order.
29    pub const ALL: [Sensitivity; 4] = [
30        Sensitivity::Base,
31        Sensitivity::Accent,
32        Sensitivity::Case,
33        Sensitivity::Variant,
34    ];
35
36    /// A human-readable label for this sensitivity.
37    pub fn label(self) -> &'static str {
38        match self {
39            Sensitivity::Base => "base",
40            Sensitivity::Accent => "accent",
41            Sensitivity::Case => "case",
42            Sensitivity::Variant => "variant",
43        }
44    }
45
46    fn ignores_case(self) -> bool {
47        matches!(self, Sensitivity::Base | Sensitivity::Accent)
48    }
49
50    fn ignores_accents(self) -> bool {
51        matches!(self, Sensitivity::Base | Sensitivity::Case)
52    }
53}
54
55/// The `useFilter` hook's return value: `contains`, `startsWith`, `endsWith`.
56#[derive(Clone, Copy, Debug, Default)]
57pub struct Filter {
58    sensitivity: Sensitivity,
59}
60
61impl Filter {
62    /// Creates a filter with the given sensitivity.
63    pub fn new(sensitivity: Sensitivity) -> Self {
64        Self { sensitivity }
65    }
66
67    /// `contains` — whether `text` holds `substring` anywhere.
68    pub fn contains(&self, text: &str, substring: &str) -> bool {
69        self.fold(text).contains(&self.fold(substring))
70    }
71
72    /// `startsWith` — whether `text` begins with `prefix`.
73    pub fn starts_with(&self, text: &str, prefix: &str) -> bool {
74        self.fold(text).starts_with(&self.fold(prefix))
75    }
76
77    /// `endsWith` — whether `text` ends with `suffix`.
78    pub fn ends_with(&self, text: &str, suffix: &str) -> bool {
79        self.fold(text).ends_with(&self.fold(suffix))
80    }
81
82    /// The comparison form of a string under this sensitivity.
83    fn fold(&self, s: &str) -> String {
84        let mut out = String::with_capacity(s.len());
85        for ch in s.chars() {
86            let ch = if self.sensitivity.ignores_accents() {
87                unaccent(ch)
88            } else {
89                ch
90            };
91            if self.sensitivity.ignores_case() {
92                out.extend(ch.to_lowercase());
93            } else {
94                out.push(ch);
95            }
96        }
97        out
98    }
99}
100
101/// The base letter of an accented one.
102///
103/// Latin-1 Supplement and Latin Extended-A, which is the range a Latin-script
104/// UI types in; a character outside it is its own base letter. Unicode
105/// decomposition would cover more, and needs tables `std` does not carry.
106fn unaccent(ch: char) -> char {
107    const FOLD: &[(char, char, &str)] = &[
108        ('\u{00C0}', '\u{00C5}', "AAAAAA"),
109        ('\u{00C8}', '\u{00CB}', "EEEE"),
110        ('\u{00CC}', '\u{00CF}', "IIII"),
111        ('\u{00D2}', '\u{00D6}', "OOOOO"),
112        ('\u{00D9}', '\u{00DC}', "UUUU"),
113        ('\u{00E0}', '\u{00E5}', "aaaaaa"),
114        ('\u{00E8}', '\u{00EB}', "eeee"),
115        ('\u{00EC}', '\u{00EF}', "iiii"),
116        ('\u{00F2}', '\u{00F6}', "ooooo"),
117        ('\u{00F9}', '\u{00FC}', "uuuu"),
118    ];
119    match ch {
120        '\u{00C7}' => 'C',
121        '\u{00E7}' => 'c',
122        '\u{00D1}' => 'N',
123        '\u{00F1}' => 'n',
124        '\u{00DD}' | '\u{0178}' => 'Y',
125        '\u{00FD}' | '\u{00FF}' => 'y',
126        '\u{00D8}' => 'O',
127        '\u{00F8}' => 'o',
128        _ => {
129            for (first, last, bases) in FOLD {
130                if ch >= *first && ch <= *last {
131                    let at = ch as u32 - *first as u32;
132                    if let Some(base) = bases.chars().nth(at as usize) {
133                        return base;
134                    }
135                }
136            }
137            ch
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn base_ignores_case_and_accents() {
148        let f = Filter::new(Sensitivity::Base);
149        assert!(f.contains("Café", "cafe"));
150        assert!(f.contains("CAFE", "café"));
151    }
152
153    #[test]
154    fn accent_keeps_accents_and_drops_case() {
155        let f = Filter::new(Sensitivity::Accent);
156        assert!(f.contains("Café", "CAFÉ"));
157        assert!(!f.contains("Café", "cafe"));
158    }
159
160    #[test]
161    fn case_keeps_case_and_drops_accents() {
162        let f = Filter::new(Sensitivity::Case);
163        assert!(f.contains("Café", "Cafe"));
164        assert!(!f.contains("Café", "cafe"));
165    }
166
167    #[test]
168    fn variant_keeps_both() {
169        let f = Filter::new(Sensitivity::Variant);
170        assert!(f.contains("Café", "Café"));
171        assert!(!f.contains("Café", "Cafe"));
172        assert!(!f.contains("Café", "café"));
173    }
174
175    #[test]
176    fn starts_and_ends_anchor() {
177        let f = Filter::new(Sensitivity::Base);
178        assert!(f.starts_with("Ångström", "ang"));
179        assert!(!f.starts_with("Ångström", "strom"));
180        assert!(f.ends_with("Ångström", "STROM"));
181        assert!(!f.ends_with("Ångström", "ang"));
182    }
183
184    #[test]
185    fn the_default_is_v3s_base() {
186        assert_eq!(Sensitivity::default(), Sensitivity::Base);
187        assert!(Filter::default().contains("Ñandú", "nandu"));
188    }
189
190    #[test]
191    fn a_letter_outside_the_table_is_its_own_base() {
192        let f = Filter::new(Sensitivity::Base);
193        // Greek and CJK have no fold here, so they match themselves.
194        assert!(f.contains("Δέλτα", "Δέλτα"));
195        assert!(f.contains("東京", "東京"));
196    }
197}