egui_charts/ui/dialogs/indicator_dialog/
templates.rs1use 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#[derive(Debug, Clone)]
20#[non_exhaustive]
21pub enum IndicatorTemplateAction {
22 None,
24 Save {
26 name: String,
28 description: String,
30 },
31 Load {
33 template_id: String,
35 },
36 Delete {
38 template_id: String,
40 },
41 Rename {
43 template_id: String,
45 new_name: String,
47 },
48 ToggleShared {
50 template_id: String,
52 is_shared: bool,
54 },
55}
56
57#[derive(Debug, Clone, Default)]
63pub struct IndicatorTemplateState {
64 pub templates: Vec<IndicatorTemplate>,
66 pub selected_template: Option<String>,
68 pub is_loading: bool,
70 pub show_save_form: bool,
72 pub save_name: String,
74 pub save_description: String,
76 pub pending_delete: Option<String>,
78 pub show_shared: bool,
80}
81
82impl IndicatorTemplateState {
83 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 pub fn cancel_delete(&mut self) {
92 self.pending_delete = None;
93 }
94
95 pub fn set_templates(&mut self, templates: Vec<IndicatorTemplate>) {
97 self.templates = templates;
98 self.is_loading = false;
99 }
100}
101
102pub struct IndicatorTemplatePanel<'a> {
111 state: &'a mut IndicatorTemplateState,
112 indicator_type: &'a str,
113}
114
115impl<'a> IndicatorTemplatePanel<'a> {
116 pub fn new(state: &'a mut IndicatorTemplateState, indicator_type: &'a str) -> Self {
118 Self {
119 state,
120 indicator_type,
121 }
122 }
123
124 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 action = self.draw_filter_toggle(ui, action);
133 ui.spaced_separator();
134
135 action = self.draw_save_section(ui, action);
137 ui.spaced_separator();
138
139 action = self.draw_template_list(ui, action);
141
142 action
143 }
144
145 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 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 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 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 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 if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
295 self.state.reset_save_form();
296 }
297
298 action
299 }
300
301 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 self.draw_item_bg(ui, rect, &response, is_selected);
382
383 self.draw_item_content(ui, rect, template, is_selected);
385
386 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 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 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 return Some(IndicatorTemplateAction::Load {
458 template_id: template.id.clone(),
459 });
460 }
461 self.state.selected_template = Some(template.id.clone());
462 }
463
464 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 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}