Skip to main content

egui_table_kit/filter/
search.rs

1use fluent_zero::t;
2
3bitflags::bitflags! {
4    /// Configuration flags for search behavior.
5    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
6    pub struct SearchOptions: u8 {
7        /// If set, the search ignores case differences.
8        const CASE_INSENSITIVE = 0b0000_0001;
9        /// If set, the search query is treated as a raw Regular Expression.
10        const REGEX =            0b0000_0010;
11    }
12}
13
14/// Internal state for the matching logic.
15#[derive(Debug, Clone, Default)]
16enum Matcher {
17    /// Matches everything (empty query or inactive).
18    #[default]
19    Always,
20    /// Fast exact substring match (Case-Sensitive Text).
21    Literal(String),
22    /// Compiled Regex match.
23    Compiled(regex::Regex),
24    /// The user provided an invalid regex.
25    Invalid(String),
26}
27
28/// A robust, headless-ready search engine.
29#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
30pub struct Search {
31    raw_query: String,
32    active: bool,
33    options: SearchOptions,
34    #[serde(skip)]
35    matcher: Matcher,
36}
37
38impl Search {
39    #[must_use]
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    #[must_use]
45    pub fn is_match(&self, text: &str) -> bool {
46        if !self.active {
47            return true;
48        }
49
50        match &self.matcher {
51            Matcher::Always => true,
52            Matcher::Literal(s) => text.contains(s),
53            Matcher::Compiled(re) => re.is_match(text),
54            Matcher::Invalid(_) => false,
55        }
56    }
57
58    pub fn set_text(&mut self, text: impl Into<String>) {
59        self.raw_query = text.into();
60        self.rebuild_matcher();
61    }
62
63    pub fn set_options(&mut self, options: SearchOptions) {
64        if self.options != options {
65            self.options = options;
66            self.rebuild_matcher();
67        }
68    }
69
70    pub fn toggle_option(&mut self, option: SearchOptions) {
71        self.options.toggle(option);
72        self.rebuild_matcher();
73    }
74
75    pub fn open(&mut self) {
76        if !self.active {
77            self.active = true;
78            self.rebuild_matcher();
79        }
80    }
81
82    pub fn clear(&mut self) {
83        self.raw_query.clear();
84        self.active = false;
85        self.matcher = Matcher::Always;
86    }
87
88    pub fn edit_text(&mut self, f: impl FnOnce(&mut String) -> bool) -> bool {
89        let changed = f(&mut self.raw_query);
90        if changed {
91            self.rebuild_matcher();
92        }
93        changed
94    }
95
96    #[must_use]
97    pub fn text(&self) -> &str {
98        &self.raw_query
99    }
100
101    #[must_use]
102    pub const fn options(&self) -> SearchOptions {
103        self.options
104    }
105
106    #[must_use]
107    pub const fn is_active(&self) -> bool {
108        self.active
109    }
110
111    #[must_use]
112    pub fn error_message(&self) -> Option<&str> {
113        if let Matcher::Invalid(msg) = &self.matcher {
114            Some(msg)
115        } else {
116            None
117        }
118    }
119
120    fn rebuild_matcher(&mut self) {
121        if self.raw_query.is_empty() {
122            self.matcher = Matcher::Always;
123            return;
124        }
125
126        let case_insensitive = self.options.contains(SearchOptions::CASE_INSENSITIVE);
127        let is_regex_mode = self.options.contains(SearchOptions::REGEX);
128
129        if !is_regex_mode && !case_insensitive {
130            self.matcher = Matcher::Literal(self.raw_query.clone());
131            return;
132        }
133
134        let pattern = if is_regex_mode {
135            self.raw_query.clone()
136        } else {
137            regex::escape(&self.raw_query)
138        };
139
140        match regex::RegexBuilder::new(&pattern)
141            .case_insensitive(case_insensitive)
142            .build()
143        {
144            Ok(re) => {
145                self.matcher = Matcher::Compiled(re);
146            }
147            Err(e) => {
148                self.matcher = Matcher::Invalid(e.to_string());
149            }
150        }
151    }
152}
153
154pub struct SearchBar<'a> {
155    label: &'a str,
156}
157
158impl<'a> SearchBar<'a> {
159    #[must_use]
160    pub const fn new(label: &'a str) -> Self {
161        Self { label }
162    }
163
164    pub fn ui(self, ui: &mut egui::Ui, search: &mut Search) -> bool {
165        let mut changed = false;
166
167        ui.horizontal(|ui| {
168            if search.is_active() {
169                let text_changed = search.edit_text(|s| {
170                    ui.add(
171                        egui::TextEdit::singleline(s)
172                            .clip_text(true)
173                            .hint_text("Search..."),
174                    )
175                    .changed()
176                });
177
178                if text_changed {
179                    changed = true;
180                }
181
182                let case_selected = search.options().contains(SearchOptions::CASE_INSENSITIVE);
183                if ui
184                    .add(
185                        egui::Button::new(egui::RichText::new("Aa").monospace())
186                            .selected(case_selected),
187                    )
188                    .on_hover_text(t!("case-insensitive"))
189                    .clicked()
190                {
191                    search.toggle_option(SearchOptions::CASE_INSENSITIVE);
192                    changed = true;
193                }
194
195                let regex_selected = search.options().contains(SearchOptions::REGEX);
196                if ui
197                    .add(
198                        egui::Button::new(egui::RichText::new(".*").monospace())
199                            .selected(regex_selected),
200                    )
201                    .on_hover_text(t!("regular-expression"))
202                    .clicked()
203                {
204                    search.toggle_option(SearchOptions::REGEX);
205                    changed = true;
206                }
207
208                if ui.button("❌").on_hover_text(t!("remove-filter")).clicked() {
209                    search.clear();
210                    changed = true;
211                }
212            } else if ui.button(self.label).clicked() {
213                search.open();
214                changed = true;
215            }
216
217            if search.is_active()
218                && let Some(msg) = search.error_message()
219            {
220                ui.label(
221                    egui::RichText::new(format!("⚠ {msg}"))
222                        .monospace()
223                        .color(egui::Color32::RED),
224                );
225            }
226        });
227
228        changed
229    }
230}