Skip to main content

datui_lib/chart/
chart_export_modal.rs

1//! Chart export dialog: where, in what format, style and size, with what legend,
2//! and the words around the chart. Its keys are the shared form keys
3//! (`crate::app::form`).
4
5use crate::chart::chart_export::{
6    ChartExportFormat, ExportStyle, LegendPlace, LineWidth, PointOpacity, PointSize, SizePreset,
7};
8use crate::chart::chart_modal::Mark;
9use crate::widgets::text_input::TextInput;
10use std::path::Path;
11
12#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
13pub enum ChartExportFocus {
14    #[default]
15    PathInput,
16    Format,
17    Style,
18    Size,
19    WidthInput,
20    HeightInput,
21    Legend,
22    PointOpacity,
23    PointSize,
24    LineWidth,
25    YFromZero,
26    TitleInput,
27    DescriptionInput,
28    NotesInput,
29    SourceInput,
30    BylineInput,
31    Recipe,
32}
33
34impl ChartExportFocus {
35    /// Whether the field is typed into; every other one is a choice. Every field is
36    /// named, so a new one has to say which it is.
37    pub fn is_text(self) -> bool {
38        match self {
39            Self::PathInput
40            | Self::WidthInput
41            | Self::HeightInput
42            | Self::TitleInput
43            | Self::DescriptionInput
44            | Self::NotesInput
45            | Self::SourceInput
46            | Self::BylineInput => true,
47            Self::Format
48            | Self::Style
49            | Self::Size
50            | Self::Legend
51            | Self::PointOpacity
52            | Self::PointSize
53            | Self::LineWidth
54            | Self::YFromZero
55            | Self::Recipe => false,
56        }
57    }
58
59    /// The field's label.
60    pub fn label(self) -> &'static str {
61        FIELDS
62            .iter()
63            .find(|(field, _)| *field == self)
64            .map_or("", |(_, label)| label)
65    }
66}
67
68/// The fields in order, top to bottom, with their labels; the marks' rows show only
69/// for the chart types they change ([`ChartExportModal::shows`]).
70pub const FIELDS: [(ChartExportFocus, &str); 17] = [
71    (ChartExportFocus::PathInput, "Path:"),
72    (ChartExportFocus::Format, "Format:"),
73    (ChartExportFocus::Style, "Style:"),
74    (ChartExportFocus::Size, "Size:"),
75    (ChartExportFocus::WidthInput, "Width:"),
76    (ChartExportFocus::HeightInput, "Height:"),
77    (ChartExportFocus::Legend, "Legend:"),
78    (ChartExportFocus::PointOpacity, "Opacity:"),
79    (ChartExportFocus::PointSize, "Point size:"),
80    (ChartExportFocus::LineWidth, "Line width:"),
81    (ChartExportFocus::YFromZero, "Y from zero:"),
82    (ChartExportFocus::TitleInput, "Title:"),
83    (ChartExportFocus::DescriptionInput, "Description:"),
84    (ChartExportFocus::NotesInput, "Notes:"),
85    (ChartExportFocus::SourceInput, "Source:"),
86    (ChartExportFocus::BylineInput, "Byline:"),
87    (ChartExportFocus::Recipe, "Recipe:"),
88];
89
90/// What the chart being exported brings to the dialog.
91#[derive(Debug, Clone, Default)]
92pub struct ExportDefaults {
93    /// What the chart is: the description, until it is changed.
94    pub description: String,
95    /// Where the data comes from: the catalog entry's publisher and license.
96    pub source: String,
97    /// Whether the chart shows its legend; off carries over to the file.
98    pub legend: bool,
99    /// The chart's type: which mark rows the dialog offers.
100    pub mark: Mark,
101    /// A line chart's Y from zero, as drawn.
102    pub y_from_zero: bool,
103}
104
105pub struct ChartExportModal {
106    pub focus: ChartExportFocus,
107    pub format: ChartExportFormat,
108    pub style: ExportStyle,
109    pub size: SizePreset,
110    /// Pixels per inch: the last preset's, which a custom size keeps, so typing a
111    /// width does not change the text's size on the page.
112    pub dpi: f32,
113    pub legend: LegendPlace,
114    /// The chart being exported, for which mark rows apply.
115    pub mark: Mark,
116    pub point_opacity: PointOpacity,
117    pub point_size: PointSize,
118    pub line_width: LineWidth,
119    pub y_from_zero: bool,
120    pub path_input: TextInput,
121    pub width_input: TextInput,
122    pub height_input: TextInput,
123    pub title_input: TextInput,
124    pub description_input: TextInput,
125    pub notes_input: TextInput,
126    pub source_input: TextInput,
127    pub byline_input: TextInput,
128    /// Whether the file carries how the chart was made: the source path, query,
129    /// chart and sample. Starts from `chart.export_recipe`, then stays as last set.
130    pub recipe: bool,
131    /// A view's export settings, put in place the next time the dialog opens.
132    pub restore: Option<crate::view::SavedChartExport>,
133    /// Why Enter did not write: a blank path, or the failed write's reason. Said
134    /// on the dialog's status line; cleared by typing in the path.
135    pub error: Option<String>,
136}
137
138impl ChartExportModal {
139    pub fn new() -> Self {
140        Self::default()
141    }
142
143    /// Open the dialog for a chart. The format, style, size and byline stay as
144    /// they were last used; the words about the chart start from `defaults`.
145    pub fn open(
146        &mut self,
147        theme: &crate::config::Theme,
148        history_limit: usize,
149        defaults: ExportDefaults,
150    ) {
151        self.focus = ChartExportFocus::PathInput;
152        self.error = None;
153        let input = || TextInput::new().with_theme(theme);
154        // Ctrl+P / Ctrl+N recall the paths exported to before.
155        self.path_input = TextInput::new()
156            .with_history("chart_export_path".to_string())
157            .with_history_limit(history_limit)
158            .with_theme(theme);
159        self.title_input = input();
160        self.description_input = input();
161        self.description_input.set_value(defaults.description);
162        self.notes_input = input();
163        self.source_input = input();
164        self.source_input.set_value(defaults.source);
165        let byline = self.byline_input.value().to_string();
166        self.byline_input = input();
167        self.byline_input.set_value(byline);
168        let (width, height) = (
169            self.width_input.value().to_string(),
170            self.height_input.value().to_string(),
171        );
172        self.width_input = input();
173        self.height_input = input();
174        self.width_input.set_value(width);
175        self.height_input.set_value(height);
176        self.legend = if defaults.legend {
177            LegendPlace::LineEnds
178        } else {
179            LegendPlace::Off
180        };
181        // The marks' sizes stay as last used; the axis follows the chart.
182        self.mark = defaults.mark;
183        self.y_from_zero = defaults.y_from_zero;
184        self.apply_size();
185        if let Some(saved) = self.restore.take() {
186            self.put_back(saved);
187        }
188    }
189
190    /// The dialog as a view keeps it: everything but the path.
191    pub fn saved(&self) -> crate::view::SavedChartExport {
192        let (width, height) = self.export_dimensions();
193        crate::view::SavedChartExport {
194            format: self.format,
195            style: self.style,
196            size: self.size,
197            width,
198            height,
199            dpi: self.dpi,
200            legend: self.legend,
201            point_opacity: self.point_opacity,
202            point_size: self.point_size,
203            line_width: self.line_width,
204            y_from_zero: self.y_from_zero,
205            title: self.title_input.value().to_string(),
206            description: self.description_input.value().to_string(),
207            notes: self.notes_input.value().to_string(),
208            source: self.source_input.value().to_string(),
209            byline: self.byline_input.value().to_string(),
210            recipe: self.recipe,
211        }
212    }
213
214    /// Put back a view's settings. Words it left blank keep the chart's own.
215    fn put_back(&mut self, saved: crate::view::SavedChartExport) {
216        self.format = saved.format;
217        self.style = saved.style;
218        self.size = saved.size;
219        self.dpi = saved.dpi;
220        self.width_input.set_value(saved.width.to_string());
221        self.height_input.set_value(saved.height.to_string());
222        self.legend = saved.legend;
223        self.point_opacity = saved.point_opacity;
224        self.point_size = saved.point_size;
225        self.line_width = saved.line_width;
226        self.y_from_zero = saved.y_from_zero;
227        self.recipe = saved.recipe;
228        for (input, text) in [
229            (&mut self.title_input, saved.title),
230            (&mut self.description_input, saved.description),
231            (&mut self.notes_input, saved.notes),
232            (&mut self.source_input, saved.source),
233            (&mut self.byline_input, saved.byline),
234        ] {
235            if !text.is_empty() {
236                input.set_value(text);
237            }
238        }
239    }
240
241    /// Whether `field` applies to the chart being exported: the points' rows to a
242    /// scatter, the line's width and Y from zero to a line. Bars always start at
243    /// zero: a bar's length is its value.
244    pub fn shows(&self, field: ChartExportFocus) -> bool {
245        match field {
246            ChartExportFocus::PointOpacity | ChartExportFocus::PointSize => {
247                self.mark == Mark::Scatter
248            }
249            ChartExportFocus::LineWidth | ChartExportFocus::YFromZero => self.mark == Mark::Line,
250            _ => true,
251        }
252    }
253
254    /// The fields on screen, top to bottom.
255    pub fn shown(&self) -> Vec<ChartExportFocus> {
256        FIELDS
257            .into_iter()
258            .map(|(field, _)| field)
259            .filter(|f| self.shows(*f))
260            .collect()
261    }
262
263    /// What a choice row shows.
264    pub fn choice(&self, field: ChartExportFocus) -> Option<&'static str> {
265        Some(match field {
266            ChartExportFocus::Format => self.format.as_str(),
267            ChartExportFocus::Style => self.style.label(),
268            ChartExportFocus::Size => self.size.label(),
269            ChartExportFocus::Legend => self.legend.label(),
270            ChartExportFocus::PointOpacity => self.point_opacity.label(),
271            ChartExportFocus::PointSize => self.point_size.label(),
272            ChartExportFocus::LineWidth => self.line_width.label(),
273            ChartExportFocus::YFromZero => {
274                if self.y_from_zero {
275                    "On"
276                } else {
277                    "Off"
278                }
279            }
280            // What is embedded is said with the choice, so nobody is surprised by it.
281            ChartExportFocus::Recipe => {
282                if self.recipe {
283                    "Include: source path, query, chart, sample"
284                } else {
285                    "Omit: no datui metadata"
286                }
287            }
288            _ => return None,
289        })
290    }
291
292    /// Y from zero for the file: what the dialog says, on a line chart.
293    pub fn y_from_zero_option(&self) -> Option<bool> {
294        self.shows(ChartExportFocus::YFromZero)
295            .then_some(self.y_from_zero)
296    }
297
298    /// Reopen after an overwrite declined or a failed write: the form as it was,
299    /// focus on the path.
300    pub fn reopen_with_path(&mut self, path: &Path, format: ChartExportFormat) {
301        self.focus = ChartExportFocus::PathInput;
302        self.format = format;
303        self.path_input.set_value(path.display().to_string());
304    }
305
306    pub fn close(&mut self) {
307        self.focus = ChartExportFocus::PathInput;
308        self.path_input.clear();
309        self.error = None;
310    }
311
312    /// ←/→ on a choice.
313    pub fn step(&mut self, field: ChartExportFocus, delta: i8) {
314        use crate::app::form::step_value;
315        match field {
316            ChartExportFocus::Format => {
317                self.format = step_value(&ChartExportFormat::ALL, self.format, delta)
318            }
319            ChartExportFocus::Style => {
320                self.style = step_value(&ExportStyle::ALL, self.style, delta)
321            }
322            ChartExportFocus::Size => {
323                self.size = step_value(&SizePreset::ALL, self.size, delta);
324                self.apply_size();
325            }
326            ChartExportFocus::Legend => {
327                self.legend = step_value(&LegendPlace::ALL, self.legend, delta)
328            }
329            ChartExportFocus::PointOpacity => {
330                self.point_opacity = step_value(&PointOpacity::ALL, self.point_opacity, delta)
331            }
332            ChartExportFocus::PointSize => {
333                self.point_size = step_value(&PointSize::ALL, self.point_size, delta)
334            }
335            ChartExportFocus::LineWidth => {
336                self.line_width = step_value(&LineWidth::ALL, self.line_width, delta)
337            }
338            ChartExportFocus::YFromZero => self.y_from_zero = !self.y_from_zero,
339            ChartExportFocus::Recipe => self.recipe = !self.recipe,
340            _ => {}
341        }
342    }
343
344    /// Fill the width and height from the preset; a custom size keeps what is typed.
345    fn apply_size(&mut self) {
346        if let Some((w, h)) = self.size.size() {
347            self.width_input.set_value(w.to_string());
348            self.height_input.set_value(h.to_string());
349            self.dpi = self.size.dpi();
350        }
351    }
352
353    /// A width or height typed: the size is custom from then on.
354    pub fn size_typed(&mut self) {
355        if self.size.size() != Some(self.export_dimensions()) {
356            self.size = SizePreset::Custom;
357        }
358    }
359
360    /// A text field's input; `None` for a choice.
361    pub fn input(&self, field: ChartExportFocus) -> Option<&TextInput> {
362        Some(match field {
363            ChartExportFocus::PathInput => &self.path_input,
364            ChartExportFocus::WidthInput => &self.width_input,
365            ChartExportFocus::HeightInput => &self.height_input,
366            ChartExportFocus::TitleInput => &self.title_input,
367            ChartExportFocus::DescriptionInput => &self.description_input,
368            ChartExportFocus::NotesInput => &self.notes_input,
369            ChartExportFocus::SourceInput => &self.source_input,
370            ChartExportFocus::BylineInput => &self.byline_input,
371            _ => return None,
372        })
373    }
374
375    /// [`Self::input`], to edit.
376    pub fn input_mut(&mut self, field: ChartExportFocus) -> Option<&mut TextInput> {
377        Some(match field {
378            ChartExportFocus::PathInput => &mut self.path_input,
379            ChartExportFocus::WidthInput => &mut self.width_input,
380            ChartExportFocus::HeightInput => &mut self.height_input,
381            ChartExportFocus::TitleInput => &mut self.title_input,
382            ChartExportFocus::DescriptionInput => &mut self.description_input,
383            ChartExportFocus::NotesInput => &mut self.notes_input,
384            ChartExportFocus::SourceInput => &mut self.source_input,
385            ChartExportFocus::BylineInput => &mut self.byline_input,
386            _ => return None,
387        })
388    }
389
390    /// The width and height typed, each 16 to 8,192 px; the preset's where a field
391    /// does not read as a number.
392    pub fn export_dimensions(&self) -> (u32, u32) {
393        const MIN: u32 = 16;
394        const MAX: u32 = 8192;
395        let (dw, dh) = self.size.size().unwrap_or((1600, 1000));
396        let read = |input: &TextInput, default: u32| {
397            input
398                .value()
399                .trim()
400                .parse::<u32>()
401                .map(|n| n.clamp(MIN, MAX))
402                .unwrap_or(default)
403        };
404        (read(&self.width_input, dw), read(&self.height_input, dh))
405    }
406}
407
408impl crate::app::form::Form for ChartExportModal {
409    type Field = ChartExportFocus;
410
411    fn fields(&self) -> Vec<(ChartExportFocus, crate::app::form::FieldKind)> {
412        use crate::app::form::FieldKind::{Choice, Text};
413        self.shown()
414            .into_iter()
415            .map(|f| {
416                let kind = if f.is_text() { Text } else { Choice };
417                (f, kind)
418            })
419            .collect()
420    }
421
422    fn focused(&self) -> ChartExportFocus {
423        self.focus
424    }
425
426    fn set_focused(&mut self, field: ChartExportFocus) {
427        self.focus = field;
428    }
429}
430
431impl Default for ChartExportModal {
432    fn default() -> Self {
433        let size = SizePreset::Document;
434        let (w, h) = size.size().unwrap_or((1600, 1000));
435        let mut width_input = TextInput::new();
436        width_input.set_value(w.to_string());
437        let mut height_input = TextInput::new();
438        height_input.set_value(h.to_string());
439        Self {
440            focus: ChartExportFocus::PathInput,
441            format: ChartExportFormat::Png,
442            style: ExportStyle::Light,
443            size,
444            dpi: size.dpi(),
445            legend: LegendPlace::LineEnds,
446            mark: Mark::default(),
447            point_opacity: PointOpacity::default(),
448            point_size: PointSize::default(),
449            line_width: LineWidth::default(),
450            y_from_zero: false,
451            path_input: TextInput::new(),
452            width_input,
453            height_input,
454            title_input: TextInput::new(),
455            description_input: TextInput::new(),
456            notes_input: TextInput::new(),
457            source_input: TextInput::new(),
458            byline_input: TextInput::new(),
459            recipe: true,
460            restore: None,
461            error: None,
462        }
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use crate::app::form::Form;
470
471    fn opened() -> ChartExportModal {
472        let config = crate::config::AppConfig::default();
473        let theme = crate::config::Theme::from_config(&config.theme).unwrap();
474        let mut modal = ChartExportModal::new();
475        modal.open(
476            &theme,
477            10,
478            ExportDefaults {
479                description: "Mean by month".to_string(),
480                source: "NYC flights · CC0".to_string(),
481                legend: false,
482                mark: Mark::Line,
483                y_from_zero: false,
484            },
485        );
486        modal
487    }
488
489    #[test]
490    fn presets_set_the_size_and_typing_makes_it_custom() {
491        let mut modal = opened();
492        assert_eq!(modal.export_dimensions(), (1600, 1000));
493        modal.step(ChartExportFocus::Size, -1);
494        assert_eq!(modal.size, SizePreset::Slide);
495        assert_eq!(modal.export_dimensions(), (1920, 1080));
496        modal.step(ChartExportFocus::Size, 3);
497        assert_eq!(modal.size, SizePreset::SingleColumn);
498        assert_eq!(modal.width_input.value(), "1050");
499        modal.width_input.set_value("800");
500        modal.size_typed();
501        assert_eq!(modal.size, SizePreset::Custom);
502        assert_eq!(modal.dpi, 300.0, "the column's resolution stays");
503        assert_eq!(modal.export_dimensions(), (800, 788));
504    }
505
506    #[test]
507    fn the_chart_fills_the_words_and_legend_off_carries_over() {
508        let modal = opened();
509        assert_eq!(modal.description_input.value(), "Mean by month");
510        assert_eq!(modal.source_input.value(), "NYC flights · CC0");
511        assert_eq!(modal.legend, LegendPlace::Off);
512        // A line takes its width and Y from zero, not the points' rows.
513        assert_eq!(modal.fields().len(), FIELDS.len() - 2);
514    }
515
516    /// A text field has an input and no choice; every other field, a choice.
517    #[test]
518    fn a_field_is_text_or_a_choice() {
519        let mut modal = opened();
520        for (field, _) in FIELDS {
521            assert_eq!(modal.choice(field).is_none(), field.is_text(), "{field:?}");
522            assert_eq!(
523                modal.input_mut(field).is_some(),
524                field.is_text(),
525                "{field:?}"
526            );
527        }
528    }
529
530    /// A mark row shows only for the chart types it changes, so focus never lands
531    /// on one that would do nothing.
532    #[test]
533    fn mark_rows_follow_the_chart_type() {
534        use ChartExportFocus::*;
535        let mut modal = opened();
536        let has = |modal: &ChartExportModal, f| modal.fields().iter().any(|(g, _)| *g == f);
537        assert!(has(&modal, LineWidth) && has(&modal, YFromZero));
538        assert!(!has(&modal, PointOpacity) && !has(&modal, PointSize));
539        modal.mark = Mark::Scatter;
540        assert!(has(&modal, PointOpacity) && has(&modal, PointSize));
541        assert!(!has(&modal, LineWidth) && !has(&modal, YFromZero));
542        assert_eq!(modal.y_from_zero_option(), None, "a scatter keeps its axis");
543        modal.mark = Mark::Histogram;
544        for f in [PointOpacity, PointSize, LineWidth, YFromZero] {
545            assert!(!has(&modal, f), "{f:?}");
546        }
547        modal.mark = Mark::Bar;
548        assert!(!has(&modal, YFromZero) && !has(&modal, LineWidth));
549        assert_eq!(modal.y_from_zero_option(), None, "bars start at zero");
550        modal.mark = Mark::Line;
551        modal.y_from_zero = true;
552        modal.step(YFromZero, 1);
553        assert_eq!(modal.y_from_zero_option(), Some(false));
554        modal.mark = Mark::Scatter;
555        assert_eq!(
556            modal.point_opacity,
557            crate::chart::chart_export::PointOpacity::Auto
558        );
559        modal.step(PointOpacity, 1);
560        assert_eq!(modal.choice(PointOpacity), Some("100%"));
561        modal.step(PointSize, -1);
562        assert_eq!(modal.choice(PointSize), Some("Small"));
563    }
564}