Skip to main content

egui_table_kit/filter/
search.rs

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