use crate::ext::UiExt;
use egui::Context;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
Png,
Svg,
Clipboard,
Csv,
}
impl ExportFormat {
pub fn as_str(&self) -> &'static str {
match self {
Self::Png => "PNG",
Self::Svg => "SVG",
Self::Clipboard => "Clipboard",
Self::Csv => "CSV",
}
}
pub fn file_extension(&self) -> Option<&'static str> {
match self {
Self::Png => Some("png"),
Self::Svg => Some("svg"),
Self::Clipboard => None,
Self::Csv => Some("csv"),
}
}
pub fn is_data_format(&self) -> bool {
matches!(self, Self::Csv)
}
}
#[derive(Debug, Clone)]
#[must_use = "Export dialog response should be handled"]
pub enum ExportResponse {
Export {
config: ExportConfig,
save_path: Option<PathBuf>,
},
Closed,
None,
}
#[derive(Debug, Clone)]
pub struct ExportConfig {
pub format: ExportFormat,
pub include_title: bool,
pub include_timestamp: bool,
pub include_watermark: bool,
pub width: Option<u32>,
pub height: Option<u32>,
pub transparent_background: bool,
}
impl Default for ExportConfig {
fn default() -> Self {
Self {
format: ExportFormat::Png,
include_title: true,
include_timestamp: true,
include_watermark: false,
width: None,
height: None,
transparent_background: false,
}
}
}
pub struct ExportDialog {
is_open: bool,
config: ExportConfig,
save_path: Option<PathBuf>,
export_status: ExportStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExportStatus {
Idle,
Exporting,
Success,
Error(String),
}
impl Default for ExportDialog {
fn default() -> Self {
Self::new()
}
}
impl ExportDialog {
pub fn new() -> Self {
Self {
is_open: false,
config: ExportConfig::default(),
save_path: None,
export_status: ExportStatus::Idle,
}
}
pub fn open(&mut self) {
self.is_open = true;
self.export_status = ExportStatus::Idle;
}
pub fn close(&mut self) {
self.is_open = false;
}
pub fn is_open(&self) -> bool {
self.is_open
}
#[must_use = "Export dialog response should be handled"]
pub fn show(&mut self, ctx: &Context) -> ExportResponse {
if !self.is_open {
return ExportResponse::None;
}
let mut response = ExportResponse::None;
egui::Window::new("Export Chart")
.collapsible(false)
.resizable(false)
.show(ctx, |ui| {
self.draw_format_selection(ui);
self.draw_export_options(ui);
self.draw_size_options(ui);
self.draw_save_path(ui);
self.draw_status(ui);
response = self.draw_action_buttons(ui);
});
if matches!(response, ExportResponse::Export { .. }) {
self.close();
}
response
}
fn draw_format_selection(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label("Format:");
ui.radio_value(&mut self.config.format, ExportFormat::Png, "PNG");
ui.radio_value(&mut self.config.format, ExportFormat::Svg, "SVG");
ui.radio_value(&mut self.config.format, ExportFormat::Csv, "CSV");
ui.radio_value(
&mut self.config.format,
ExportFormat::Clipboard,
"Clipboard",
);
});
ui.separator();
}
fn draw_export_options(&mut self, ui: &mut egui::Ui) {
if self.config.format.is_data_format() {
ui.label("Exports visible OHLCV bar data and active indicator values.");
ui.separator();
return;
}
ui.checkbox(&mut self.config.include_title, "Include title");
ui.checkbox(&mut self.config.include_timestamp, "Include ts");
ui.checkbox(&mut self.config.include_watermark, "Include watermark");
if self.config.format == ExportFormat::Png {
ui.checkbox(
&mut self.config.transparent_background,
"Transparent background",
);
}
ui.separator();
}
fn draw_size_options(&mut self, ui: &mut egui::Ui) {
if self.config.format.is_data_format() {
return;
}
ui.label("Export Size:");
ui.horizontal(|ui| {
ui.radio_value(&mut self.config.width, None, "Current size");
if ui.radio(self.config.width.is_some(), "Custom").clicked() {
self.config.width = Some(1920);
self.config.height = Some(1080);
}
});
if let Some(width) = &mut self.config.width {
ui.horizontal(|ui| {
ui.label("Width:");
ui.add(egui::DragValue::new(width).range(100..=7680));
ui.label("Height:");
let height = self.config.height.get_or_insert(1080);
ui.add(egui::DragValue::new(height).range(100..=4320));
});
}
ui.separator();
}
fn draw_save_path(&mut self, ui: &mut egui::Ui) {
if self.config.format == ExportFormat::Clipboard {
return;
}
ui.horizontal(|ui| {
ui.label("Save to:");
if let Some(path) = &self.save_path {
ui.label(path.display().to_string());
} else {
ui.label("(Select location)");
}
if ui.button("Browse...").clicked() {
self.save_path = Some(PathBuf::from(format!(
"chart_export.{}",
self.config.format.file_extension().unwrap_or("png")
)));
}
});
ui.separator();
}
fn draw_status(&self, ui: &mut egui::Ui) {
match &self.export_status {
ExportStatus::Idle => {}
ExportStatus::Exporting => {
ui.spinner();
ui.label("Exporting...");
}
ExportStatus::Success => {
ui.success_label("Export successful!");
}
ExportStatus::Error(msg) => {
ui.error_label(format!("Error: {msg}"));
}
}
ui.separator();
}
fn draw_action_buttons(&mut self, ui: &mut egui::Ui) -> ExportResponse {
let mut response = ExportResponse::None;
ui.horizontal(|ui| {
if ui.button("Export").clicked() {
if self.config.format == ExportFormat::Clipboard || self.save_path.is_some() {
response = ExportResponse::Export {
config: self.config.clone(),
save_path: self.save_path.clone(),
};
self.export_status = ExportStatus::Exporting;
} else {
self.export_status =
ExportStatus::Error("Please select a save location".to_string());
}
}
if ui.button("Cancel").clicked() {
response = ExportResponse::Closed;
self.close();
}
});
response
}
pub fn mark_success(&mut self) {
self.export_status = ExportStatus::Success;
}
pub fn mark_error(&mut self, error: String) {
self.export_status = ExportStatus::Error(error);
}
pub fn reset_status(&mut self) {
self.export_status = ExportStatus::Idle;
}
}
pub struct ExportButton {
format: ExportFormat,
}
impl ExportButton {
pub fn new(format: ExportFormat) -> Self {
Self { format }
}
#[must_use = "Button click should be handled"]
pub fn show(self, ui: &mut egui::Ui) -> bool {
ui.button(format!("Export {}", self.format.as_str()))
.on_hover_text(format!("Export chart as {}", self.format.as_str()))
.clicked()
}
}
pub trait Exportable {
fn export(&self, config: &ExportConfig) -> Result<Vec<u8>, String>;
fn export_png(&self, width: u32, height: u32, transparent: bool) -> Result<Vec<u8>, String> {
self.export(&ExportConfig {
format: ExportFormat::Png,
width: Some(width),
height: Some(height),
transparent_background: transparent,
..Default::default()
})
}
fn export_svg(&self) -> Result<Vec<u8>, String> {
self.export(&ExportConfig {
format: ExportFormat::Svg,
..Default::default()
})
}
fn copy_to_clipboard(&self, _ctx: &Context) -> Result<(), String> {
let _bytes = self.export_png(1920, 1080, false)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_export_format_as_str() {
assert_eq!(ExportFormat::Png.as_str(), "PNG");
assert_eq!(ExportFormat::Svg.as_str(), "SVG");
assert_eq!(ExportFormat::Clipboard.as_str(), "Clipboard");
assert_eq!(ExportFormat::Csv.as_str(), "CSV");
}
#[test]
fn test_export_format_file_extension() {
assert_eq!(ExportFormat::Png.file_extension(), Some("png"));
assert_eq!(ExportFormat::Svg.file_extension(), Some("svg"));
assert_eq!(ExportFormat::Clipboard.file_extension(), None);
assert_eq!(ExportFormat::Csv.file_extension(), Some("csv"));
}
#[test]
fn test_export_format_is_data_format() {
assert!(!ExportFormat::Png.is_data_format());
assert!(!ExportFormat::Svg.is_data_format());
assert!(!ExportFormat::Clipboard.is_data_format());
assert!(ExportFormat::Csv.is_data_format());
}
#[test]
fn test_export_config_default() {
let config = ExportConfig::default();
assert_eq!(config.format, ExportFormat::Png);
assert!(config.include_title);
assert!(config.include_timestamp);
assert!(!config.include_watermark);
assert_eq!(config.width, None);
assert_eq!(config.height, None);
assert!(!config.transparent_background);
}
#[test]
fn test_export_dialog_creation() {
let dialog = ExportDialog::new();
assert!(!dialog.is_open());
assert_eq!(dialog.config.format, ExportFormat::Png);
}
#[test]
fn test_export_dialog_open_close() {
let mut dialog = ExportDialog::new();
assert!(!dialog.is_open());
dialog.open();
assert!(dialog.is_open());
dialog.close();
assert!(!dialog.is_open());
}
}