1use 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 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 pub fn label(self) -> &'static str {
61 FIELDS
62 .iter()
63 .find(|(field, _)| *field == self)
64 .map_or("", |(_, label)| label)
65 }
66}
67
68pub 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#[derive(Debug, Clone, Default)]
92pub struct ExportDefaults {
93 pub description: String,
95 pub source: String,
97 pub legend: bool,
99 pub mark: Mark,
101 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 pub dpi: f32,
113 pub legend: LegendPlace,
114 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 pub recipe: bool,
131 pub restore: Option<crate::view::SavedChartExport>,
133 pub error: Option<String>,
136}
137
138impl ChartExportModal {
139 pub fn new() -> Self {
140 Self::default()
141 }
142
143 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 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 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 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 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 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 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 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 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 pub fn y_from_zero_option(&self) -> Option<bool> {
294 self.shows(ChartExportFocus::YFromZero)
295 .then_some(self.y_from_zero)
296 }
297
298 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 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 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 pub fn size_typed(&mut self) {
355 if self.size.size() != Some(self.export_dimensions()) {
356 self.size = SizePreset::Custom;
357 }
358 }
359
360 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 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 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 assert_eq!(modal.fields().len(), FIELDS.len() - 2);
514 }
515
516 #[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 #[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}