egui_table_kit/filter/
search.rs1use fluent_zero::t;
4
5bitflags::bitflags! {
6 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
8 pub struct SearchOptions: u8 {
9 const CASE_INSENSITIVE = 0b0000_0001;
11 const REGEX = 0b0000_0010;
13 }
14}
15
16#[derive(Debug, Clone, Default)]
18enum Matcher {
19 #[default]
21 Always,
22 Literal(String),
24 Compiled(regex::Regex),
26 Invalid(String),
28}
29
30#[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 #[must_use]
43 pub fn new() -> Self {
44 Self::default()
45 }
46
47 #[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 pub fn set_text(&mut self, text: impl Into<String>) {
64 self.raw_query = text.into();
65 self.rebuild_matcher();
66 }
67
68 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 pub fn toggle_option(&mut self, option: SearchOptions) {
78 self.options.toggle(option);
79 self.rebuild_matcher();
80 }
81
82 pub fn open(&mut self) {
84 if !self.active {
85 self.active = true;
86 self.rebuild_matcher();
87 }
88 }
89
90 pub fn clear(&mut self) {
92 self.raw_query.clear();
93 self.active = false;
94 self.matcher = Matcher::Always;
95 }
96
97 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 #[must_use]
108 pub fn text(&self) -> &str {
109 &self.raw_query
110 }
111
112 #[must_use]
114 pub const fn options(&self) -> SearchOptions {
115 self.options
116 }
117
118 #[must_use]
120 pub const fn is_active(&self) -> bool {
121 self.active
122 }
123
124 #[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
168pub struct SearchBar<'a> {
170 label: &'a str,
171}
172
173impl<'a> SearchBar<'a> {
174 #[must_use]
176 pub const fn new(label: &'a str) -> Self {
177 Self { label }
178 }
179
180 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}