egui_table_kit/filter/
search.rs1use fluent_zero::t;
2
3bitflags::bitflags! {
4 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
6 pub struct SearchOptions: u8 {
7 const CASE_INSENSITIVE = 0b0000_0001;
9 const REGEX = 0b0000_0010;
11 }
12}
13
14#[derive(Debug, Clone, Default)]
16enum Matcher {
17 #[default]
19 Always,
20 Literal(String),
22 Compiled(regex::Regex),
24 Invalid(String),
26}
27
28#[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}