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