Skip to main content

egui_charts/ui/dialogs/templates/
dialog.rs

1//! A named save form and catalog for study and drawing templates.
2
3use super::TemplateStore;
4use crate::localization as l10n;
5use crate::services::StoreKind;
6use crate::ui_kit::modal::Modal;
7use serde::{Serialize, de::DeserializeOwned};
8
9/// Template management opened by a host's study or drawing toolbar intents. The host supplies
10/// the current value, handles the returned loaded value and supplies durable storage if desired.
11pub struct TemplateDialog<T> {
12    /// Host-readable catalog and storage connection.
13    pub templates: TemplateStore<T>,
14    /// Whether the dialog is visible.
15    pub is_open: bool,
16    current: Option<T>,
17    name: String,
18}
19
20impl<T: Clone + Serialize + DeserializeOwned> TemplateDialog<T> {
21    /// A closed dialog in the given template namespace.
22    pub fn new(kind: StoreKind) -> Self {
23        Self {
24            templates: TemplateStore::new(kind),
25            is_open: false,
26            current: None,
27            name: String::new(),
28        }
29    }
30
31    /// Open the name form for a snapshot of the current host settings.
32    pub fn open(&mut self, current: T) {
33        self.current = Some(current);
34        self.name.clear();
35        self.is_open = true;
36    }
37
38    /// Open the catalog without a current value to save. Loading still returns a value for the host.
39    pub fn open_catalog(&mut self) {
40        self.current = None;
41        self.name.clear();
42        self.is_open = true;
43    }
44
45    /// Poll storage and show named Save/Replace, Load and Delete controls. A loaded value is
46    /// returned without mutating the host chart; cancellation never applies the pending snapshot.
47    pub fn show(&mut self, ctx: &egui::Context, title: &str) -> Option<T> {
48        self.templates.poll(ctx);
49        if !self.is_open {
50            return None;
51        }
52        let mut loaded = None;
53        let mut open = self.is_open;
54        Modal::new(title, title)
55            .width(440.0)
56            .show(ctx, &mut open, |ui| {
57                egui::ScrollArea::vertical()
58                    .max_height(ui.available_height())
59                    .show(ui, |ui| {
60                        let ready = self.templates.ready();
61                        if self.current.is_some() {
62                            ui.label(l10n::text(ui.ctx(), "Template name"));
63                            ui.add(
64                                egui::TextEdit::singleline(&mut self.name)
65                                    .desired_width(ui.available_width()),
66                            );
67                            let label = if self.templates.entries().contains_key(self.name.trim()) {
68                                "Replace"
69                            } else {
70                                "Save"
71                            };
72                            if ui
73                                .add_enabled(
74                                    ready && !self.name.trim().is_empty(),
75                                    egui::Button::new(l10n::text(ui.ctx(), label)),
76                                )
77                                .clicked()
78                                && let Some(current) = &self.current
79                                && let Err(error) = self.templates.save(&self.name, current.clone())
80                            {
81                                self.templates.error = Some(error);
82                            }
83                        }
84                        ui.separator();
85                        let names: Vec<_> = self.templates.entries().keys().cloned().collect();
86                        if names.is_empty() && ready {
87                            ui.label(l10n::text(ui.ctx(), "No saved templates"));
88                        }
89                        for name in names {
90                            ui.add(egui::Label::new(l10n::text(ui.ctx(), &name)).truncate())
91                                .on_hover_text(l10n::text(ui.ctx(), &name));
92                            ui.horizontal(|ui| {
93                                if ui
94                                    .add_enabled(
95                                        ready,
96                                        egui::Button::new(l10n::text(ui.ctx(), "Load")),
97                                    )
98                                    .clicked()
99                                {
100                                    loaded = self.templates.entries().get(&name).cloned();
101                                }
102                                if ui
103                                    .add_enabled(
104                                        ready,
105                                        egui::Button::new(l10n::text(ui.ctx(), "Delete")),
106                                    )
107                                    .clicked()
108                                    && let Err(error) = self.templates.delete(&name)
109                                {
110                                    self.templates.error = Some(error);
111                                }
112                            });
113                        }
114                        if let Some(error) = &self.templates.error {
115                            ui.colored_label(
116                                ui.visuals().error_fg_color,
117                                l10n::text(ui.ctx(), error),
118                            );
119                            if !self.templates.ready()
120                                && ui
121                                    .button(l10n::text(ui.ctx(), "Retry loading templates"))
122                                    .clicked()
123                            {
124                                self.templates.reload();
125                            }
126                        } else if !ready {
127                            ui.label(l10n::text(ui.ctx(), "Loading templates…"));
128                        }
129                    });
130            });
131        self.is_open = open && loaded.is_none();
132        loaded
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn catalog_actions_remain_visible_with_long_names_in_short_and_narrow_windows() {
142        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
143            for size in [egui::vec2(1440.0, 900.0), egui::vec2(420.0, 360.0)] {
144                let ctx = egui::Context::default();
145                crate::theme::apply_to_egui(&ctx, &theme);
146                let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
147                let mut dialog = TemplateDialog::new(StoreKind::StudyTemplates);
148                for index in 0..20 {
149                    dialog
150                        .templates
151                        .save(&format!("{index:02} {}", "Long name ".repeat(20)), index)
152                        .unwrap();
153                }
154                dialog.open(42);
155                for frame in 0..3 {
156                    let output = ctx.run_ui(
157                        egui::RawInput {
158                            screen_rect: Some(screen),
159                            ..Default::default()
160                        },
161                        |ui| {
162                            dialog.show(ui.ctx(), "Study templates");
163                        },
164                    );
165                    let shapes = output.shapes.clone();
166                    output.drop_without_applying_deltas();
167                    if frame > 0 {
168                        let area = ctx
169                            .memory(|memory| memory.area_rect(egui::Id::unique("Study templates")))
170                            .unwrap();
171                        assert!(
172                            screen.shrink(23.0).contains_rect(area),
173                            "{size:?}: {area:?}"
174                        );
175                        for label in ["Save", "Load", "Delete"] {
176                            let (clip, rect) = shapes
177                                .iter()
178                                .find_map(|shape| match &shape.shape {
179                                    egui::Shape::Text(text) if text.galley.text() == label => {
180                                        Some((
181                                            shape.clip_rect,
182                                            egui::Rect::from_min_size(text.pos, text.galley.size()),
183                                        ))
184                                    }
185                                    _ => None,
186                                })
187                                .expect("catalog action is painted");
188                            assert!(
189                                screen.contains_rect(rect) && clip.contains_rect(rect),
190                                "{label}: {rect:?}"
191                            );
192                        }
193                    }
194                }
195            }
196        }
197    }
198}