1use 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 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
59pub 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#[derive(Debug, Clone, Default)]
83pub struct ExportDefaults {
84 pub description: String,
86 pub source: String,
88 pub legend: bool,
90 pub mark: Mark,
92 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 pub dpi: f32,
105 pub legend: LegendPlace,
106 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 pub recipe: bool,
123 pub restore: Option<crate::view::SavedChartExport>,
125 pub error: Option<String>,
128}
129
130impl ChartExportModal {
131 pub fn new() -> Self {
132 Self::default()
133 }
134
135 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 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 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 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 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 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 pub fn shown(&self) -> Vec<ChartExportFocus> {
249 FIELDS.into_iter().filter(|f| self.shows(*f)).collect()
250 }
251
252 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 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 pub fn y_from_zero_option(&self) -> Option<bool> {
283 self.shows(ChartExportFocus::YFromZero)
284 .then_some(self.y_from_zero)
285 }
286
287 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 pub fn suspend(&mut self) {
306 self.active = false;
307 }
308
309 pub fn resume(&mut self) {
310 self.active = true;
311 }
312
313 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 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 pub fn size_typed(&mut self) {
356 if self.size.size() != Some(self.export_dimensions()) {
357 self.size = SizePreset::Custom;
358 }
359 }
360
361 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 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 assert_eq!(modal.fields().len(), FIELDS.len() - 2);
519 }
520
521 #[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}