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