1use 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
22pub trait QuickFilterMatcher: Send + Sync + 'static {
25 fn compile(&self, text: &str) -> QuickFilterRule;
28}
29
30pub trait QuickFilterQuery: Send + Sync + 'static {
33 fn matches(&self, cells: &CellTexts<'_>) -> bool;
37}
38
39#[derive(Clone)]
42#[non_exhaustive]
43pub enum QuickFilterRule {
44 ContainsAllTerms(ContainsAllTermsQuery),
46 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
70pub struct CellTexts<'a> {
75 cells: Vec<(ColumnIx, CellValue<'a>)>,
77 formats: &'a [NumberFormat],
79 numbers: Cell<String>,
82 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 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 fn format(&self, ix: ColumnIx) -> NumberFormat {
109 self.formats.get(ix.get()).copied().unwrap_or_default()
110 }
111
112 pub fn visit(&self, mut visit: impl FnMut(ColumnIx, &str) -> ControlFlow<()>) {
116 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#[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#[derive(Clone, Debug)]
164pub struct ContainsAllTermsQuery {
165 terms: Arc<[Term]>,
166}
167
168#[derive(Debug)]
169struct Term {
170 text: Box<str>,
172 is_ascii: bool,
173 in_numbers: bool,
175}
176
177impl Term {
178 fn occurs_in(&self, text: &str, is_ascii: bool, lowercase: &str) -> bool {
183 let needle = self.text.as_bytes();
184 if is_ascii {
185 self.is_ascii
187 && text
188 .as_bytes()
189 .windows(needle.len())
190 .any(|window| window.eq_ignore_ascii_case(needle))
191 } else {
192 lowercase
194 .as_bytes()
195 .windows(needle.len())
196 .any(|window| window[0] == needle[0] && window == needle)
197 }
198 }
199}
200
201fn 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
213fn 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 self.terms
237 .chunks(u64::BITS as usize)
238 .all(|terms| cells.contains_all(terms))
239 }
240}
241
242impl CellTexts<'_> {
243 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
297fn 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#[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 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 pub fn with_number_formats(mut self, formats: impl Into<Arc<[NumberFormat]>>) -> Self {
343 self.formats = formats.into();
344 self
345 }
346
347 pub fn text(&self) -> &str {
349 &self.text
350 }
351
352 pub fn columns(&self) -> ColumnSet {
355 self.columns
356 }
357}
358
359pub(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 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 for text in ["sber", "Sber", "сбер", "Сбер", "БАНК", "ербан"] {
482 assert!(passes(text, all, &SBER), "{text}");
483 }
484 assert!(passes("sber сбер 2.5", all, &SBER));
486 assert!(!passes("sber gazp", all, &SBER));
487 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 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 for blank in ["", " ", " \t\n "] {
500 assert!(QuickFilter::new(blank, all, &ContainsAllTerms).is_none());
501 }
502 for text in ["✓", "true"] {
504 assert!(!passes(text, all, &SBER), "{text}");
505 }
506 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 assert!(passes("сбер", columns(&[Column::Name]), &SBER));
513 assert!(!passes("sber 2.5", columns(&[Column::Name]), &SBER));
514 assert!(!passes("sber", ColumnSet::EMPTY, &SBER));
515 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 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 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 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 assert!(passes("50 12.5 150", all, &SBER));
568 assert!(!passes("sber gazp 1500", all, &SBER));
569 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 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 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 #[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 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 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 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 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}