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}