Skip to main content

datui_lib/widgets/
view_modal.rs

1//! State for the Views sidebar: the list of saved views scored against the
2//! open file, and the save/edit form.
3
4use crate::view::{BrokenView, MatchReason, SavedView};
5use crate::widgets::text_input::TextInput;
6use ratatui::widgets::TableState;
7
8#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
9pub enum ViewModalMode {
10    #[default]
11    List,
12    Create,
13    Edit,
14}
15
16/// One row of the list: a view scored against the open file, and the reason
17/// its criteria fit, when they do.
18pub struct ViewRow {
19    pub view: SavedView,
20    pub score: f64,
21    pub reason: Option<MatchReason>,
22}
23
24/// The rows of the save/edit form, in Tab order. The five criteria rows walk
25/// only while the Matching section is expanded.
26#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
27pub enum FormFocus {
28    #[default]
29    Name,
30    Description,
31    Matching,
32    ExactPath,
33    RelativePath,
34    PathPattern,
35    FilenamePattern,
36    SchemaMatch,
37}
38
39#[derive(Default)]
40pub struct ViewModal {
41    pub active: bool,
42    pub mode: ViewModalMode,
43    pub table_state: TableState,
44    pub rows: Vec<ViewRow>,
45    pub broken_views: Vec<BrokenView>, // Views that failed to parse
46    // Save/edit form
47    pub form_focus: FormFocus,
48    /// Whether the Matching section's criteria rows are open for editing.
49    /// Collapsed by default: the defaults stand, and the form is name-first.
50    pub matching_expanded: bool,
51    pub name_input: TextInput,
52    pub description_input: TextInput,
53    pub exact_path_input: TextInput,
54    pub relative_path_input: TextInput,
55    pub path_pattern_input: TextInput,
56    pub filename_pattern_input: TextInput,
57    pub schema_match_enabled: bool,
58    /// The table of a file of tables the view is for, echoed under the criteria: set
59    /// from the dataset on save, kept from the view on edit.
60    pub table: Option<String>,
61    pub editing_view_id: Option<String>, // None while creating
62    pub name_error: Option<String>,
63    /// A refusal or note for the list's own status line, above the footer:
64    /// said where the key was pressed, not in a modal. The next key clears it.
65    pub status: Option<String>,
66    pub history_limit: usize,
67    /// Set by `i`: the selected view's score breakdown, (title, body).
68    pub score_details: Option<(String, String)>,
69}
70
71impl ViewModal {
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    pub fn selected_view(&self) -> Option<&SavedView> {
77        self.table_state
78            .selected()
79            .and_then(|i| self.rows.get(i))
80            .map(|row| &row.view)
81    }
82
83    /// Move the list selection by one, wrapping. Broken rows are shown after
84    /// the views but are not selectable.
85    pub fn select_next(&mut self) {
86        if self.rows.is_empty() {
87            return;
88        }
89        let i = match self.table_state.selected() {
90            Some(i) if i + 1 < self.rows.len() => i + 1,
91            Some(_) => 0,
92            None => 0,
93        };
94        self.table_state.select(Some(i));
95    }
96
97    pub fn select_prev(&mut self) {
98        if self.rows.is_empty() {
99            return;
100        }
101        let i = match self.table_state.selected() {
102            Some(0) | None => self.rows.len() - 1,
103            Some(i) => i - 1,
104        };
105        self.table_state.select(Some(i));
106    }
107
108    /// The form rows Tab walks right now, in order.
109    pub fn focus_order(&self) -> &'static [FormFocus] {
110        if self.matching_expanded {
111            &[
112                FormFocus::Name,
113                FormFocus::Description,
114                FormFocus::Matching,
115                FormFocus::ExactPath,
116                FormFocus::RelativePath,
117                FormFocus::PathPattern,
118                FormFocus::FilenamePattern,
119                FormFocus::SchemaMatch,
120            ]
121        } else {
122            &[FormFocus::Name, FormFocus::Description, FormFocus::Matching]
123        }
124    }
125
126    /// Collapsing while focus sits on a hidden criteria row pulls it back to
127    /// the section header, so focus never points at nothing.
128    pub fn toggle_matching(&mut self) {
129        self.matching_expanded = !self.matching_expanded;
130        if !self.matching_expanded && !self.focus_order().contains(&self.form_focus) {
131            self.form_focus = FormFocus::Matching;
132        }
133    }
134
135    /// The text input the form focus sits on, for key forwarding.
136    pub fn focused_input_mut(&mut self) -> Option<&mut TextInput> {
137        match self.form_focus {
138            FormFocus::Name => Some(&mut self.name_input),
139            FormFocus::Description => Some(&mut self.description_input),
140            FormFocus::ExactPath => Some(&mut self.exact_path_input),
141            FormFocus::RelativePath => Some(&mut self.relative_path_input),
142            FormFocus::PathPattern => Some(&mut self.path_pattern_input),
143            FormFocus::FilenamePattern => Some(&mut self.filename_pattern_input),
144            FormFocus::Matching | FormFocus::SchemaMatch => None,
145        }
146    }
147
148    /// How many of the criteria are set, for the collapsed section's chip.
149    pub fn criteria_count(&self) -> usize {
150        [
151            &self.exact_path_input,
152            &self.relative_path_input,
153            &self.path_pattern_input,
154            &self.filename_pattern_input,
155        ]
156        .iter()
157        .filter(|input| !input.value().trim().is_empty())
158        .count()
159            + usize::from(self.schema_match_enabled)
160            + usize::from(self.table.is_some())
161    }
162
163    fn reset_form(&mut self, history_limit: usize, theme: &crate::config::Theme) {
164        self.form_focus = FormFocus::Name;
165        self.matching_expanded = false;
166        self.name_error = None;
167        self.history_limit = history_limit;
168        self.name_input = TextInput::new()
169            .with_history_limit(history_limit)
170            .with_theme(theme);
171        self.description_input = TextInput::multiline()
172            .with_history_limit(history_limit)
173            .with_theme(theme);
174        self.exact_path_input = TextInput::new()
175            .with_history_limit(history_limit)
176            .with_theme(theme);
177        self.relative_path_input = TextInput::new()
178            .with_history_limit(history_limit)
179            .with_theme(theme);
180        self.path_pattern_input = TextInput::new()
181            .with_history_limit(history_limit)
182            .with_theme(theme);
183        self.filename_pattern_input = TextInput::new()
184            .with_history_limit(history_limit)
185            .with_theme(theme);
186        self.schema_match_enabled = false;
187        self.table = None;
188    }
189
190    pub fn enter_create_mode(&mut self, history_limit: usize, theme: &crate::config::Theme) {
191        self.mode = ViewModalMode::Create;
192        self.editing_view_id = None;
193        self.reset_form(history_limit, theme);
194    }
195
196    pub fn enter_edit_mode(
197        &mut self,
198        view: &SavedView,
199        history_limit: usize,
200        theme: &crate::config::Theme,
201    ) {
202        self.mode = ViewModalMode::Edit;
203        self.reset_form(history_limit, theme);
204        self.editing_view_id = Some(view.id.clone());
205        self.name_input.set_value(&view.name);
206        self.description_input
207            .set_value(view.description.clone().unwrap_or_default());
208        self.exact_path_input.set_value(
209            view.match_criteria
210                .exact_path
211                .as_ref()
212                .map(|p| p.to_string_lossy().to_string())
213                .unwrap_or_default(),
214        );
215        self.relative_path_input.set_value(
216            view.match_criteria
217                .relative_path
218                .clone()
219                .unwrap_or_default(),
220        );
221        self.path_pattern_input
222            .set_value(view.match_criteria.path_pattern.clone().unwrap_or_default());
223        self.filename_pattern_input.set_value(
224            view.match_criteria
225                .filename_pattern
226                .clone()
227                .unwrap_or_default(),
228        );
229        self.schema_match_enabled = view.match_criteria.schema_columns.is_some();
230        self.table = view.match_criteria.table.clone();
231    }
232
233    pub fn exit_form(&mut self) {
234        self.mode = ViewModalMode::List;
235        self.editing_view_id = None;
236        self.name_error = None;
237    }
238
239    /// Take the modal down wholesale, whatever mode it is in.
240    pub fn close(&mut self) {
241        self.exit_form();
242        self.active = false;
243        self.score_details = None;
244    }
245}
246
247impl crate::form::Form for ViewModal {
248    type Field = FormFocus;
249
250    fn fields(&self) -> Vec<(FormFocus, crate::form::FieldKind)> {
251        use crate::form::FieldKind;
252        self.focus_order()
253            .iter()
254            .map(|&row| {
255                let kind = match row {
256                    FormFocus::Description => FieldKind::MultilineText,
257                    FormFocus::Matching => FieldKind::Button,
258                    FormFocus::SchemaMatch => FieldKind::Checkbox,
259                    _ => FieldKind::Text,
260                };
261                (row, kind)
262            })
263            .collect()
264    }
265
266    fn focused(&self) -> FormFocus {
267        self.form_focus
268    }
269
270    fn set_focused(&mut self, field: FormFocus) {
271        self.form_focus = field;
272    }
273
274    fn text_edge(&self, _field: FormFocus) -> (bool, bool) {
275        let line = self.description_input.cursor_line();
276        let last = self.description_input.line_count().saturating_sub(1);
277        (line == 0, line >= last)
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::form::Form;
285
286    /// Collapsed, Tab walks name → description → matching and wraps; the five
287    /// criteria only join the walk once the section is expanded.
288    #[test]
289    fn tab_walks_the_criteria_only_when_expanded() {
290        let mut modal = ViewModal::new();
291        modal.form_focus = FormFocus::Name;
292        modal.focus_next();
293        modal.focus_next();
294        assert_eq!(modal.form_focus, FormFocus::Matching);
295        modal.focus_next();
296        assert_eq!(modal.form_focus, FormFocus::Name, "collapsed wraps early");
297
298        modal.form_focus = FormFocus::Matching;
299        modal.toggle_matching();
300        modal.focus_next();
301        assert_eq!(modal.form_focus, FormFocus::ExactPath);
302        modal.focus_prev();
303        modal.focus_prev();
304        assert_eq!(modal.form_focus, FormFocus::Description);
305    }
306
307    /// Collapsing while focus is on a criteria row pulls focus back to the
308    /// section header instead of leaving it on a hidden row.
309    #[test]
310    fn collapsing_rescues_focus_from_a_hidden_row() {
311        let mut modal = ViewModal::new();
312        modal.matching_expanded = true;
313        modal.form_focus = FormFocus::PathPattern;
314        modal.toggle_matching();
315        assert!(!modal.matching_expanded);
316        assert_eq!(modal.form_focus, FormFocus::Matching);
317    }
318
319    #[test]
320    fn criteria_count_counts_set_fields_and_the_schema_toggle() {
321        let mut modal = ViewModal::new();
322        assert_eq!(modal.criteria_count(), 0);
323        modal.exact_path_input.set_value("/data/x.csv");
324        modal.filename_pattern_input.set_value("x_*.csv");
325        modal.schema_match_enabled = true;
326        assert_eq!(modal.criteria_count(), 3);
327    }
328}