Skip to main content

egui_charts/ui/dialogs/indicator_dialog/
templates.rs

1//! Indicator template management UI
2//!
3//! Provides the template panel within the indicator dialog for saving,
4//! loading, and managing indicator parameter templates.
5
6use crate::localization as l10n;
7use crate::theme::ThemeExt;
8use crate::tokens::DESIGN_TOKENS;
9use crate::ui::model::IndicatorTemplate;
10use crate::ui_kit::ext::UiExt;
11use crate::ui_kit::{EmptyState, LoadingIndicator};
12use egui::{Color32, Pos2, Rect, RichText, Sense, Ui, Vec2};
13
14// =============================================================================
15// Actions
16// =============================================================================
17
18/// Actions emitted by the indicator template panel
19#[derive(Debug, Clone)]
20#[non_exhaustive]
21pub enum IndicatorTemplateAction {
22    /// No action taken
23    None,
24    /// Save current indicator parameters as a new template
25    Save {
26        /// Display name.
27        name: String,
28        /// Description displayed to the user.
29        description: String,
30    },
31    /// Load a template's parameters into the current indicator
32    Load {
33        /// Host identifier of the template.
34        template_id: String,
35    },
36    /// Delete a template
37    Delete {
38        /// Host identifier of the template.
39        template_id: String,
40    },
41    /// Rename a template
42    Rename {
43        /// Host identifier of the template.
44        template_id: String,
45        /// Replacement display name.
46        new_name: String,
47    },
48    /// Toggle shared/personal visibility
49    ToggleShared {
50        /// Host identifier of the template.
51        template_id: String,
52        /// Requested sharing state; the host manages permissions.
53        is_shared: bool,
54    },
55}
56
57// =============================================================================
58// State
59// =============================================================================
60
61/// State for the indicator template panel
62#[derive(Debug, Clone, Default)]
63pub struct IndicatorTemplateState {
64    /// Available templates for the current indicator type
65    pub templates: Vec<IndicatorTemplate>,
66    /// Currently selected template (for preview/actions)
67    pub selected_template: Option<String>,
68    /// Whether the host is loading the template list.
69    pub is_loading: bool,
70    /// Whether the save form is visible
71    pub show_save_form: bool,
72    /// Name input for saving a new template
73    pub save_name: String,
74    /// Description input for saving a new template
75    pub save_description: String,
76    /// Template pending deletion (for confirmation)
77    pub pending_delete: Option<String>,
78    /// Whether to show shared templates
79    pub show_shared: bool,
80}
81
82impl IndicatorTemplateState {
83    /// Reset the save form fields
84    pub fn reset_save_form(&mut self) {
85        self.show_save_form = false;
86        self.save_name.clear();
87        self.save_description.clear();
88    }
89
90    /// Cancel pending deletion
91    pub fn cancel_delete(&mut self) {
92        self.pending_delete = None;
93    }
94
95    /// Replace the template list supplied by the host and clear the loading state.
96    pub fn set_templates(&mut self, templates: Vec<IndicatorTemplate>) {
97        self.templates = templates;
98        self.is_loading = false;
99    }
100}
101
102// =============================================================================
103// Panel
104// =============================================================================
105
106/// Indicator template management panel
107///
108/// Renders inside the indicator dialog's configuration area.
109/// Shows template list, save form, and action buttons.
110pub struct IndicatorTemplatePanel<'a> {
111    state: &'a mut IndicatorTemplateState,
112    indicator_type: &'a str,
113}
114
115impl<'a> IndicatorTemplatePanel<'a> {
116    /// Create a new template panel for the given indicator type
117    pub fn new(state: &'a mut IndicatorTemplateState, indicator_type: &'a str) -> Self {
118        Self {
119            state,
120            indicator_type,
121        }
122    }
123
124    /// Show the template panel and return any action
125    pub fn show(&mut self, ui: &mut Ui) -> IndicatorTemplateAction {
126        let mut action = IndicatorTemplateAction::None;
127
128        self.draw_header(ui);
129        ui.space_sm();
130
131        // Shared/personal toggle
132        action = self.draw_filter_toggle(ui, action);
133        ui.spaced_separator();
134
135        // Save current button
136        action = self.draw_save_section(ui, action);
137        ui.spaced_separator();
138
139        // Template list
140        action = self.draw_template_list(ui, action);
141
142        action
143    }
144
145    // =========================================================================
146    // Header
147    // =========================================================================
148
149    fn draw_header(&self, ui: &mut Ui) {
150        ui.horizontal(|ui| {
151            ui.label(
152                RichText::new(l10n::text(ui.ctx(), "Templates"))
153                    .size(ui.active_theme().text.body)
154                    .strong(),
155            );
156            ui.label(
157                RichText::new(format!("({})", self.indicator_type))
158                    .size(ui.active_theme().text.small)
159                    .color(ui.visuals().weak_text_color()),
160            );
161        });
162    }
163
164    // =========================================================================
165    // Filter toggle
166    // =========================================================================
167
168    fn draw_filter_toggle(
169        &mut self,
170        ui: &mut Ui,
171        current_action: IndicatorTemplateAction,
172    ) -> IndicatorTemplateAction {
173        ui.horizontal(|ui| {
174            let personal_label = if self.state.show_shared {
175                "Personal"
176            } else {
177                "> Personal"
178            };
179            let shared_label = if self.state.show_shared {
180                "> Shared"
181            } else {
182                "Shared"
183            };
184
185            if ui
186                .selectable_label(
187                    !self.state.show_shared,
188                    l10n::text(ui.ctx(), personal_label),
189                )
190                .clicked()
191            {
192                self.state.show_shared = false;
193            }
194            if ui
195                .selectable_label(self.state.show_shared, l10n::text(ui.ctx(), shared_label))
196                .clicked()
197            {
198                self.state.show_shared = true;
199            }
200        });
201        current_action
202    }
203
204    // =========================================================================
205    // Save section
206    // =========================================================================
207
208    fn draw_save_section(
209        &mut self,
210        ui: &mut Ui,
211        current_action: IndicatorTemplateAction,
212    ) -> IndicatorTemplateAction {
213        let mut action = current_action;
214
215        if !self.state.show_save_form {
216            if ui
217                .button(l10n::text(ui.ctx(), "Save Current as Template..."))
218                .clicked()
219            {
220                self.state.show_save_form = true;
221                self.state.save_name.clear();
222                self.state.save_description.clear();
223                ui.data_mut(|d| d.insert_temp(ui.scope_id().with("focus_template_name"), true));
224            }
225            return action;
226        }
227
228        action = self.draw_save_form(ui);
229        action
230    }
231
232    fn draw_save_form(&mut self, ui: &mut Ui) -> IndicatorTemplateAction {
233        let mut action = IndicatorTemplateAction::None;
234
235        ui.label(
236            RichText::new(l10n::text(ui.ctx(), "Save Template"))
237                .size(ui.active_theme().text.body)
238                .strong(),
239        );
240        ui.space_sm();
241
242        let label = ui.label(l10n::text(ui.ctx(), "Name:"));
243        let name_response = ui
244            .text_edit_singleline(&mut self.state.save_name)
245            .labelled_by(label.id);
246
247        // Auto-focus the name field when save form opens
248        if ui
249            .data_mut(|d| d.remove_temp::<bool>(ui.scope_id().with("focus_template_name")))
250            .unwrap_or(false)
251        {
252            name_response.request_focus();
253        }
254
255        ui.space_sm();
256        let label = ui.label(l10n::text(ui.ctx(), "Description (optional):"));
257        ui.text_edit_singleline(&mut self.state.save_description)
258            .labelled_by(label.id);
259
260        ui.space_lg();
261
262        let can_save = !self.state.save_name.trim().is_empty();
263
264        ui.horizontal(|ui| {
265            if ui
266                .add_enabled(can_save, egui::Button::new(l10n::text(ui.ctx(), "Save")))
267                .clicked()
268            {
269                action = IndicatorTemplateAction::Save {
270                    name: self.state.save_name.trim().to_string(),
271                    description: self.state.save_description.trim().to_string(),
272                };
273                self.state.reset_save_form();
274            }
275
276            if ui.button(l10n::text(ui.ctx(), "Cancel")).clicked() {
277                self.state.reset_save_form();
278            }
279        });
280
281        // Enter key to confirm
282        if (name_response.has_focus() || ui.memory(|m| m.had_focus_last_frame(name_response.id)))
283            && ui.input(|i| i.key_pressed(egui::Key::Enter))
284            && can_save
285        {
286            action = IndicatorTemplateAction::Save {
287                name: self.state.save_name.trim().to_string(),
288                description: self.state.save_description.trim().to_string(),
289            };
290            self.state.reset_save_form();
291        }
292
293        // Escape key to cancel
294        if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
295            self.state.reset_save_form();
296        }
297
298        action
299    }
300
301    // =========================================================================
302    // Template list
303    // =========================================================================
304
305    fn draw_template_list(
306        &mut self,
307        ui: &mut Ui,
308        current_action: IndicatorTemplateAction,
309    ) -> IndicatorTemplateAction {
310        let mut action = current_action;
311
312        if self.state.is_loading {
313            LoadingIndicator::new()
314                .message("Loading templates...")
315                .show(ui);
316            return action;
317        }
318
319        let filtered: Vec<IndicatorTemplate> = self
320            .state
321            .templates
322            .iter()
323            .filter(|t| t.is_shared == self.state.show_shared)
324            .cloned()
325            .collect();
326
327        if filtered.is_empty() {
328            let msg = if self.state.show_shared {
329                "No shared templates"
330            } else {
331                "No saved templates"
332            };
333            EmptyState::new(msg).show(ui);
334            return action;
335        }
336
337        egui::ScrollArea::vertical()
338            .max_height(ui.available_height())
339            .show(ui, |ui| {
340                for template in &filtered {
341                    if let Some(a) = self.draw_template_item(ui, template) {
342                        action = a;
343                    }
344                }
345            });
346
347        action
348    }
349
350    fn draw_template_item(
351        &mut self,
352        ui: &mut Ui,
353        template: &IndicatorTemplate,
354    ) -> Option<IndicatorTemplateAction> {
355        let item_height = DESIGN_TOKENS.sizing.button_lg;
356        let desired_size = Vec2::new(
357            ui.available_width() - ui.active_theme().spacing.lg,
358            item_height,
359        );
360        let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
361        crate::styles::focus::draw_focus_ring(ui, &response, ui.active_theme().radius.md);
362
363        let is_selected = self
364            .state
365            .selected_template
366            .as_ref()
367            .is_some_and(|id| id == &template.id);
368        let translated_label = l10n::text(ui.ctx(), &template.name);
369        response.widget_info(|| {
370            egui::WidgetInfo::labeled(
371                egui::Role::ListBoxOption,
372                response.enabled(),
373                &translated_label,
374            )
375        });
376
377        ui.ctx()
378            .accesskit_node_builder(response.id, |node| node.set_selected(is_selected));
379
380        // Background
381        self.draw_item_bg(ui, rect, &response, is_selected);
382
383        // Text content
384        self.draw_item_content(ui, rect, template, is_selected);
385
386        // Handle selection and actions
387        self.handle_item_interaction(template, &response, is_selected)
388    }
389
390    fn draw_item_bg(&self, ui: &mut Ui, rect: Rect, response: &egui::Response, is_selected: bool) {
391        if response.hovered() || is_selected {
392            let bg = if is_selected {
393                ui.visuals().selection.bg_fill
394            } else {
395                ui.visuals().widgets.hovered.bg_fill
396            };
397            ui.painter()
398                .rect_filled(rect, ui.active_theme().radius.md, bg);
399        }
400    }
401
402    fn draw_item_content(
403        &self,
404        ui: &mut Ui,
405        rect: Rect,
406        template: &IndicatorTemplate,
407        is_selected: bool,
408    ) {
409        let text_x = rect.min.x + ui.active_theme().spacing.lg;
410        let visuals = ui.visuals();
411        let (text, weak) = if is_selected {
412            let on_accent = visuals.selection.stroke.color;
413            (on_accent, on_accent)
414        } else {
415            (visuals.text_color(), visuals.weak_text_color())
416        };
417
418        // Template name
419        ui.painter().text(
420            Pos2::new(text_x, rect.min.y + ui.active_theme().spacing.xl),
421            egui::Align2::LEFT_CENTER,
422            l10n::text(ui.ctx(), &template.name),
423            egui::FontId::proportional(ui.active_theme().text.body),
424            text,
425        );
426
427        // Parameter summary and shared badge
428        let summary = template.parameter_summary();
429        let suffix = if template.is_shared {
430            l10n::format_text(
431                ui.ctx(),
432                "{summary} [shared]",
433                &[("summary", summary.as_str())],
434            )
435        } else {
436            summary
437        };
438
439        ui.painter().text(
440            Pos2::new(text_x, rect.min.y + DESIGN_TOKENS.sizing.button_sm),
441            egui::Align2::LEFT_CENTER,
442            l10n::text(ui.ctx(), &suffix),
443            egui::FontId::proportional(ui.active_theme().text.tiny),
444            weak,
445        );
446    }
447
448    fn handle_item_interaction(
449        &mut self,
450        template: &IndicatorTemplate,
451        response: &egui::Response,
452        is_selected: bool,
453    ) -> Option<IndicatorTemplateAction> {
454        if response.clicked() {
455            if is_selected {
456                // Double-click loads the template
457                return Some(IndicatorTemplateAction::Load {
458                    template_id: template.id.clone(),
459                });
460            }
461            self.state.selected_template = Some(template.id.clone());
462        }
463
464        // Context menu (right-click on desktop)
465        let mut action = None;
466        response.context_menu(|ui| {
467            if ui.button(l10n::text(ui.ctx(), "Load")).clicked() {
468                action = Some(IndicatorTemplateAction::Load {
469                    template_id: template.id.clone(),
470                });
471                ui.close();
472            }
473
474            let share_label = if template.is_shared {
475                "Make Personal"
476            } else {
477                "Share with Team"
478            };
479            if ui.button(l10n::text(ui.ctx(), share_label)).clicked() {
480                action = Some(IndicatorTemplateAction::ToggleShared {
481                    template_id: template.id.clone(),
482                    is_shared: !template.is_shared,
483                });
484                ui.close();
485            }
486
487            ui.separator();
488
489            // Delete with confirmation
490            if self.state.pending_delete.as_ref() == Some(&template.id) {
491                ui.colored_label(
492                    Color32::from_rgb(239, 83, 80),
493                    l10n::text(ui.ctx(), "Confirm delete?"),
494                );
495                ui.horizontal(|ui| {
496                    if ui.button(l10n::text(ui.ctx(), "Yes")).clicked() {
497                        action = Some(IndicatorTemplateAction::Delete {
498                            template_id: template.id.clone(),
499                        });
500                        self.state.pending_delete = None;
501                        ui.close();
502                    }
503                    if ui.button(l10n::text(ui.ctx(), "No")).clicked() {
504                        self.state.pending_delete = None;
505                        ui.close();
506                    }
507                });
508            } else if ui.button(l10n::text(ui.ctx(), "Delete")).clicked() {
509                self.state.pending_delete = Some(template.id.clone());
510            }
511        });
512
513        action
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn template_fields_release_focus_and_keyboard_cancel_does_not_save() {
523        let ctx = egui::Context::default();
524        let mut state = IndicatorTemplateState {
525            show_save_form: true,
526            save_name: "Example".into(),
527            ..Default::default()
528        };
529        let mut frame = |key: Option<egui::Key>| {
530            let mut action = IndicatorTemplateAction::None;
531            ctx.run_ui(
532                egui::RawInput {
533                    events: key
534                        .map(|key| egui::Event::Key {
535                            key,
536                            physical_key: None,
537                            pressed: true,
538                            repeat: false,
539                            modifiers: egui::Modifiers::NONE,
540                        })
541                        .into_iter()
542                        .collect(),
543                    ..Default::default()
544                },
545                |ui| {
546                    action = IndicatorTemplatePanel::new(&mut state, "Example").draw_save_form(ui);
547                },
548            )
549            .drop_without_applying_deltas();
550            (action, ctx.memory(|m| m.focused()))
551        };
552        frame(None);
553        let name = frame(Some(egui::Key::Tab)).1;
554        let description = frame(Some(egui::Key::Tab)).1;
555        assert_ne!(name, description);
556        frame(Some(egui::Key::Tab));
557        frame(Some(egui::Key::Tab));
558        assert!(matches!(
559            frame(Some(egui::Key::Enter)).0,
560            IndicatorTemplateAction::None
561        ));
562        assert!(!state.show_save_form);
563    }
564}