Skip to main content

datui_lib/
filter_modal.rs

1//! The Filters tab: one row per statement, and a three-step inline editor —
2//! column via Picker, operator via a short Picker, value as text.
3
4use 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    /// A find's match kept as a filter: the text, in any case until a capital is
20    /// typed. With [`ANY_COLUMN`], in any column.
21    Has,
22    /// A find's regex kept as a filter.
23    HasRegex,
24    /// A find's letters in order kept as a filter: `smth` keeps `Smith`.
25    HasFuzzy,
26}
27
28/// The column of a filter kept from a find over every column: a row passes when any
29/// of its columns matches.
30pub const ANY_COLUMN: &str = "*";
31
32/// How the column picker and the sidebar name [`ANY_COLUMN`].
33pub 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    /// A find kept as a filter: its value is matched as the find matched it, as text
55    /// whatever the column's type.
56    pub fn is_find(&self) -> bool {
57        matches!(
58            self,
59            FilterOperator::Has | FilterOperator::HasRegex | FilterOperator::HasFuzzy
60        )
61    }
62
63    /// Whether the statement compares with a value; the null tests take none.
64    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/// What a column holds, as far as the operators that read it go.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum Operand {
92    /// Text: every operator reads it.
93    Text,
94    /// Numbers, dates and times: compared, never searched as text.
95    Ordered,
96    /// True or false: equal or not, or null.
97    Boolean,
98    /// Anything else (lists, structs, bytes): no operator is ruled out.
99    #[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    /// Whether the operator picker offers `op` for a column of this kind.
116    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    /// The columns a find kept over every column ([`ANY_COLUMN`]) searches: the
155    /// ones shown when it was kept, so the table, the script and a saved view match
156    /// the same cells whatever the layout later. Empty for every other statement.
157    #[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    /// How this statement joins the one before it; meaningless on the first.
163    pub logical_op: LogicalOperator,
164}
165
166impl FilterStatement {
167    /// How the statement reads in a line of text: `prcp > 0`, `name has "smith"`,
168    /// `has /^US/` for a find kept over every column.
169    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/// Where the inline editor stands: the three steps walk left to right on one row.
192#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
193pub enum FilterEditStep {
194    #[default]
195    Column,
196    Operator,
197    Value,
198}
199
200/// One statement under edit. Enter advances a step and commits from the value;
201/// Esc abandons the edit and only the edit.
202pub struct FilterEditor {
203    /// `Some(i)` rewrites statement `i`; `None` appends a new one.
204    pub editing: Option<usize>,
205    pub step: FilterEditStep,
206    pub column: PickerState,
207    pub operator: PickerState,
208    /// The operators the operator picker lists, in its order: those the column's
209    /// type takes.
210    pub operators: Vec<FilterOperator>,
211    pub value: TextInput,
212    pub logical: LogicalOperator,
213}
214
215impl FilterEditor {
216    /// The operator chosen in the picker.
217    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    /// List `operators` in the operator picker, keeping the choice where it is
225    /// still offered.
226    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    /// The row the cursor is on: an index into `statements`, or one past the
243    /// end for the trailing "add filter" row.
244    pub cursor: usize,
245    pub editor: Option<FilterEditor>,
246    /// The table's column cursor, where a new filter's column starts.
247    pub current_column: Option<String>,
248    /// The statements in effect on the table when the sidebar opened.
249    pub applied: Vec<FilterStatement>,
250    /// What each of `available_columns` holds, in the same order.
251    pub operands: Vec<Operand>,
252}
253
254impl FilterModal {
255    pub fn new() -> Self {
256        Self::default()
257    }
258
259    /// The operators column choice `i` takes: by its type, and only a find's over
260    /// any column shown.
261    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    /// After the column step: the operator picker lists what the chosen column
275    /// takes.
276    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    /// Whether the statements staged differ from those in effect.
291    pub fn has_unapplied_changes(&self) -> bool {
292        self.statements != self.applied
293    }
294
295    /// Rows the cursor can rest on: every statement plus the add row.
296    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    /// The column picker's choices: every column, then [`ANY_COLUMN_LABEL`], which
317    /// a find's operators (`has`, `has regex`, `has letters`) take.
318    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    /// The column choice `i` of the picker names: a column, or [`ANY_COLUMN`].
325    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    /// How a statement's column reads in the list: a find over every column is
333    /// "any column shown" while it searches the columns shown, else counts them.
334    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    /// Start the editor on the cursor's row: pre-filled over a statement, empty
346    /// on the add row.
347    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            // A find kept over every column comes back as "any column shown".
368            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        // The column's operators, and the statement's own even where its type
386        // would not offer it (a find kept on a number).
387        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    /// Commit the editor's statement; the edit dies if its column picker chose
417    /// nothing (a filter narrowed to no match). Any column shown takes only a
418    /// find's operators: with another, the editor stays open on the operator.
419    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        // Over every column: the columns it searched before, else the ones shown.
434        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            // A null test keeps no stale value from an earlier operator.
449            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    /// Move statement `i` one place earlier or later. Each and/or stays where it
466    /// was, between the same two places, so `a or b` reordered is `b or a`, not
467    /// `b and a`. Returns where it is now.
468    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    /// Delete the statement under the cursor; the add row deletes nothing.
490    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    /// Flip and/or on the cursor's row. The first statement joins nothing, and
498    /// the add row is not a statement, so both are left alone.
499    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    /// A find kept over every column edits as one: its column comes back as "any
523    /// column shown", and stays `*` when only the value changes; other operators
524    /// do not take it.
525    #[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        // `=` over every column means nothing: only a find's operators are
550        // offered, and one that is not waits on the operator.
551        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    /// The operator picker lists what the column's type takes: no text search on
564    /// a number, only equality and nulls on a flag, and a find's operators over
565    /// any column shown. A statement keeps its own operator even where its type
566    /// would not offer it.
567    #[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        // Another column chosen: its operators, the choice kept where it can be.
583        {
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        // A find kept on a number edits with its own operator still there.
615        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            // Type-to-narrow reaches the column without arrow-cycling the list.
658            editor.column.type_char('d');
659            editor.column.type_char('e');
660            editor.step = FilterEditStep::Operator;
661            editor.operator.select_original(2); // >
662            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}