Skip to main content

egui_charts/ui/
export.rs

1//! Chart export functionality for screenshots and saving charts
2//!
3//! This module provides widgets and traits for exporting charts to multiple formats:
4//! - PNG images
5//! - CSV of the bar data and visible study plots
6//! - Clipboard copy
7//!
8//! # Example
9//!
10//! ```no_run
11//! use egui_charts::ui::export::{ExportDialog, ExportResponse};
12//!
13//! # fn update(ctx: &egui::Context) {
14//! let mut export_dialog = ExportDialog::new();
15//!
16//! // In your egui update loop
17//! match export_dialog.show(ctx) {
18//!     ExportResponse::Export { config, save_path } => {
19//!         // Trigger export with config
20//!         println!("Exporting to: {:?}", save_path);
21//!     }
22//!     ExportResponse::Closed => {
23//!         // User closed the dialog
24//!     }
25//!     ExportResponse::None => {
26//!         // Dialog is not open or no action taken
27//!     }
28//! }
29//! # }
30//! ```
31
32use crate::localization as l10n;
33use crate::theme::ThemeExt;
34use crate::ui_kit::FormGrid;
35use crate::ui_kit::dialog::{DialogFooter, dialog_frame, title_row};
36use crate::ui_kit::ext::UiExt;
37use egui::{Context, Id, Key, Modifiers, RichText};
38use std::path::PathBuf;
39
40/// Id of the file path field, so a failed export can focus it.
41fn path_field() -> Id {
42    Id::NULL.with("egui_charts::export_dialog::path")
43}
44
45/// Export format options
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ExportFormat {
48    /// PNG raster image format
49    Png,
50    /// SVG vector image format, from [`Chart::export_svg`](crate::widget::Chart::export_svg)
51    Svg,
52    /// Copy to system clipboard
53    Clipboard,
54    /// CSV export of loaded bars and visible study plots
55    Csv,
56}
57
58impl ExportFormat {
59    /// Returns the human-readable name of this format
60    pub fn as_str(&self) -> &'static str {
61        match self {
62            Self::Png => "PNG",
63            Self::Svg => "SVG",
64            Self::Clipboard => "Clipboard",
65            Self::Csv => "CSV",
66        }
67    }
68
69    /// Returns the file extension for this format (if applicable)
70    pub fn file_extension(&self) -> Option<&'static str> {
71        match self {
72            Self::Png => Some("png"),
73            Self::Svg => Some("svg"),
74            Self::Clipboard => None,
75            Self::Csv => Some("csv"),
76        }
77    }
78
79    /// Returns whether this format exports data (vs image/screenshot)
80    pub fn is_data_format(&self) -> bool {
81        matches!(self, Self::Csv)
82    }
83}
84
85/// Response from the export dialog
86///
87/// Use this to handle user actions from the export dialog.
88#[derive(Debug, Clone)]
89#[must_use = "Export dialog response should be handled"]
90pub enum ExportResponse {
91    /// User requested export with the given configuration
92    Export {
93        /// The export configuration chosen by the user
94        config: ExportConfig,
95        /// Optional save path (None for clipboard exports)
96        save_path: Option<PathBuf>,
97    },
98    /// User closed the dialog
99    Closed,
100    /// No action taken (dialog not open or still being configured)
101    None,
102}
103
104/// Export configuration for customizing chart exports
105///
106/// # Example
107///
108/// ```
109/// use egui_charts::ui::export::{ExportConfig, ExportFormat};
110///
111/// let config = ExportConfig {
112///     format: ExportFormat::Png,
113///     ..Default::default()
114/// };
115/// ```
116#[derive(Debug, Clone)]
117pub struct ExportConfig {
118    /// Export format (PNG, CSV or Clipboard)
119    pub format: ExportFormat,
120    /// Size, header and background options for PNG and clipboard exports.
121    pub image: crate::widget::ImageExportOptions,
122}
123
124impl Default for ExportConfig {
125    fn default() -> Self {
126        Self {
127            format: ExportFormat::Png,
128            image: Default::default(),
129        }
130    }
131}
132
133/// Export dialog for configuring and executing chart exports
134///
135/// The dialog offers:
136/// - Format selection (PNG, CSV, Clipboard)
137/// - Image dimensions, title, timestamp and transparent background
138/// - File save path entry
139///
140/// The dialog only collects the request; the host performs the export. Use
141/// [`with_formats`](Self::with_formats) to offer only the formats the host
142/// can produce. The dialog stays open after a request; report its result with
143/// [`mark_success`](Self::mark_success) or [`mark_error`](Self::mark_error).
144///
145/// # Example
146///
147/// ```no_run
148/// use egui_charts::ui::export::{ExportDialog, ExportResponse};
149///
150/// # fn update(ctx: &egui::Context) {
151/// let mut export_dialog = ExportDialog::new();
152///
153/// // Open the dialog
154/// export_dialog.open();
155///
156/// // In your egui update loop
157/// match export_dialog.show(ctx) {
158///     ExportResponse::Export { config, save_path } => {
159///         // Perform the actual export
160///         println!("Exporting with config: {:?}", config);
161///     }
162///     ExportResponse::Closed => {
163///         println!("Dialog closed");
164///     }
165///     ExportResponse::None => {}
166/// }
167/// # }
168/// ```
169pub struct ExportDialog {
170    is_open: bool,
171    config: ExportConfig,
172    formats: Vec<ExportFormat>,
173    save_path: String,
174    export_status: ExportStatus,
175}
176
177/// Internal status tracking for export operations
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum ExportStatus {
180    /// Ready to export
181    Idle,
182    /// Export in progress
183    Exporting,
184    /// Export completed successfully
185    Success,
186    /// Export failed with error message
187    Error(String),
188}
189
190impl Default for ExportDialog {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196impl ExportDialog {
197    /// Creates a new export dialog with default configuration
198    pub fn new() -> Self {
199        Self {
200            is_open: false,
201            config: ExportConfig::default(),
202            formats: vec![
203                ExportFormat::Png,
204                ExportFormat::Csv,
205                ExportFormat::Clipboard,
206            ],
207            save_path: String::new(),
208            export_status: ExportStatus::Idle,
209        }
210    }
211
212    /// Offers only `formats`, in the given order, for hosts that cannot
213    /// perform every export. Selects the first of them if the current format
214    /// is not offered.
215    pub fn with_formats(mut self, formats: &[ExportFormat]) -> Self {
216        self.formats = formats.to_vec();
217        if !self.formats.contains(&self.config.format)
218            && let Some(&first) = self.formats.first()
219        {
220            self.config.format = first;
221        }
222        self
223    }
224
225    /// Opens the export dialog
226    ///
227    /// This resets the export status to idle and shows the dialog.
228    pub fn open(&mut self) {
229        self.is_open = true;
230        self.export_status = ExportStatus::Idle;
231    }
232
233    /// Closes the export dialog
234    pub fn close(&mut self) {
235        self.is_open = false;
236    }
237
238    /// Returns whether the dialog is currently open
239    pub fn is_open(&self) -> bool {
240        self.is_open
241    }
242
243    /// Shows the export dialog and returns the user's action
244    ///
245    /// This displays a dialog with export configuration options.
246    /// Returns [`ExportResponse`] indicating what action the user took.
247    ///
248    /// # Example
249    ///
250    /// ```no_run
251    /// # use egui_charts::ui::export::{ExportDialog, ExportResponse};
252    /// # fn update(ctx: &egui::Context) {
253    /// let mut dialog = ExportDialog::new();
254    /// dialog.open();
255    ///
256    /// match dialog.show(ctx) {
257    ///     ExportResponse::Export { config, save_path } => {
258    ///         // Perform export
259    ///     }
260    ///     ExportResponse::Closed => {}
261    ///     ExportResponse::None => {}
262    /// }
263    /// # }
264    /// ```
265    #[must_use = "Export dialog response should be handled"]
266    pub fn show(&mut self, ctx: &Context) -> ExportResponse {
267        self.show_with(ctx, |_, _| {})
268    }
269
270    /// Shows the dialog with host controls below its title, such as an image scope chooser.
271    /// `content` receives the currently selected format; the host keeps its own control state.
272    #[must_use = "Export dialog response should be handled"]
273    pub fn show_with(
274        &mut self,
275        ctx: &Context,
276        mut content: impl FnMut(&mut egui::Ui, ExportFormat),
277    ) -> ExportResponse {
278        if !self.is_open {
279            return ExportResponse::None;
280        }
281
282        let mut response = ExportResponse::None;
283        // Escape and Enter belong to a focused widget or an open popup first. egui drops focus and
284        // closes popups on Escape before the frame runs, so their state is read from the previous
285        // frame. Only a fresh press acts: the repeats of a key held after it left a field or
286        // closed a popup must not go on to close the dialog.
287        let busy_id = Id::NULL.with("egui_charts::export_dialog::busy");
288        let busy = ctx.data(|d| d.get_temp::<bool>(busy_id)).unwrap_or(false);
289
290        // Centred horizontally like the crate's other dialogs, but movable. The top edge is the
291        // pivot, so the window grows downwards as its content changes instead of around its centre.
292        let r = ctx.content_rect();
293        let frame = dialog_frame(ctx);
294        egui::Window::new("Export Chart")
295            .title_bar(false)
296            .resizable(false)
297            .default_width(340.0 + frame.total_margin().sum().x)
298            .frame(frame)
299            .pivot(egui::Align2::CENTER_TOP)
300            .default_pos(egui::pos2(r.center().x, r.top() + r.height() * 0.2))
301            .show(ctx, |ui| response = self.draw_contents(ui, &mut content));
302
303        let consume = |key| {
304            ctx.input_mut(|i| {
305                let fresh = i.events.iter().any(|e| {
306                    matches!(e, egui::Event::Key { key: k, pressed: true, repeat: false, modifiers, .. }
307                        if *k == key && modifiers.is_none())
308                });
309                fresh && i.consume_key(Modifiers::NONE, key)
310            })
311        };
312        if matches!(response, ExportResponse::None) && !busy {
313            if consume(Key::Escape) {
314                response = ExportResponse::Closed;
315            } else if consume(Key::Enter) {
316                response = self.request_export(ctx);
317            }
318        }
319
320        let busy = ctx.memory(|m| m.focused().is_some()) || egui::Popup::is_any_open(ctx);
321        ctx.data_mut(|d| d.insert_temp(busy_id, busy));
322
323        if matches!(response, ExportResponse::Closed) {
324            self.close();
325        }
326        response
327    }
328
329    fn draw_contents(
330        &mut self,
331        ui: &mut egui::Ui,
332        content: &mut impl FnMut(&mut egui::Ui, ExportFormat),
333    ) -> ExportResponse {
334        let theme = ui.active_theme();
335        if title_row(ui, "Export chart", true) {
336            return ExportResponse::Closed;
337        }
338
339        content(ui, self.config.format);
340        let mut response = ExportResponse::None;
341        ui.add_enabled_ui(self.export_status != ExportStatus::Exporting, |ui| {
342            FormGrid::new("export_form").show(ui, |ui| {
343                let label = ui.label(l10n::text(ui.ctx(), "Format"));
344                ui.horizontal(|ui| {
345                    for &format in &self.formats {
346                        if ui
347                            .radio_value(
348                                &mut self.config.format,
349                                format,
350                                l10n::text(ui.ctx(), format.as_str()),
351                            )
352                            .changed()
353                        {
354                            self.export_status = ExportStatus::Idle;
355                        }
356                    }
357                })
358                .response
359                .labelled_by(label.id);
360                ui.end_row();
361
362                if !self.config.format.is_data_format() {
363                    ui.label(l10n::text(ui.ctx(), "Image size"));
364                    let mut changed = false;
365                    ui.horizontal(|ui| {
366                        changed |= ui
367                            .add(
368                                egui::DragValue::new(&mut self.config.image.size[0])
369                                    .range(100..=7680)
370                                    .suffix(l10n::text(ui.ctx(), " px")),
371                            )
372                            .changed();
373                        ui.label("×");
374                        changed |= ui
375                            .add(
376                                egui::DragValue::new(&mut self.config.image.size[1])
377                                    .range(100..=4320)
378                                    .suffix(l10n::text(ui.ctx(), " px")),
379                            )
380                            .changed();
381                    });
382                    ui.end_row();
383                    ui.label(l10n::text(ui.ctx(), "Include"));
384                    ui.vertical(|ui| {
385                        changed |= ui
386                            .checkbox(
387                                &mut self.config.image.include_title,
388                                l10n::text(ui.ctx(), "Chart title"),
389                            )
390                            .changed();
391                        changed |= ui
392                            .checkbox(
393                                &mut self.config.image.include_timestamp,
394                                l10n::text(ui.ctx(), "Export timestamp"),
395                            )
396                            .changed();
397                        changed |= ui
398                            .checkbox(
399                                &mut self.config.image.include_watermark,
400                                l10n::text(ui.ctx(), "Watermark"),
401                            )
402                            .changed();
403                        changed |= ui
404                            .checkbox(
405                                &mut self.config.image.include_crosshair,
406                                l10n::text(ui.ctx(), "Crosshair"),
407                            )
408                            .changed();
409                        changed |= ui
410                            .checkbox(
411                                &mut self.config.image.transparent_background,
412                                l10n::text(ui.ctx(), "Transparent background"),
413                            )
414                            .changed();
415                    });
416                    ui.end_row();
417                    if changed {
418                        self.reset_status();
419                    }
420                }
421
422                if self.config.format != ExportFormat::Clipboard {
423                    let hint = l10n::format_text(
424                        ui.ctx(),
425                        "File path, such as chart_export.{extension}",
426                        &[(
427                            "extension",
428                            self.config.format.file_extension().unwrap_or("png"),
429                        )],
430                    );
431                    let label = ui.label(l10n::text(ui.ctx(), "File"));
432                    let field = ui
433                        .add(
434                            egui::TextEdit::singleline(&mut self.save_path)
435                                .id(path_field())
436                                .hint_text(l10n::text(ui.ctx(), hint))
437                                .desired_width(f32::INFINITY),
438                        )
439                        .labelled_by(label.id);
440                    if field.changed() {
441                        self.reset_status();
442                    }
443                    if field.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) {
444                        response = self.request_export(ui.ctx());
445                    }
446                    ui.end_row();
447                }
448            });
449        });
450
451        if self.config.format.is_data_format() {
452            ui.add_space(theme.spacing.md);
453            ui.label(
454                RichText::new(l10n::text(ui.ctx(), "Exports loaded bars and visible study values, using the chart time zone and price precision."))
455                    .size(theme.text.small)
456                    .color(theme.ui.text_secondary),
457            );
458        }
459        self.draw_status(ui);
460
461        let (export, cancel) = DialogFooter::new("Export")
462            .primary_enabled(self.export_status != ExportStatus::Exporting)
463            .secondary("Cancel")
464            .show(ui);
465        if export {
466            response = self.request_export(ui.ctx());
467        } else if cancel {
468            response = ExportResponse::Closed;
469        }
470        response
471    }
472
473    fn draw_status(&self, ui: &mut egui::Ui) {
474        match &self.export_status {
475            ExportStatus::Idle => {}
476            ExportStatus::Exporting => {
477                ui.spinner();
478                ui.label(l10n::text(ui.ctx(), "Exporting..."));
479            }
480            ExportStatus::Success => {
481                if self.config.format == ExportFormat::Clipboard {
482                    ui.success_label("Export successful!");
483                } else {
484                    ui.success_label(l10n::format_text(
485                        ui.ctx(),
486                        "Export successful: {path}",
487                        &[("path", self.save_path.trim())],
488                    ));
489                }
490            }
491            ExportStatus::Error(msg) => {
492                ui.add_space(ui.active_theme().spacing.md);
493                ui.error_label(l10n::format_text(
494                    ui.ctx(),
495                    "Error: {message}",
496                    &[("message", msg.as_str())],
497                ));
498            }
499        }
500    }
501
502    /// The export response for the current choices, or `None` with an error status and the path
503    /// field focused when a file format has no path.
504    fn request_export(&mut self, ctx: &Context) -> ExportResponse {
505        if self.export_status == ExportStatus::Exporting {
506            return ExportResponse::None;
507        }
508        let to_clipboard = self.config.format == ExportFormat::Clipboard;
509        let path = self.save_path.trim();
510        if to_clipboard || !path.is_empty() {
511            self.export_status = ExportStatus::Exporting;
512            return ExportResponse::Export {
513                config: self.config.clone(),
514                save_path: (!to_clipboard).then(|| PathBuf::from(path)),
515            };
516        }
517        self.export_status = ExportStatus::Error("Please enter a save path".to_string());
518        ctx.memory_mut(|m| m.request_focus(path_field()));
519        ExportResponse::None
520    }
521
522    /// Marks the current export as successful
523    ///
524    /// Call this after completing an export to show success status in the dialog.
525    pub fn mark_success(&mut self) {
526        self.export_status = ExportStatus::Success;
527    }
528
529    /// Marks the current export as failed with an error message
530    ///
531    /// Call this if an export fails to show the error in the dialog.
532    ///
533    /// # Arguments
534    ///
535    /// * `error` - A description of what went wrong
536    pub fn mark_error(&mut self, error: String) {
537        self.export_status = ExportStatus::Error(error);
538    }
539
540    /// Resets the export status to idle
541    pub fn reset_status(&mut self) {
542        self.export_status = ExportStatus::Idle;
543    }
544}
545
546/// Quick export button widget for inline export actions
547///
548/// A simple button that can trigger exports without opening the full dialog.
549/// Useful for toolbar actions or quick access buttons.
550///
551/// # Example
552///
553/// ```no_run
554/// # use egui_charts::ui::export::{ExportButton, ExportFormat};
555/// # fn toolbar(ui: &mut egui::Ui) {
556/// if ExportButton::new(ExportFormat::Png).show(ui) {
557///     // Trigger PNG export
558///     println!("Export PNG clicked");
559/// }
560/// # }
561/// ```
562pub struct ExportButton {
563    format: ExportFormat,
564}
565
566impl ExportButton {
567    /// Creates a new export button for the specified format
568    pub fn new(format: ExportFormat) -> Self {
569        Self { format }
570    }
571
572    /// Shows the export button in the UI
573    ///
574    /// Returns `true` if the button was clicked.
575    #[must_use = "Button click should be handled"]
576    pub fn show(self, ui: &mut egui::Ui) -> bool {
577        ui.button(l10n::format_text(
578            ui.ctx(),
579            "Export {0}",
580            &[("0", self.format.as_str().to_string())],
581        ))
582        .on_hover_text(l10n::format_text(
583            ui.ctx(),
584            "Export chart as {0}",
585            &[("0", self.format.as_str().to_string())],
586        ))
587        .clicked()
588    }
589}
590
591/// Trait for implementing chart export functionality
592///
593/// Implement this trait on your chart widgets to enable export to multiple formats.
594///
595/// # Example
596///
597/// ```no_run
598/// use egui_charts::ui::export::{Exportable, ExportConfig, ExportFormat};
599///
600/// struct MyChart {
601///     // chart data
602/// }
603///
604/// impl Exportable for MyChart {
605///     fn export(&self, config: &ExportConfig) -> Result<Vec<u8>, String> {
606///         match config.format {
607///             ExportFormat::Png => {
608///                 // Render chart to PNG bytes
609///                 Ok(vec![])  // Replace with actual PNG bytes
610///             }
611///             ExportFormat::Svg => {
612///                 // Render chart to SVG text bytes
613///                 Ok(b"<svg/>".to_vec())
614///             }
615///             ExportFormat::Clipboard => {
616///                 // Same as PNG
617///                 self.export_png()
618///             }
619///             ExportFormat::Csv => {
620///                 // Export bar data as CSV bytes
621///                 Ok(b"time,open,high,low,close,volume\n".to_vec())
622///             }
623///         }
624///     }
625/// }
626/// ```
627pub trait Exportable {
628    /// Exports the chart with the given configuration
629    ///
630    /// # Arguments
631    ///
632    /// * `config` - Export configuration specifying the format
633    ///
634    /// # Returns
635    ///
636    /// * `Ok(Vec<u8>)` - The exported chart as raw bytes
637    /// * `Err(String)` - An error message if export fails
638    ///
639    /// # Errors
640    ///
641    /// Returns an error if:
642    /// - The format is not supported
643    /// - Rendering fails
644    /// - Memory allocation fails
645    fn export(&self, config: &ExportConfig) -> Result<Vec<u8>, String>;
646
647    /// Convenience method to export as PNG
648    fn export_png(&self) -> Result<Vec<u8>, String> {
649        self.export(&ExportConfig {
650            format: ExportFormat::Png,
651            ..Default::default()
652        })
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    #[test]
661    fn restricting_formats_selects_an_offered_format() {
662        let dialog = ExportDialog::new().with_formats(&[ExportFormat::Clipboard]);
663        assert_eq!(dialog.config.format, ExportFormat::Clipboard);
664    }
665
666    #[test]
667    fn export_results_stay_visible_until_closed() {
668        for (format, path, success) in [
669            (
670                ExportFormat::Png,
671                "chart.png",
672                "Export successful: chart.png",
673            ),
674            (ExportFormat::Csv, "bars.csv", "Export successful: bars.csv"),
675            (ExportFormat::Clipboard, "", "Export successful!"),
676        ] {
677            for (theme, width, error) in [
678                (crate::theme::Theme::light(), 800.0, false),
679                (crate::theme::Theme::dark(), 800.0, true),
680                (crate::theme::Theme::light(), 320.0, true),
681                (crate::theme::Theme::dark(), 320.0, false),
682            ] {
683                let ctx = Context::default();
684                crate::theme::apply_to_egui(&ctx, &theme);
685                let mut dialog = ExportDialog::new();
686                dialog.config.format = format;
687                dialog.save_path = format!("  {path}  ");
688                dialog.open();
689                let frame = |dialog: &mut ExportDialog, key: Option<Key>| {
690                    let input = egui::RawInput {
691                        screen_rect: Some(egui::Rect::from_min_size(
692                            egui::Pos2::ZERO,
693                            egui::vec2(width, 600.0),
694                        )),
695                        events: key
696                            .map(|key| egui::Event::Key {
697                                key,
698                                physical_key: None,
699                                pressed: true,
700                                repeat: false,
701                                modifiers: Modifiers::NONE,
702                            })
703                            .into_iter()
704                            .collect(),
705                        ..Default::default()
706                    };
707                    let mut response = ExportResponse::None;
708                    let output = ctx.run_ui(input, |ui| response = dialog.show(ui.ctx()));
709                    (response, output)
710                };
711                frame(&mut dialog, None).1.drop_without_applying_deltas();
712                let (response, output) = frame(&mut dialog, Some(Key::Enter));
713                output.drop_without_applying_deltas();
714                let ExportResponse::Export { save_path, .. } = response else {
715                    panic!("expected an export request for {format:?}");
716                };
717                assert_eq!(
718                    save_path,
719                    (format != ExportFormat::Clipboard).then(|| PathBuf::from(path))
720                );
721                assert!(
722                    dialog.is_open(),
723                    "submission must keep the result dialog open"
724                );
725                assert!(
726                    matches!(dialog.request_export(&ctx), ExportResponse::None),
727                    "a pending export must not be submitted again"
728                );
729                if error {
730                    dialog.mark_error("Permission denied".into());
731                } else {
732                    dialog.mark_success();
733                }
734                frame(&mut dialog, None).1.drop_without_applying_deltas();
735                let (response, output) = frame(&mut dialog, None);
736                assert!(matches!(response, ExportResponse::None));
737                let expected = if error {
738                    "Error: Permission denied"
739                } else {
740                    success
741                };
742                assert!(output.shapes.iter().any(|shape| {
743                    matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == expected)
744                }), "missing result: {expected}");
745                for shape in &output.shapes {
746                    if let egui::Shape::Text(text) = &shape.shape {
747                        let rect = text.galley.rect.translate(text.pos.to_vec2());
748                        assert!(
749                            rect.left() >= 0.0 && rect.right() <= width,
750                            "text outside {width}px window: {} at {rect:?}",
751                            text.galley.text()
752                        );
753                    }
754                }
755                output.drop_without_applying_deltas();
756                let (response, output) = frame(&mut dialog, Some(Key::Escape));
757                output.drop_without_applying_deltas();
758                assert!(matches!(response, ExportResponse::Closed));
759                assert!(!dialog.is_open());
760            }
761        }
762    }
763}