Skip to main content

gpui_kit_grid/
quick_filter.rs

1//! The quick filter: a text search over the display text of chosen columns.
2//! Part of the engine layer, so it has no GPUI types.
3//!
4//! A [`QuickFilterMatcher`] compiles the text once per change into a
5//! [`QuickFilterRule`]. The background [`Rebuild`](crate::Rebuild) asks the
6//! rule about every row that passes the view's own filter. Built-in rules
7//! are dispatched statically; an application rule costs one dynamic call per
8//! row.
9
10use std::{
11    cell::Cell,
12    fmt,
13    ops::ControlFlow,
14    sync::{Arc, OnceLock},
15};
16
17use crate::model::{
18    CellValue, ColumnIx, ColumnSet, GridColumns, GridRow, NumberFormat, display_text_in,
19    may_occur_in_number_text,
20};
21
22/// How quick filter text matches rows. The default is [`ContainsAllTerms`].
23/// Runs off the UI thread, so it is `Send + Sync`.
24pub trait QuickFilterMatcher: Send + Sync + 'static {
25    /// Compiles `text` once per filter change, not per row.
26    /// [`QuickFilter::new`] passes it trimmed and never blank.
27    fn compile(&self, text: &str) -> QuickFilterRule;
28}
29
30/// A compiled application quick filter, wrapped in
31/// [`QuickFilterRule::Custom`].
32pub trait QuickFilterQuery: Send + Sync + 'static {
33    /// Returns true if the row passes. Called once per row of a rebuild in
34    /// the background, on large snapshots from several threads at once: it
35    /// must not allocate or lock.
36    fn matches(&self, cells: &CellTexts<'_>) -> bool;
37}
38
39/// A compiled quick filter: a built-in rule, dispatched statically, or an
40/// application one.
41#[derive(Clone)]
42#[non_exhaustive]
43pub enum QuickFilterRule {
44    /// Compiled by [`ContainsAllTerms`].
45    ContainsAllTerms(ContainsAllTermsQuery),
46    /// An application rule: one dynamic call per row.
47    Custom(Arc<dyn QuickFilterQuery>),
48}
49
50impl QuickFilterQuery for QuickFilterRule {
51    fn matches(&self, cells: &CellTexts<'_>) -> bool {
52        match self {
53            Self::ContainsAllTerms(query) => query.matches(cells),
54            Self::Custom(query) => query.matches(cells),
55        }
56    }
57}
58
59impl fmt::Debug for QuickFilterRule {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::ContainsAllTerms(query) => {
63                f.debug_tuple("ContainsAllTerms").field(query).finish()
64            }
65            Self::Custom(_) => f.write_str("Custom(..)"),
66        }
67    }
68}
69
70/// The display text of the quick filter columns of one row, as the body
71/// paints it. `Empty` and `Bool` cells have no searchable text and are left
72/// out: the body shows `Bool` only as a check mark. An `Icon` cell is
73/// searched by its text.
74pub struct CellTexts<'a> {
75    /// The searchable cells of the current row, in schema order.
76    cells: Vec<(ColumnIx, CellValue<'a>)>,
77    /// How each column shows its numbers, by `ColumnIx`.
78    formats: &'a [NumberFormat],
79    /// Numbers, timestamps and custom values are written here, one buffer
80    /// for the whole rebuild.
81    numbers: Cell<String>,
82    /// [`ContainsAllTerms`] lowercases non-ASCII text here.
83    lowercase: Cell<String>,
84}
85
86impl<'a> CellTexts<'a> {
87    fn with_capacity(columns: usize, formats: &'a [NumberFormat]) -> Self {
88        Self {
89            cells: Vec::with_capacity(columns),
90            formats,
91            numbers: Cell::default(),
92            lowercase: Cell::default(),
93        }
94    }
95
96    /// Reads the cells of `row`. Allocates only while the buffers grow.
97    fn load<R: GridRow>(&mut self, row: &'a R, columns: &[R::Column]) {
98        self.cells.clear();
99        self.cells.extend(
100            columns
101                .iter()
102                .map(|&column| (column.ix(), row.value(column)))
103                .filter(|(_, value)| !matches!(value, CellValue::Empty | CellValue::Bool(_))),
104        );
105    }
106
107    /// How the column `ix` shows its numbers.
108    fn format(&self, ix: ColumnIx) -> NumberFormat {
109        self.formats.get(ix.get()).copied().unwrap_or_default()
110    }
111
112    /// Visits each quick filter column in schema order until `visit` breaks.
113    /// Text and icon cells are borrowed from the row; numbers, timestamps
114    /// and custom values are written into a buffer reused across rows.
115    pub fn visit(&self, mut visit: impl FnMut(ColumnIx, &str) -> ControlFlow<()>) {
116        // Taken, not borrowed: a nested `visit` gets an empty buffer instead
117        // of a panic.
118        let mut buffer = self.numbers.take();
119        for &(column, value) in &self.cells {
120            if visit(
121                column,
122                display_text_in(value, self.format(column), &mut buffer),
123            )
124            .is_break()
125            {
126                break;
127            }
128        }
129        self.numbers.set(buffer);
130    }
131}
132
133/// Every whitespace-separated term occurs, ignoring case, in some column, as
134/// in AG Grid's quick filter. Terms may occur in different columns.
135///
136/// Case is ignored with `char::to_lowercase` (full Unicode), character by
137/// character: "сбер" finds "СБЕРБАНК". Diacritics count: "e" does not find
138/// "é" (ignoring them is another [`QuickFilterMatcher`]).
139#[derive(Clone, Copy, Debug, Default)]
140pub struct ContainsAllTerms;
141
142impl QuickFilterMatcher for ContainsAllTerms {
143    fn compile(&self, text: &str) -> QuickFilterRule {
144        let mut terms: Vec<Term> = Vec::new();
145        for term in text.split_whitespace() {
146            let text: String = term.chars().flat_map(char::to_lowercase).collect();
147            if !terms.iter().any(|known| *known.text == *text) {
148                terms.push(Term {
149                    is_ascii: text.is_ascii(),
150                    in_numbers: may_occur_in_number_text(&text),
151                    text: text.into_boxed_str(),
152                });
153            }
154        }
155        QuickFilterRule::ContainsAllTerms(ContainsAllTermsQuery {
156            terms: terms.into(),
157        })
158    }
159}
160
161/// [`ContainsAllTerms`] compiled: lowercase terms without repeats. Without
162/// terms every row passes.
163#[derive(Clone, Debug)]
164pub struct ContainsAllTermsQuery {
165    terms: Arc<[Term]>,
166}
167
168#[derive(Debug)]
169struct Term {
170    /// Lowercase and never empty.
171    text: Box<str>,
172    is_ascii: bool,
173    /// The term may occur in the display text of a number.
174    in_numbers: bool,
175}
176
177impl Term {
178    /// `lowercase` is `text` lowercased when `text` is not ASCII.
179    ///
180    /// Cells are short, so this compares windows: `str::contains` would build
181    /// a searcher per cell and term, which costs more than the search.
182    fn occurs_in(&self, text: &str, is_ascii: bool, lowercase: &str) -> bool {
183        let needle = self.text.as_bytes();
184        if is_ascii {
185            // ASCII lowercases to ASCII, so no other term can occur in it.
186            self.is_ascii
187                && text
188                    .as_bytes()
189                    .windows(needle.len())
190                    .any(|window| window.eq_ignore_ascii_case(needle))
191        } else {
192            // Bytes of valid UTF-8 match only at character boundaries.
193            lowercase
194                .as_bytes()
195                .windows(needle.len())
196                .any(|window| window[0] == needle[0] && window == needle)
197        }
198    }
199}
200
201/// Appends `text` lowercased with `char::to_lowercase`, character by
202/// character.
203fn push_lowercase(text: &str, out: &mut String) {
204    let table = small_lowercase();
205    for c in text.chars() {
206        match table.get(c as usize) {
207            Some(&lower) if lower != '\0' => out.push(lower),
208            _ => out.extend(c.to_lowercase()),
209        }
210    }
211}
212
213/// The lowercase of every character below U+0800 (Latin, Greek, Cyrillic,
214/// Armenian, Hebrew, Arabic) that lowercases to one character, and `'\0'`
215/// for the rest. Built once from `char::to_lowercase`, so it agrees with it;
216/// it saves a binary search per character, the larger part of a quick filter
217/// over Cyrillic text.
218fn small_lowercase() -> &'static [char; 0x800] {
219    static TABLE: OnceLock<[char; 0x800]> = OnceLock::new();
220    TABLE.get_or_init(|| {
221        let mut table = ['\0'; 0x800];
222        for (code, lower) in table.iter_mut().enumerate() {
223            let c = char::from_u32(code as u32).unwrap_or('\0');
224            let mut lowercase = c.to_lowercase();
225            if let (Some(single), None) = (lowercase.next(), lowercase.next()) {
226                *lower = single;
227            }
228        }
229        table
230    })
231}
232
233impl QuickFilterQuery for ContainsAllTermsQuery {
234    fn matches(&self, cells: &CellTexts<'_>) -> bool {
235        // More than 64 terms take a pass per 64.
236        self.terms
237            .chunks(u64::BITS as usize)
238            .all(|terms| cells.contains_all(terms))
239    }
240}
241
242impl CellTexts<'_> {
243    /// Returns true if every term occurs in some cell, with a bit per term,
244    /// and stops as soon as every term is found. Which cell holds a term
245    /// does not matter, so text and the text of icons, which are borrowed,
246    /// are searched first; numbers are formatted only while a missing term
247    /// may occur in one, timestamps and custom values while any term is
248    /// missing.
249    fn contains_all(&self, terms: &[Term]) -> bool {
250        let all = u64::MAX >> (u64::BITS as usize - terms.len());
251        let in_numbers = terms
252            .iter()
253            .enumerate()
254            .filter(|(_, term)| term.in_numbers)
255            .fold(0u64, |mask, (ix, _)| mask | 1 << ix);
256        let mut found = 0u64;
257        let mut lowercase = self.lowercase.take();
258        for &(_, value) in &self.cells {
259            let text = match value {
260                CellValue::Text(text) => text,
261                CellValue::Icon(icon) => icon.text(),
262                CellValue::Image(image) => image.text(),
263                CellValue::Link(link) => link.text(),
264                _ => continue,
265            };
266            find_terms(terms, all, &mut found, text, &mut lowercase);
267            if found == all {
268                break;
269            }
270        }
271        if found != all {
272            let mut buffer = self.numbers.take();
273            for &(column, value) in &self.cells {
274                if found == all {
275                    break;
276                }
277                let wanted = match value {
278                    CellValue::Text(_)
279                    | CellValue::Icon(_)
280                    | CellValue::Image(_)
281                    | CellValue::Link(_) => 0,
282                    _ if value.is_number() => in_numbers,
283                    _ => all,
284                };
285                if wanted & !found != 0 {
286                    let text = display_text_in(value, self.format(column), &mut buffer);
287                    find_terms(terms, wanted, &mut found, text, &mut lowercase);
288                }
289            }
290            self.numbers.set(buffer);
291        }
292        self.lowercase.set(lowercase);
293        found == all
294    }
295}
296
297/// Sets the bit of every term of `terms` in `wanted` and missing from
298/// `found` that occurs in `text`. `lowercase` is a buffer for non-ASCII
299/// text.
300fn find_terms(terms: &[Term], wanted: u64, found: &mut u64, text: &str, lowercase: &mut String) {
301    let is_ascii = text.is_ascii();
302    if !is_ascii {
303        lowercase.clear();
304        push_lowercase(text, lowercase);
305    }
306    for (ix, term) in terms.iter().enumerate() {
307        let bit = 1u64 << ix;
308        if wanted & !*found & bit != 0 && term.occurs_in(text, is_ascii, lowercase) {
309            *found |= bit;
310        }
311    }
312}
313
314/// A compiled quick filter over a set of columns. Cloning is cheap.
315#[derive(Clone, Debug)]
316pub struct QuickFilter {
317    text: Arc<str>,
318    columns: ColumnSet,
319    rule: QuickFilterRule,
320    formats: Arc<[NumberFormat]>,
321}
322
323impl QuickFilter {
324    /// Compiles `text`, trimmed, to search `columns`. `None` when the text
325    /// is empty or only whitespace: no filter at all.
326    pub fn new(text: &str, columns: ColumnSet, matcher: &dyn QuickFilterMatcher) -> Option<Self> {
327        let text = text.trim();
328        if text.is_empty() {
329            return None;
330        }
331        Some(Self {
332            rule: matcher.compile(text),
333            text: text.into(),
334            columns,
335            formats: Arc::new([]),
336        })
337    }
338
339    /// Searches numbers as these formats show them, by `ColumnIx`
340    /// (`GridColumn::with_number_format`); a column past the end shows the
341    /// default. The grid sets its columns' formats.
342    pub fn with_number_formats(mut self, formats: impl Into<Arc<[NumberFormat]>>) -> Self {
343        self.formats = formats.into();
344        self
345    }
346
347    /// The text, trimmed.
348    pub fn text(&self) -> &str {
349        &self.text
350    }
351
352    /// The columns searched. The view depends on them: a change in one of
353    /// them rebuilds it.
354    pub fn columns(&self) -> ColumnSet {
355        self.columns
356    }
357}
358
359/// A quick filter applied row by row in one rebuild: the columns are
360/// resolved once and every row shares the text buffers.
361pub(crate) struct QuickFilterPass<'a, R: GridRow> {
362    rule: &'a QuickFilterRule,
363    columns: Vec<R::Column>,
364    cells: CellTexts<'a>,
365}
366
367impl<'a, R: GridRow> QuickFilterPass<'a, R> {
368    pub(crate) fn new(filter: &'a QuickFilter) -> Self {
369        let columns: Vec<R::Column> = R::Column::all()
370            .iter()
371            .copied()
372            .filter(|column| filter.columns.contains(column.ix()))
373            .collect();
374        Self {
375            rule: &filter.rule,
376            cells: CellTexts::with_capacity(columns.len(), &filter.formats),
377            columns,
378        }
379    }
380
381    /// Returns true if `row` passes. Allocates only while the buffers grow.
382    pub(crate) fn matches(&mut self, row: &'a R) -> bool {
383        self.cells.load(row, &self.columns);
384        self.rule.matches(&self.cells)
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
393    enum Column {
394        Ticker,
395        Name,
396        Price,
397        Quantity,
398        Active,
399        Note,
400    }
401
402    impl GridColumns for Column {
403        fn all() -> &'static [Self] {
404            &[
405                Self::Ticker,
406                Self::Name,
407                Self::Price,
408                Self::Quantity,
409                Self::Active,
410                Self::Note,
411            ]
412        }
413
414        fn id(self) -> &'static str {
415            match self {
416                Self::Ticker => "ticker",
417                Self::Name => "name",
418                Self::Price => "price",
419                Self::Quantity => "quantity",
420                Self::Active => "active",
421                Self::Note => "note",
422            }
423        }
424
425        fn ix(self) -> ColumnIx {
426            ColumnIx::new(self as u16)
427        }
428    }
429
430    struct Row {
431        ticker: &'static str,
432        name: &'static str,
433        price: f64,
434        quantity: i64,
435    }
436
437    impl GridRow for Row {
438        type Key = &'static str;
439        type Column = Column;
440
441        fn key(&self) -> &&'static str {
442            &self.ticker
443        }
444
445        fn value(&self, column: Column) -> CellValue<'_> {
446            match column {
447                Column::Ticker => CellValue::Text(self.ticker),
448                Column::Name => CellValue::Text(self.name),
449                Column::Price => CellValue::Float(self.price),
450                Column::Quantity => CellValue::Int(self.quantity),
451                Column::Active => CellValue::Bool(true),
452                Column::Note => CellValue::Empty,
453            }
454        }
455    }
456
457    const SBER: Row = Row {
458        ticker: "SBER",
459        name: "СБЕРБАНК",
460        price: 12.5,
461        quantity: 1500,
462    };
463
464    fn columns(columns: &[Column]) -> ColumnSet {
465        let mut set = ColumnSet::EMPTY;
466        for column in columns {
467            set.insert(column.ix());
468        }
469        set
470    }
471
472    fn passes(text: &str, set: ColumnSet, row: &Row) -> bool {
473        let filter = QuickFilter::new(text, set, &ContainsAllTerms).expect("not blank");
474        QuickFilterPass::<Row>::new(&filter).matches(row)
475    }
476
477    #[test]
478    fn test_contains_all_terms() {
479        let all = ColumnSet::first(Column::all().len());
480        // Case is ignored for ASCII and Cyrillic; a term may be a substring.
481        for text in ["sber", "Sber", "сбер", "Сбер", "БАНК", "ербан"] {
482            assert!(passes(text, all, &SBER), "{text}");
483        }
484        // Every term is required, but each may occur in another column.
485        assert!(passes("sber сбер 2.5", all, &SBER));
486        assert!(!passes("sber gazp", all, &SBER));
487        // Extra whitespace only separates terms; the text is trimmed.
488        assert!(passes("  sber \t  2.5 ", all, &SBER));
489        let filter = QuickFilter::new("  sber \t 2.5 ", all, &ContainsAllTerms).unwrap();
490        assert_eq!(filter.text(), "sber \t 2.5");
491        assert_eq!(filter.columns(), all);
492        // Repeated terms are compiled once.
493        let QuickFilterRule::ContainsAllTerms(query) = ContainsAllTerms.compile("Sber SBER sber")
494        else {
495            panic!("a built-in rule");
496        };
497        assert_eq!(query.terms.len(), 1);
498        // Empty and blank text is no filter at all.
499        for blank in ["", "   ", " \t\n "] {
500            assert!(QuickFilter::new(blank, all, &ContainsAllTerms).is_none());
501        }
502        // Empty and Bool cells have no text: the check mark is not searchable.
503        for text in ["✓", "true"] {
504            assert!(!passes(text, all, &SBER), "{text}");
505        }
506        // Numbers are searched by their display text: 12.5 shows as "12.50".
507        for text in ["2.5", "12.50", "150", "1500"] {
508            assert!(passes(text, all, &SBER), "{text}");
509        }
510        assert!(!passes("12.500", all, &SBER));
511        // Only the given columns are searched.
512        assert!(passes("сбер", columns(&[Column::Name]), &SBER));
513        assert!(!passes("sber 2.5", columns(&[Column::Name]), &SBER));
514        assert!(!passes("sber", ColumnSet::EMPTY, &SBER));
515        // More terms than one pass tracks: 70 distinct terms, all present.
516        let many: Vec<String> = (0..70).map(|ix| format!("{ix}")).collect();
517        let row = Row {
518            name: Box::leak(
519                (0..70)
520                    .map(|ix| format!("<{ix}>"))
521                    .collect::<String>()
522                    .into(),
523            ),
524            ..SBER
525        };
526        assert!(passes(&many.join(" "), all, &row));
527        assert!(!passes(&format!("{} 70", many.join(" ")), all, &row));
528    }
529
530    #[test]
531    fn test_lowercase_table() {
532        // The table only skips a binary search: every character lowercases
533        // exactly as `char::to_lowercase` does, in any Unicode version.
534        let (mut fast, mut expected) = (String::new(), String::new());
535        for c in (0..=char::MAX as u32).filter_map(char::from_u32) {
536            fast.clear();
537            expected.clear();
538            push_lowercase(c.encode_utf8(&mut [0; 4]), &mut fast);
539            expected.extend(c.to_lowercase());
540            assert_eq!(fast, expected, "U+{:04X}", c as u32);
541        }
542    }
543
544    #[test]
545    fn test_contains_all_terms_searches_text_first() {
546        let all = ColumnSet::first(Column::all().len());
547        let nan = Row {
548            price: f64::NAN,
549            ..SBER
550        };
551        // A term no number shows is never looked for in numbers: the
552        // number buffer stays empty, found or not.
553        for (text, expected) in [("банк", true), ("sber банк", true), ("gazp", false)] {
554            let filter = QuickFilter::new(text, all, &ContainsAllTerms).unwrap();
555            let mut pass = QuickFilterPass::<Row>::new(&filter);
556            assert_eq!(pass.matches(&SBER), expected, "{text}");
557            assert_eq!(pass.cells.numbers.take(), "", "{text}");
558        }
559        // Missing from the text, a term that may be in a number is looked
560        // for there, and the row fails only after the last number.
561        assert!(passes("sber 1500", all, &SBER));
562        assert!(passes("nan", all, &nan));
563        assert!(passes("NaN сбер", all, &nan));
564        assert!(!passes("sber 1501", all, &SBER));
565        assert!(!passes("банк nan", all, &SBER));
566        // A term in both text and numbers, and terms only in numbers.
567        assert!(passes("50 12.5 150", all, &SBER));
568        assert!(!passes("sber gazp 1500", all, &SBER));
569        // Text columns that hold digits are searched like any text.
570        let digits = Row {
571            name: "Counterparty 42",
572            ..SBER
573        };
574        assert!(passes("42 party", columns(&[Column::Name]), &digits));
575    }
576
577    #[test]
578    fn test_cell_texts_visit() {
579        let filter = QuickFilter::new("x", ColumnSet::first(6), &ContainsAllTerms).unwrap();
580        let mut pass = QuickFilterPass::<Row>::new(&filter);
581        pass.cells.load(&SBER, &pass.columns);
582        // Schema order, without the Bool and Empty columns, numbers as painted.
583        let mut visited = Vec::new();
584        pass.cells.visit(|column, text| {
585            visited.push((column, text.to_owned()));
586            ControlFlow::Continue(())
587        });
588        assert_eq!(
589            visited,
590            [
591                (Column::Ticker.ix(), "SBER".to_owned()),
592                (Column::Name.ix(), "СБЕРБАНК".to_owned()),
593                (Column::Price.ix(), "12.50".to_owned()),
594                (Column::Quantity.ix(), "1500".to_owned()),
595            ]
596        );
597        // Breaking stops the visit.
598        let mut count = 0;
599        pass.cells.visit(|_, _| {
600            count += 1;
601            ControlFlow::Break(())
602        });
603        assert_eq!(count, 1);
604    }
605
606    #[test]
607    fn test_quick_filter_new_values() {
608        use chrono::{DateTime, TimeZone as _, Utc};
609
610        use crate::model::{GridValue, IconValue};
611
612        /// Shows as `3M`.
613        #[derive(Debug)]
614        struct Tenor(u32);
615
616        impl GridValue for Tenor {
617            fn cmp_value(&self, other: &dyn GridValue) -> std::cmp::Ordering {
618                other
619                    .downcast_ref::<Tenor>()
620                    .map_or(std::cmp::Ordering::Less, |other| self.0.cmp(&other.0))
621            }
622
623            fn write_text(&self, out: &mut String) {
624                use std::fmt::Write as _;
625                let _ = write!(out, "{}M", self.0 / 30);
626            }
627        }
628
629        /// An order: the columns of `Column` with an icon, a time and a
630        /// custom value in place of the ticker, the name and the price.
631        struct Order {
632            status: IconValue<'static>,
633            placed: DateTime<Utc>,
634            tenor: Tenor,
635        }
636
637        impl GridRow for Order {
638            type Key = u8;
639            type Column = Column;
640
641            fn key(&self) -> &u8 {
642                &0
643            }
644
645            fn value(&self, column: Column) -> CellValue<'_> {
646                match column {
647                    Column::Ticker => CellValue::Icon(self.status),
648                    Column::Name => CellValue::Timestamp(self.placed),
649                    Column::Price => CellValue::Custom(&self.tenor),
650                    Column::Quantity => CellValue::Int(1500),
651                    Column::Active => CellValue::Bool(true),
652                    Column::Note => CellValue::Empty,
653                }
654            }
655        }
656
657        let order = Order {
658            status: IconValue::new("icons/circle-check.svg", "Filled"),
659            placed: Utc.with_ymd_and_hms(2026, 11, 4, 12, 34, 56).unwrap(),
660            tenor: Tenor(90),
661        };
662        let passes = |text: &str, set: ColumnSet| {
663            let filter = QuickFilter::new(text, set, &ContainsAllTerms).expect("not blank");
664            QuickFilterPass::<Order>::new(&filter).matches(&order)
665        };
666        let all = ColumnSet::first(Column::all().len());
667        // The icon by its text, never its path; the time by its text in UTC;
668        // the custom value by its text.
669        for text in [
670            "filled",
671            "FILL",
672            "2026-11",
673            "12:34",
674            "11-04 12",
675            "3m",
676            "filled 3M 2026 1500",
677        ] {
678            assert!(passes(text, all), "{text}");
679        }
680        for text in ["circle", "svg", "2026-11-05", "12:35", "90", "3M gazp"] {
681            assert!(!passes(text, all), "{text}");
682        }
683        assert!(passes("fill", columns(&[Column::Ticker])));
684        assert!(!passes("fill", columns(&[Column::Name, Column::Price])));
685        assert!(passes("3m", columns(&[Column::Price])));
686
687        // The icon's text is borrowed and searched first: a term found there
688        // writes nothing into the buffer.
689        let filter = QuickFilter::new("filled", all, &ContainsAllTerms).unwrap();
690        let mut pass = QuickFilterPass::<Order>::new(&filter);
691        assert!(pass.matches(&order));
692        assert_eq!(pass.cells.numbers.take(), "");
693        // Visiting shows each cell as the body paints it.
694        pass.cells.load(&order, &pass.columns);
695        let mut visited = Vec::new();
696        pass.cells.visit(|column, text| {
697            visited.push((column, text.to_owned()));
698            ControlFlow::Continue(())
699        });
700        assert_eq!(
701            visited,
702            [
703                (Column::Ticker.ix(), "Filled".to_owned()),
704                (Column::Name.ix(), "2026-11-04 12:34:56".to_owned()),
705                (Column::Price.ix(), "3M".to_owned()),
706                (Column::Quantity.ix(), "1500".to_owned()),
707            ]
708        );
709    }
710}