Skip to main content

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