Skip to main content

egui_table_kit/filter/
search.rs

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