1use crate::widgets::text_input::TextInput;
5use crate::widgets::ui::PickerState;
6
7#[derive(Debug, Clone, PartialEq, Eq, Copy, serde::Serialize, serde::Deserialize)]
8pub enum FilterOperator {
9 Eq,
10 NotEq,
11 Gt,
12 Lt,
13 GtEq,
14 LtEq,
15 Contains,
16 NotContains,
17 IsNull,
18 IsNotNull,
19 Has,
22 HasRegex,
24 HasFuzzy,
26}
27
28pub const ANY_COLUMN: &str = "*";
31
32pub const ANY_COLUMN_LABEL: &str = "any column shown";
34
35impl FilterOperator {
36 pub fn as_str(&self) -> &'static str {
37 match self {
38 FilterOperator::Eq => "=",
39 FilterOperator::NotEq => "!=",
40 FilterOperator::Gt => ">",
41 FilterOperator::Lt => "<",
42 FilterOperator::GtEq => ">=",
43 FilterOperator::LtEq => "<=",
44 FilterOperator::Contains => "contains",
45 FilterOperator::NotContains => "!contains",
46 FilterOperator::IsNull => "is null",
47 FilterOperator::IsNotNull => "not null",
48 FilterOperator::Has => "has",
49 FilterOperator::HasRegex => "has regex",
50 FilterOperator::HasFuzzy => "has letters",
51 }
52 }
53
54 pub fn is_find(&self) -> bool {
57 matches!(
58 self,
59 FilterOperator::Has | FilterOperator::HasRegex | FilterOperator::HasFuzzy
60 )
61 }
62
63 pub fn takes_value(&self) -> bool {
65 !matches!(self, FilterOperator::IsNull | FilterOperator::IsNotNull)
66 }
67
68 pub fn iterator() -> impl Iterator<Item = FilterOperator> {
69 [
70 FilterOperator::Eq,
71 FilterOperator::NotEq,
72 FilterOperator::Gt,
73 FilterOperator::Lt,
74 FilterOperator::GtEq,
75 FilterOperator::LtEq,
76 FilterOperator::Contains,
77 FilterOperator::NotContains,
78 FilterOperator::IsNull,
79 FilterOperator::IsNotNull,
80 FilterOperator::Has,
81 FilterOperator::HasRegex,
82 FilterOperator::HasFuzzy,
83 ]
84 .iter()
85 .copied()
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum Operand {
92 Text,
94 Ordered,
96 Boolean,
98 #[default]
100 Any,
101}
102
103impl Operand {
104 pub fn of(dtype: &polars::prelude::DataType) -> Self {
105 use polars::prelude::DataType;
106 match dtype {
107 DataType::String => Self::Text,
108 DataType::Boolean => Self::Boolean,
109 d if d.is_categorical() || d.is_enum() => Self::Text,
110 d if d.is_numeric() || d.is_temporal() => Self::Ordered,
111 _ => Self::Any,
112 }
113 }
114
115 pub fn offers(self, op: FilterOperator) -> bool {
117 use FilterOperator::*;
118 match self {
119 Self::Text | Self::Any => true,
120 Self::Ordered => !matches!(op, Contains | NotContains | Has | HasRegex | HasFuzzy),
121 Self::Boolean => matches!(op, Eq | NotEq | IsNull | IsNotNull),
122 }
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Copy, serde::Serialize, serde::Deserialize)]
127pub enum LogicalOperator {
128 And,
129 Or,
130}
131
132impl LogicalOperator {
133 pub fn as_str(&self) -> &'static str {
134 match self {
135 LogicalOperator::And => "AND",
136 LogicalOperator::Or => "OR",
137 }
138 }
139
140 pub fn toggled(&self) -> Self {
141 match self {
142 LogicalOperator::And => LogicalOperator::Or,
143 LogicalOperator::Or => LogicalOperator::And,
144 }
145 }
146
147 pub fn iterator() -> impl Iterator<Item = LogicalOperator> {
148 [LogicalOperator::And, LogicalOperator::Or].iter().copied()
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
153pub struct FilterStatement {
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 pub columns: Vec<String>,
159 pub column: String,
160 pub operator: FilterOperator,
161 pub value: String,
162 pub logical_op: LogicalOperator,
164}
165
166impl FilterStatement {
167 pub fn describe(&self) -> String {
170 let value = match self.operator {
171 FilterOperator::IsNull | FilterOperator::IsNotNull => String::new(),
172 FilterOperator::HasRegex => format!(" /{}/", self.value),
173 FilterOperator::Has
174 | FilterOperator::HasFuzzy
175 | FilterOperator::Contains
176 | FilterOperator::NotContains => format!(" \"{}\"", self.value),
177 _ => format!(" {}", self.value),
178 };
179 let op = match self.operator {
180 FilterOperator::HasRegex => "has",
181 op => op.as_str(),
182 };
183 if self.column == ANY_COLUMN {
184 format!("{op}{value}")
185 } else {
186 format!("{} {op}{value}", self.column)
187 }
188 }
189}
190
191#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
193pub enum FilterEditStep {
194 #[default]
195 Column,
196 Operator,
197 Value,
198}
199
200pub struct FilterEditor {
203 pub editing: Option<usize>,
205 pub step: FilterEditStep,
206 pub column: PickerState,
207 pub operator: PickerState,
208 pub operators: Vec<FilterOperator>,
211 pub value: TextInput,
212 pub logical: LogicalOperator,
213}
214
215impl FilterEditor {
216 pub fn selected_operator(&self) -> Option<FilterOperator> {
218 self.operator
219 .selected_original()
220 .and_then(|i| self.operators.get(i))
221 .copied()
222 }
223
224 fn set_operators(&mut self, operators: Vec<FilterOperator>) {
227 let chosen = self.selected_operator();
228 let mut picker =
229 PickerState::new(operators.iter().map(|op| op.as_str().to_string()).collect());
230 if let Some(i) = chosen.and_then(|op| operators.iter().position(|o| *o == op)) {
231 picker.select_original(i);
232 }
233 self.operator = picker;
234 self.operators = operators;
235 }
236}
237
238#[derive(Default)]
239pub struct FilterModal {
240 pub statements: Vec<FilterStatement>,
241 pub available_columns: Vec<String>,
242 pub cursor: usize,
245 pub editor: Option<FilterEditor>,
246 pub current_column: Option<String>,
248 pub applied: Vec<FilterStatement>,
250 pub operands: Vec<Operand>,
252}
253
254impl FilterModal {
255 pub fn new() -> Self {
256 Self::default()
257 }
258
259 pub fn operators_for(&self, i: usize) -> Vec<FilterOperator> {
262 let operand = self.operands.get(i).copied().unwrap_or_default();
263 FilterOperator::iterator()
264 .filter(|op| {
265 if i >= self.available_columns.len() {
266 op.is_find()
267 } else {
268 operand.offers(*op)
269 }
270 })
271 .collect()
272 }
273
274 pub fn retarget_operators(&mut self) {
277 let Some(column) = self
278 .editor
279 .as_ref()
280 .and_then(|editor| editor.column.selected_original())
281 else {
282 return;
283 };
284 let operators = self.operators_for(column);
285 if let Some(editor) = self.editor.as_mut() {
286 editor.set_operators(operators);
287 }
288 }
289
290 pub fn has_unapplied_changes(&self) -> bool {
292 self.statements != self.applied
293 }
294
295 pub fn row_count(&self) -> usize {
297 self.statements.len() + 1
298 }
299
300 pub fn on_add_row(&self) -> bool {
301 self.cursor >= self.statements.len()
302 }
303
304 pub fn move_cursor_up(&mut self) {
305 self.cursor = if self.cursor == 0 {
306 self.row_count() - 1
307 } else {
308 self.cursor - 1
309 };
310 }
311
312 pub fn move_cursor_down(&mut self) {
313 self.cursor = (self.cursor + 1) % self.row_count();
314 }
315
316 fn column_choices(&self) -> Vec<String> {
319 let mut choices = self.available_columns.clone();
320 choices.push(ANY_COLUMN_LABEL.to_string());
321 choices
322 }
323
324 pub fn column_at(&self, i: usize) -> String {
326 self.available_columns
327 .get(i)
328 .cloned()
329 .unwrap_or_else(|| ANY_COLUMN.to_string())
330 }
331
332 pub fn column_label(&self, statement: &FilterStatement) -> String {
335 if statement.column != ANY_COLUMN {
336 return statement.column.clone();
337 }
338 if statement.columns.is_empty() || statement.columns == self.available_columns {
339 ANY_COLUMN_LABEL.to_string()
340 } else {
341 format!("{} columns", statement.columns.len())
342 }
343 }
344
345 pub fn open_editor(&mut self, theme: &crate::config::Theme, history_limit: usize) {
348 if self.available_columns.is_empty() {
349 return;
350 }
351 let mut column = PickerState::new(self.column_choices());
352 let mut value = TextInput::new()
353 .with_history_limit(history_limit)
354 .with_theme(theme);
355 value.set_focused(false);
356 let (editing, logical, operator) = if self.on_add_row() {
357 if let Some(i) = self
358 .current_column
359 .as_ref()
360 .and_then(|current| self.available_columns.iter().position(|c| c == current))
361 {
362 column.select_original(i);
363 }
364 (None, LogicalOperator::And, None)
365 } else {
366 let statement = &self.statements[self.cursor];
367 let at = if statement.column == ANY_COLUMN {
369 Some(self.available_columns.len())
370 } else {
371 self.available_columns
372 .iter()
373 .position(|c| *c == statement.column)
374 };
375 if let Some(i) = at {
376 column.select_original(i);
377 }
378 value.set_value(&statement.value);
379 (
380 Some(self.cursor),
381 statement.logical_op,
382 Some(statement.operator),
383 )
384 };
385 let mut operators = column
388 .selected_original()
389 .map(|i| self.operators_for(i))
390 .unwrap_or_else(|| FilterOperator::iterator().collect());
391 if let Some(op) = operator
392 && !operators.contains(&op)
393 {
394 operators.push(op);
395 }
396 let mut picker =
397 PickerState::new(operators.iter().map(|op| op.as_str().to_string()).collect());
398 if let Some(i) = operator.and_then(|op| operators.iter().position(|o| *o == op)) {
399 picker.select_original(i);
400 }
401 self.editor = Some(FilterEditor {
402 editing,
403 step: FilterEditStep::Column,
404 column,
405 operator: picker,
406 operators,
407 value,
408 logical,
409 });
410 }
411
412 pub fn cancel_editor(&mut self) {
413 self.editor = None;
414 }
415
416 pub fn commit_editor(&mut self) {
420 let Some(mut editor) = self.editor.take() else {
421 return;
422 };
423 let Some(column_idx) = editor.column.selected_original() else {
424 return;
425 };
426 let operator = editor.selected_operator().unwrap_or(FilterOperator::Eq);
427 let column = self.column_at(column_idx);
428 if column == ANY_COLUMN && !operator.is_find() {
429 editor.step = FilterEditStep::Operator;
430 self.editor = Some(editor);
431 return;
432 }
433 let columns = if column == ANY_COLUMN {
435 editor
436 .editing
437 .and_then(|i| self.statements.get(i))
438 .filter(|s| s.column == ANY_COLUMN && !s.columns.is_empty())
439 .map(|s| s.columns.clone())
440 .unwrap_or_else(|| self.available_columns.clone())
441 } else {
442 Vec::new()
443 };
444 let statement = FilterStatement {
445 columns,
446 column,
447 operator,
448 value: if operator.takes_value() {
450 editor.value.value().to_string()
451 } else {
452 String::new()
453 },
454 logical_op: editor.logical,
455 };
456 match editor.editing {
457 Some(i) if i < self.statements.len() => self.statements[i] = statement,
458 _ => {
459 self.statements.push(statement);
460 self.cursor = self.statements.len();
461 }
462 }
463 }
464
465 pub fn move_statement(&mut self, i: usize, earlier: bool) -> usize {
469 let to = if earlier {
470 i.checked_sub(1)
471 } else {
472 Some(i + 1).filter(|to| *to < self.statements.len())
473 };
474 match to {
475 Some(to) if i < self.statements.len() => {
476 let (a, b) = (
477 self.statements[i].logical_op,
478 self.statements[to].logical_op,
479 );
480 self.statements.swap(i, to);
481 self.statements[i].logical_op = a;
482 self.statements[to].logical_op = b;
483 to
484 }
485 _ => i,
486 }
487 }
488
489 pub fn delete_at_cursor(&mut self) {
491 if self.cursor < self.statements.len() {
492 self.statements.remove(self.cursor);
493 self.cursor = self.cursor.min(self.statements.len());
494 }
495 }
496
497 pub fn toggle_logical_at_cursor(&mut self) {
500 if self.cursor > 0
501 && let Some(statement) = self.statements.get_mut(self.cursor)
502 {
503 statement.logical_op = statement.logical_op.toggled();
504 }
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 fn modal() -> FilterModal {
513 let mut m = FilterModal::new();
514 m.available_columns = vec!["salary".into(), "department".into(), "name".into()];
515 m
516 }
517
518 fn theme() -> crate::config::Theme {
519 crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap()
520 }
521
522 #[test]
526 fn a_kept_find_over_every_column_edits_as_one() {
527 let mut m = modal();
528 m.statements = vec![FilterStatement {
529 columns: Vec::new(),
530 column: ANY_COLUMN.into(),
531 operator: FilterOperator::HasFuzzy,
532 value: "chkn".into(),
533 logical_op: LogicalOperator::And,
534 }];
535 m.cursor = 0;
536 m.open_editor(&theme(), 10);
537 {
538 let editor = m.editor.as_mut().unwrap();
539 let picked = editor.column.selected_original().unwrap();
540 assert_eq!(m.available_columns.len(), picked, "the last choice");
541 editor.value.set_value("chicken");
542 }
543 m.commit_editor();
544 assert!(m.editor.is_none());
545 assert_eq!(m.statements[0].column, ANY_COLUMN);
546 assert_eq!(m.statements[0].operator, FilterOperator::HasFuzzy);
547 assert_eq!(m.statements[0].value, "chicken");
548
549 m.cursor = 0;
552 m.open_editor(&theme(), 10);
553 let editor = m.editor.as_mut().unwrap();
554 assert!(editor.operators.iter().all(FilterOperator::is_find));
555 editor.operators[0] = FilterOperator::Eq;
556 editor.operator.select_original(0);
557 m.commit_editor();
558 let editor = m.editor.as_ref().expect("still editing");
559 assert_eq!(editor.step, FilterEditStep::Operator);
560 assert_eq!(m.statements[0].operator, FilterOperator::HasFuzzy);
561 }
562
563 #[test]
568 fn operators_follow_the_columns_type() {
569 let mut m = modal();
570 m.available_columns.push("active".into());
571 m.operands = vec![
572 Operand::Ordered,
573 Operand::Text,
574 Operand::Text,
575 Operand::Boolean,
576 ];
577 m.current_column = Some("salary".into());
578 m.open_editor(&theme(), 10);
579 let offered = |m: &FilterModal| m.editor.as_ref().unwrap().operators.clone();
580 assert!(offered(&m).contains(&FilterOperator::GtEq));
581 assert!(!offered(&m).contains(&FilterOperator::Contains));
582 {
584 let editor = m.editor.as_mut().unwrap();
585 let eq = editor
586 .operators
587 .iter()
588 .position(|op| *op == FilterOperator::Eq);
589 editor.operator.select_original(eq.unwrap());
590 editor.column.select_original(1);
591 }
592 m.retarget_operators();
593 assert!(offered(&m).contains(&FilterOperator::Contains));
594 assert_eq!(
595 m.editor.as_ref().unwrap().selected_operator(),
596 Some(FilterOperator::Eq)
597 );
598 m.editor.as_mut().unwrap().column.select_original(3);
599 m.retarget_operators();
600 assert_eq!(
601 offered(&m),
602 [
603 FilterOperator::Eq,
604 FilterOperator::NotEq,
605 FilterOperator::IsNull,
606 FilterOperator::IsNotNull
607 ]
608 );
609 m.editor.as_mut().unwrap().column.select_original(4);
610 m.retarget_operators();
611 assert!(offered(&m).iter().all(FilterOperator::is_find));
612 m.cancel_editor();
613
614 m.statements = vec![FilterStatement {
616 columns: Vec::new(),
617 column: "salary".into(),
618 operator: FilterOperator::Has,
619 value: "12".into(),
620 logical_op: LogicalOperator::And,
621 }];
622 m.cursor = 0;
623 m.open_editor(&theme(), 10);
624 assert_eq!(
625 m.editor.as_ref().unwrap().selected_operator(),
626 Some(FilterOperator::Has)
627 );
628 }
629
630 #[test]
631 fn a_null_test_keeps_no_value() {
632 let mut m = modal();
633 m.open_editor(&theme(), 10);
634 {
635 let editor = m.editor.as_mut().unwrap();
636 editor.operator.select_original(
637 FilterOperator::iterator()
638 .position(|op| op == FilterOperator::IsNull)
639 .unwrap(),
640 );
641 editor.value.set_value("left over");
642 }
643 m.commit_editor();
644 let s = &m.statements[0];
645 assert_eq!(s.operator, FilterOperator::IsNull);
646 assert!(!s.operator.takes_value());
647 assert_eq!(s.value, "");
648 }
649
650 #[test]
651 fn adding_walks_column_operator_value_and_appends() {
652 let mut m = modal();
653 assert!(m.on_add_row());
654 m.open_editor(&theme(), 10);
655 {
656 let editor = m.editor.as_mut().unwrap();
657 editor.column.type_char('d');
659 editor.column.type_char('e');
660 editor.step = FilterEditStep::Operator;
661 editor.operator.select_original(2); editor.value.set_value("100");
663 }
664 m.commit_editor();
665 assert_eq!(m.statements.len(), 1);
666 let s = &m.statements[0];
667 assert_eq!(s.column, "department");
668 assert_eq!(s.operator, FilterOperator::Gt);
669 assert_eq!(s.value, "100");
670 assert!(m.on_add_row(), "the cursor lands back on the add row");
671 }
672
673 #[test]
674 fn editing_rewrites_in_place_and_keeps_the_conjunction() {
675 let mut m = modal();
676 m.statements = vec![
677 FilterStatement {
678 columns: Vec::new(),
679 column: "salary".into(),
680 operator: FilterOperator::Gt,
681 value: "1".into(),
682 logical_op: LogicalOperator::And,
683 },
684 FilterStatement {
685 columns: Vec::new(),
686 column: "name".into(),
687 operator: FilterOperator::Eq,
688 value: "ann".into(),
689 logical_op: LogicalOperator::Or,
690 },
691 ];
692 m.cursor = 1;
693 m.open_editor(&theme(), 10);
694 {
695 let editor = m.editor.as_mut().unwrap();
696 assert_eq!(editor.editing, Some(1));
697 assert_eq!(editor.value.value(), "ann", "the row arrives pre-filled");
698 editor.value.set_value("bob");
699 }
700 m.commit_editor();
701 assert_eq!(m.statements.len(), 2, "edited, not appended");
702 assert_eq!(m.statements[1].value, "bob");
703 assert_eq!(m.statements[1].logical_op, LogicalOperator::Or);
704 }
705
706 #[test]
707 fn delete_and_conjunction_act_on_the_cursor_row_only() {
708 let mut m = modal();
709 m.statements = vec![
710 FilterStatement {
711 columns: Vec::new(),
712 column: "salary".into(),
713 operator: FilterOperator::Gt,
714 value: "1".into(),
715 logical_op: LogicalOperator::And,
716 },
717 FilterStatement {
718 columns: Vec::new(),
719 column: "name".into(),
720 operator: FilterOperator::Eq,
721 value: "ann".into(),
722 logical_op: LogicalOperator::And,
723 },
724 ];
725 m.cursor = 0;
726 m.toggle_logical_at_cursor();
727 assert_eq!(
728 m.statements[0].logical_op,
729 LogicalOperator::And,
730 "the first statement joins nothing"
731 );
732 m.cursor = 1;
733 m.toggle_logical_at_cursor();
734 assert_eq!(m.statements[1].logical_op, LogicalOperator::Or);
735
736 m.cursor = 2;
737 m.delete_at_cursor();
738 assert_eq!(m.statements.len(), 2, "the add row deletes nothing");
739 m.cursor = 0;
740 m.delete_at_cursor();
741 assert_eq!(m.statements.len(), 1);
742 assert_eq!(m.statements[0].column, "name");
743 }
744
745 #[test]
746 fn moving_a_filter_keeps_each_and_or_in_its_place() {
747 let mut m = modal();
748 m.statements = vec![
749 FilterStatement {
750 columns: Vec::new(),
751 column: "salary".into(),
752 operator: FilterOperator::Gt,
753 value: "1".into(),
754 logical_op: LogicalOperator::And,
755 },
756 FilterStatement {
757 columns: Vec::new(),
758 column: "name".into(),
759 operator: FilterOperator::Eq,
760 value: "ann".into(),
761 logical_op: LogicalOperator::Or,
762 },
763 ];
764 assert_eq!(m.move_statement(1, true), 0);
765 assert_eq!(m.statements[0].column, "name");
766 assert_eq!(
767 m.statements[1].logical_op,
768 LogicalOperator::Or,
769 "still an or"
770 );
771 assert_eq!(m.move_statement(0, true), 0, "the first stays first");
772 }
773
774 #[test]
775 fn an_editor_with_no_columns_never_opens() {
776 let mut m = FilterModal::new();
777 m.open_editor(&theme(), 10);
778 assert!(m.editor.is_none());
779 }
780
781 #[test]
782 fn a_narrowed_to_nothing_column_commits_nothing() {
783 let mut m = modal();
784 m.open_editor(&theme(), 10);
785 {
786 let editor = m.editor.as_mut().unwrap();
787 for c in "zzz".chars() {
788 editor.column.type_char(c);
789 }
790 }
791 m.commit_editor();
792 assert!(m.statements.is_empty());
793 assert!(m.editor.is_none(), "the edit still ends");
794 }
795}