Skip to main content

egui_charts/ui/top_toolbar/components/
layout_menu.rs

1//! Named layouts, kept in the host's storage when it supplies one.
2
3use crate::config::KeyboardAction;
4use crate::data::FeedError;
5use crate::data::inbox::Inbox;
6use crate::icons::{Icon, icons};
7use crate::localization as l10n;
8use crate::services::{ShareKind, Sharing, Storage, StoreKind};
9use crate::theme::ThemeExt;
10use crate::ui::dialogs::keyboard_shortcuts::shortcut_key_labels;
11use crate::ui::share_controls::ShareControls;
12use crate::ui::top_toolbar::toolbar::DOWNLOAD_ICON;
13use crate::ui_kit::buttons::{ButtonSize, IconButton};
14use crate::ui_kit::list_item::{ContentContext, ListItem, ListItemContent};
15use crate::widget::SavedLayout;
16use egui::{Rect, Role, Ui, WidgetInfo, pos2, vec2};
17use std::collections::VecDeque;
18use std::sync::Arc;
19use std::time::Duration;
20
21/// Configuration for layout menu
22#[derive(Debug, Clone)]
23pub struct LayoutMenuConfig {
24    /// Menu width, in egui logical points.
25    pub menu_width: f32,
26}
27
28impl Default for LayoutMenuConfig {
29    fn default() -> Self {
30        Self { menu_width: 220.0 }
31    }
32}
33
34/// Action from layout menu
35#[derive(Debug, Clone, PartialEq)]
36#[non_exhaustive]
37pub enum LayoutAction {
38    /// No action requested.
39    None,
40    /// Select a tiled or tabbed chart arrangement.
41    SelectGrid(crate::widget::ChartLayoutMode),
42    /// Save the current layout under its name (Save layout, Ctrl+S).
43    Save,
44    /// Save the current layout under a new name, which becomes current (Save layout as).
45    SaveAs(String),
46    /// Export the displayed series through [`Chart::export_csv`](crate::Chart::export_csv).
47    DownloadData,
48    /// Open the host's saved-layout chooser.
49    OpenLayout,
50    /// Request a chart image download.
51    DownloadImage,
52    /// Request a new layout.
53    CreateNew,
54    /// Open the named layout.
55    Open(String),
56    /// Delete the named layout.
57    Delete(String),
58    /// Rename the current layout from the first name to the second.
59    Rename(String, String),
60}
61
62enum Request {
63    List,
64    Open(String),
65    Save(String, String),
66    /// Saving under the second name, then deleting the first.
67    Rename(String, String, String),
68    Delete(String),
69}
70
71/// Named layout menu. The host handles the returned intents, capturing a [`SavedLayout`] for
72/// each save. With [`set_storage`](Self::set_storage) the menu lists the layouts of
73/// [`StoreKind::Layouts`] and [`save`](Self::save), [`open`](Self::open),
74/// [`rename`](Self::rename) and [`delete`](Self::delete) keep them there; without it the host
75/// supplies `layouts` and keeps them itself.
76pub struct LayoutMenu {
77    /// Name of the current layout.
78    pub curr_layout: Option<String>,
79    /// The layouts available to open, sorted by name when read from storage.
80    pub layouts: Vec<String>,
81    /// Menu sizing.
82    pub config: LayoutMenuConfig,
83    /// Whether the current layout is saved after each change; see
84    /// [`autosave_due`](Self::autosave_due).
85    pub autosave: bool,
86    /// Names of the starred layouts. The menu stars and unstars them, moves a name on a rename
87    /// and drops it on a delete; keep it between sessions in
88    /// [`UserSettings::layout_favorites`](crate::services::UserSettings::layout_favorites).
89    pub favorites: Vec<String>,
90    favorites_only: bool,
91    editing: Option<bool>,
92    new_name: String,
93    storage: Option<Arc<dyn Storage>>,
94    inbox: Arc<Inbox>,
95    pending: Option<Request>,
96    requests: VecDeque<Request>,
97    failing: bool,
98    /// The edit count given to `autosave_due` last, and the one saved or opened.
99    edits: u64,
100    saved_edits: Option<u64>,
101    observed_layout: Option<String>,
102    /// A changed edit count with the input time it was first seen.
103    changed: Option<(u64, f64)>,
104    error: Option<String>,
105    share: ShareControls,
106}
107
108impl Default for LayoutMenu {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl LayoutMenu {
115    /// Creates an unnamed layout menu.
116    pub fn new() -> Self {
117        Self {
118            curr_layout: Some("Unnamed".into()),
119            layouts: Vec::new(),
120            config: LayoutMenuConfig::default(),
121            autosave: false,
122            favorites: Vec::new(),
123            favorites_only: false,
124            editing: None,
125            new_name: String::new(),
126            storage: None,
127            inbox: Arc::default(),
128            pending: None,
129            requests: VecDeque::new(),
130            failing: false,
131            edits: 0,
132            saved_edits: None,
133            observed_layout: None,
134            changed: None,
135            error: None,
136            share: ShareControls::default(),
137        }
138    }
139
140    /// How long the layout stays unchanged before [`autosave_due`](Self::autosave_due) asks
141    /// for a save.
142    pub const AUTOSAVE_DELAY: Duration = Duration::from_secs(2);
143
144    /// Keep layouts in `storage` and list them from it.
145    pub fn set_storage(&mut self, storage: Arc<dyn Storage>) {
146        self.inbox = Arc::default();
147        self.requests.clear();
148        self.pending = None;
149        self.layouts.clear();
150        self.saved_edits = None;
151        self.changed = None;
152        self.storage = Some(storage);
153        self.request(Request::List);
154    }
155
156    /// Offers copy-link and save-a-copy on the current stored layout, as `sharing` reports them.
157    /// A saved copy is read back from the [`Storage`] given to [`set_storage`](Self::set_storage),
158    /// where the host writes it.
159    pub fn set_sharing(&mut self, sharing: Arc<dyn Sharing>) {
160        self.share.set_service(sharing);
161    }
162
163    fn request(&mut self, request: Request) {
164        self.requests.push_back(request);
165        self.send_next();
166    }
167
168    fn send_next(&mut self) {
169        let Some(storage) = &self.storage else { return };
170        if self.pending.is_some() {
171            return;
172        }
173        let Some(request) = self.requests.pop_front() else {
174            return;
175        };
176        match &request {
177            Request::List => storage.list(StoreKind::Layouts, self.inbox.reply(0)),
178            Request::Open(name) => storage.load(StoreKind::Layouts, name, self.inbox.reply(0)),
179            Request::Save(name, document) | Request::Rename(_, name, document) => {
180                storage.save(
181                    StoreKind::Layouts,
182                    name,
183                    document.clone(),
184                    self.inbox.reply(0),
185                );
186            }
187            Request::Delete(name) => storage.delete(StoreKind::Layouts, name, self.inbox.reply(0)),
188        }
189        self.pending = Some(request);
190    }
191
192    /// Saves `layout` as `name`, which becomes the current layout. With a layout of that name,
193    /// replaces it.
194    pub fn save(&mut self, name: &str, layout: &SavedLayout) {
195        if name.trim().is_empty() {
196            self.error = Some("Enter a layout name".into());
197            return;
198        }
199        self.curr_layout = Some(name.to_owned());
200        self.saved_edits = Some(self.edits);
201        match layout.to_json() {
202            Ok(document) => self.request(Request::Save(name.to_owned(), document)),
203            Err(error) => self.error = Some(format!("Could not save {name}: {error}")),
204        }
205    }
206
207    /// Reads the layout `name`; [`poll`](Self::poll) returns it to restore.
208    pub fn open(&mut self, name: &str) {
209        self.request(Request::Open(name.to_owned()));
210    }
211
212    /// Renames the current layout to `name` by saving `layout`, the current layout, under it
213    /// and then deleting the old one.
214    pub fn rename(&mut self, name: &str, layout: &SavedLayout) {
215        if name.trim().is_empty() {
216            self.error = Some("Enter a layout name".into());
217            return;
218        }
219        let old = self.curr_layout.clone().unwrap_or_default();
220        self.saved_edits = Some(self.edits);
221        match layout.to_json() {
222            Ok(document) => self.request(Request::Rename(old, name.to_owned(), document)),
223            Err(error) => self.error = Some(format!("Could not rename to {name}: {error}")),
224        }
225    }
226
227    /// Deletes the layout `name`.
228    pub fn delete(&mut self, name: &str) {
229        self.request(Request::Delete(name.to_owned()));
230    }
231
232    /// Receives storage's answers, updating the list, and wakes the UI on the next answer.
233    /// Returns a layout [`open`](Self::open) read, now the current layout, for the host to
234    /// restore before its next [`autosave_due`](Self::autosave_due).
235    pub fn poll(&mut self, ctx: &egui::Context) -> Option<(String, SavedLayout)> {
236        let repaint = ctx.clone();
237        self.inbox.set_waker(move || repaint.request_repaint());
238        let mut opened = None;
239        if self.share.poll(ctx).is_some() {
240            self.request(Request::List);
241        }
242        loop {
243            let answers = self.inbox.drain().answers;
244            if answers.is_empty() {
245                break;
246            }
247            for (_, answer) in answers {
248                let Some(request) = self.pending.take() else {
249                    continue;
250                };
251                match request {
252                    Request::List => {
253                        match *answer
254                            .downcast::<Result<Vec<String>, FeedError>>()
255                            .expect("layout list")
256                        {
257                            Ok(mut names) => {
258                                names.sort();
259                                self.layouts = names;
260                            }
261                            Err(error) => {
262                                self.error = Some(l10n::format_text(
263                                    ctx,
264                                    "Could not list layouts: {error}",
265                                    &[("error", error.to_string().as_str())],
266                                ))
267                            }
268                        }
269                    }
270                    Request::Open(name) => {
271                        let answer = *answer
272                            .downcast::<Result<Option<String>, FeedError>>()
273                            .expect("layout load");
274                        match answer {
275                            Ok(Some(document)) => match SavedLayout::from_json(&document) {
276                                Ok(layout) => {
277                                    self.curr_layout = Some(name.clone());
278                                    self.saved_edits = None;
279                                    opened = Some((name, layout));
280                                }
281                                Err(error) => {
282                                    self.error = Some(l10n::format_text(
283                                        ctx,
284                                        "Could not open {name}: {error}",
285                                        &[
286                                            ("name", name.as_str()),
287                                            ("error", error.to_string().as_str()),
288                                        ],
289                                    ));
290                                }
291                            },
292                            Ok(None) => {
293                                self.layouts.retain(|layout| *layout != name);
294                                self.favorites.retain(|layout| *layout != name);
295                                self.error = Some(l10n::format_text(
296                                    ctx,
297                                    "{name} no longer exists",
298                                    &[("name", name.as_str())],
299                                ));
300                            }
301                            Err(error) => {
302                                self.error = Some(l10n::format_text(
303                                    ctx,
304                                    "Could not open {name}: {error}",
305                                    &[
306                                        ("name", name.as_str()),
307                                        ("error", error.to_string().as_str()),
308                                    ],
309                                ))
310                            }
311                        }
312                    }
313                    request => {
314                        let answer = *answer
315                            .downcast::<Result<(), FeedError>>()
316                            .expect("layout write");
317                        let report = answer.is_err() && !self.failing;
318                        self.failing = answer.is_err();
319                        match (request, answer) {
320                            (Request::Save(name, _), Ok(())) => self.insert(name),
321                            (Request::Rename(old, name, _), Ok(())) => {
322                                if self.curr_layout.as_ref() == Some(&old) {
323                                    self.curr_layout = Some(name.clone());
324                                }
325                                if old != name
326                                    && let Some(starred) =
327                                        self.favorites.iter_mut().find(|f| **f == old)
328                                {
329                                    *starred = name.clone();
330                                }
331                                if old != name {
332                                    self.requests.push_front(Request::Delete(old));
333                                }
334                                self.insert(name);
335                            }
336                            (Request::Delete(name), Ok(())) => {
337                                self.layouts.retain(|layout| *layout != name);
338                                self.favorites.retain(|layout| *layout != name);
339                            }
340                            (Request::Save(name, _), Err(error)) => {
341                                if report {
342                                    self.error = Some(l10n::format_text(
343                                        ctx,
344                                        "Could not save {name}: {error}",
345                                        &[
346                                            ("name", name.as_str()),
347                                            ("error", error.to_string().as_str()),
348                                        ],
349                                    ));
350                                }
351                            }
352                            (Request::Rename(old, name, _), Err(error)) => {
353                                if self.curr_layout.as_ref() == Some(&name) {
354                                    self.curr_layout = Some(old);
355                                }
356                                self.error = Some(l10n::format_text(
357                                    ctx,
358                                    "Could not rename to {name}: {error}",
359                                    &[
360                                        ("name", name.as_str()),
361                                        ("error", error.to_string().as_str()),
362                                    ],
363                                ));
364                            }
365                            (Request::Delete(name), Err(error)) => {
366                                self.error = Some(l10n::format_text(
367                                    ctx,
368                                    "Could not delete {name}: {error}",
369                                    &[
370                                        ("name", name.as_str()),
371                                        ("error", error.to_string().as_str()),
372                                    ],
373                                ));
374                            }
375                            (Request::List | Request::Open(_), _) => unreachable!("not a write"),
376                        }
377                    }
378                }
379            }
380            self.send_next();
381        }
382        opened
383    }
384
385    fn insert(&mut self, name: String) {
386        if let Err(at) = self.layouts.binary_search(&name) {
387            self.layouts.insert(at, name);
388        }
389    }
390
391    /// Call every frame with a count that changes whenever the layout does, such as the sum of
392    /// [`Chart::edit_count`](crate::widget::Chart::edit_count) over its charts. Returns `true`
393    /// when the host should save the current layout: autosave is on, the layout is stored, and
394    /// the count changed and then stayed the same for [`AUTOSAVE_DELAY`](Self::AUTOSAVE_DELAY).
395    pub fn autosave_due(&mut self, ctx: &egui::Context, edits: u64) -> bool {
396        self.edits = edits;
397        if self.observed_layout != self.curr_layout {
398            self.observed_layout = self.curr_layout.clone();
399            self.saved_edits = None;
400            self.changed = None;
401        }
402        let saved = *self.saved_edits.get_or_insert(edits);
403        let stored = self
404            .curr_layout
405            .as_ref()
406            .is_some_and(|name| self.layouts.contains(name));
407        if !self.autosave || !stored || self.storage.is_none() || edits == saved {
408            self.changed = None;
409            return false;
410        }
411        let now = ctx.input(|i| i.time);
412        let since = match self.changed {
413            Some((changed, since)) if changed == edits => since,
414            _ => {
415                self.changed = Some((edits, now));
416                now
417            }
418        };
419        let wait = Self::AUTOSAVE_DELAY.as_secs_f64() - (now - since);
420        if wait > 0.0 {
421            ctx.request_repaint_after(Duration::from_secs_f64(wait));
422            return false;
423        }
424        self.changed = None;
425        true
426    }
427
428    /// The error of a failed storage request since the last call, to show to the user.
429    pub fn take_error(&mut self) -> Option<String> {
430        self.error.take()
431    }
432
433    /// Width of the named trigger.
434    pub fn width(&self, ui: &Ui) -> f32 {
435        let label = l10n::text(ui.ctx(), self.curr_layout.as_deref().unwrap_or("Unnamed"));
436        ui.fonts_mut(|f| {
437            f.layout_no_wrap(
438                label,
439                egui::FontId::proportional(14.0),
440                ui.visuals().text_color(),
441            )
442            .size()
443            .x
444        })
445        .min(self.config.menu_width - 32.0)
446            + 32.0
447    }
448
449    /// Shows the current name and an anchored menu. Actions do not mutate the host's catalog.
450    pub fn show(&mut self, ui: &mut Ui) -> LayoutAction {
451        let (rect, button) =
452            ui.allocate_exact_size(egui::vec2(self.width(ui), 28.0), egui::Sense::click());
453        if button.hovered() {
454            ui.painter().rect_filled(
455                rect,
456                ui.active_theme().radius.sm,
457                ui.visuals().widgets.hovered.bg_fill,
458            );
459        }
460        ui.painter()
461            .with_clip_rect(egui::Rect::from_min_max(
462                rect.min,
463                rect.max - egui::vec2(22.0, 0.0),
464            ))
465            .text(
466                rect.left_center() + egui::vec2(4.0, 0.0),
467                egui::Align2::LEFT_CENTER,
468                l10n::text(ui.ctx(), self.curr_layout.as_deref().unwrap_or("Unnamed")),
469                egui::FontId::proportional(14.0),
470                ui.visuals().text_color(),
471            );
472        crate::icons::icons::CHEVRON_DOWN
473            .as_image_tinted(egui::Vec2::splat(12.0), ui.visuals().text_color())
474            .paint_at(
475                ui,
476                egui::Rect::from_center_size(
477                    rect.right_center() - egui::vec2(10.0, 0.0),
478                    egui::Vec2::splat(12.0),
479                ),
480            );
481        let mut action = LayoutAction::None;
482        let translated_label = l10n::text(ui.ctx(), "Named layouts");
483        button.widget_info(|| {
484            egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &translated_label)
485        });
486        crate::styles::focus::draw_focus_ring(ui, &button, ui.active_theme().radius.sm);
487        egui::Popup::menu(&button)
488            .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
489            .show(|ui| {
490                action = self.show_menu(ui);
491            });
492        action
493    }
494
495    /// Named layout menu contents, shared with the save dropdown.
496    pub fn show_menu(&mut self, ui: &mut Ui) -> LayoutAction {
497        let height =
498            (ui.ctx().content_rect().bottom() - ui.next_widget_position().y - 16.0).max(0.0);
499        egui::ScrollArea::vertical()
500            .max_height(height)
501            .show(ui, |ui| self.show_contents(ui))
502            .inner
503    }
504
505    fn show_contents(&mut self, ui: &mut Ui) -> LayoutAction {
506        let width = self
507            .config
508            .menu_width
509            .min(ui.ctx().content_rect().width() - 24.0);
510        ui.set_width(width.max(0.0));
511        let mut action = LayoutAction::None;
512        let ctx = ui.ctx().clone();
513        let shortcut = ctx
514            .data(|data| {
515                data.get_temp::<crate::widget::ShortcutMap>(egui::Id::NULL.with("chart_shortcuts"))
516            })
517            .map_or_else(
518                || KeyboardAction::SaveLayout.shortcut(),
519                |map| map.shortcut(KeyboardAction::SaveLayout),
520            );
521        // Keys as the toolbar's hover labels name them: "Ctrl + S".
522        let shortcut = shortcut.map(|shortcut| {
523            let font = egui::FontId::proportional(LABEL_SIZE);
524            let keys: Vec<_> = shortcut_key_labels(&ctx, &font, &shortcut)
525                .iter()
526                .map(|key| l10n::text(&ctx, key))
527                .collect();
528            keys.join(" + ")
529        });
530        if menu_row(ui, None, "Save layout", shortcut.as_deref(), None).clicked() {
531            action = LayoutAction::Save;
532            ui.close();
533        }
534        for (icon, label, value) in [
535            (
536                &DOWNLOAD_ICON,
537                "Download chart data",
538                LayoutAction::DownloadData,
539            ),
540            (&icons::PLUS, "Create new layout", LayoutAction::CreateNew),
541            (&icons::FOLDER_OPEN, "Open layout", LayoutAction::OpenLayout),
542        ] {
543            if menu_row(ui, Some(icon), label, None, None).clicked() {
544                action = value;
545                ui.close();
546            }
547        }
548        ui.separator();
549        let curr = self.curr_layout.clone().unwrap_or_default();
550        let rename = menu_row(ui, None, "Rename…", None, None).clicked();
551        let save_as = menu_row(ui, None, "Save layout as…", None, None).clicked();
552        if rename {
553            self.editing = Some(true);
554            self.new_name = curr.clone();
555        }
556        if save_as {
557            self.editing = Some(false);
558            self.new_name.clear();
559        }
560        ui.separator();
561        let image = menu_row(ui, Some(&icons::CAMERA), "Download chart image", None, None);
562        if image.clicked() {
563            action = LayoutAction::DownloadImage;
564            ui.close();
565        }
566        if menu_row(ui, None, "Autosave", None, Some(self.autosave))
567            .on_hover_text(l10n::text(ui.ctx(), "Save the layout after each change"))
568            .clicked()
569        {
570            self.autosave = !self.autosave;
571        }
572        if let Some(is_rename) = self.editing {
573            let response = ui.add(
574                egui::TextEdit::singleline(&mut self.new_name).desired_width(ui.available_width()),
575            );
576            if save_as || rename {
577                response.request_focus();
578            }
579            let name = self.new_name.trim();
580            let valid = !name.is_empty() && !self.layouts.iter().any(|l| l == name);
581            if ui
582                .add_enabled(valid, egui::Button::new(l10n::text(ui.ctx(), "Apply")))
583                .clicked()
584                || (valid && response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
585            {
586                action = if is_rename {
587                    LayoutAction::Rename(
588                        self.curr_layout.clone().unwrap_or_default(),
589                        name.to_owned(),
590                    )
591                } else {
592                    LayoutAction::SaveAs(name.to_owned())
593                };
594                self.editing = None;
595                ui.close();
596            }
597        }
598        if let Some(name) = self.curr_layout.as_deref()
599            && self.layouts.iter().any(|layout| layout == name)
600        {
601            self.share.show(ui, ShareKind::Layout, name);
602        }
603        if self.layouts.is_empty() {
604            return action;
605        }
606        ui.separator();
607        ui.horizontal(|ui| {
608            ui.colored_label(
609                ui.active_theme().ui.text_secondary,
610                l10n::text(ui.ctx(), "Layouts"),
611            );
612            ui.toggle_value(&mut self.favorites_only, l10n::text(ui.ctx(), "Favorites"))
613                .on_hover_text(l10n::text(ui.ctx(), "Show only favorite layouts"));
614        });
615        let shown: Vec<&String> = self
616            .layouts
617            .iter()
618            .filter(|layout| !self.favorites_only || self.favorites.contains(layout))
619            .collect();
620        if shown.is_empty() {
621            ui.colored_label(
622                ui.active_theme().ui.text_secondary,
623                l10n::text(ui.ctx(), "No favorite layouts"),
624            );
625        }
626        let mut starred = None;
627        egui::ScrollArea::vertical()
628            .max_height(ui.available_height().clamp(0.0, 240.0))
629            .show(ui, |ui| {
630                for layout in shown {
631                    ui.horizontal(|ui| {
632                        let favorite = self.favorites.contains(layout);
633                        if IconButton::new(if favorite {
634                            &icons::STAR_FILLED
635                        } else {
636                            &icons::STAR_EMPTY
637                        })
638                        .size(ButtonSize::SM)
639                        .tooltip(if favorite {
640                            "Remove from favorites"
641                        } else {
642                            "Add to favorites"
643                        })
644                        .show(ui)
645                        .clicked()
646                        {
647                            starred = Some(layout.clone());
648                        }
649                        if ui
650                            .add_sized(
651                                egui::vec2(
652                                    (ui.available_width() - 28.0).max(0.0),
653                                    ui.spacing().interact_size.y,
654                                ),
655                                egui::Button::new(l10n::text(ui.ctx(), layout))
656                                    .selected(self.curr_layout.as_deref() == Some(layout.as_str()))
657                                    .frame(false)
658                                    .truncate(),
659                            )
660                            .on_hover_text(l10n::text(ui.ctx(), layout))
661                            .clicked()
662                        {
663                            action = LayoutAction::Open(layout.clone());
664                            ui.close();
665                        }
666                        if ui
667                            .small_button("×")
668                            .on_hover_text(l10n::text(ui.ctx(), "Delete layout"))
669                            .clicked()
670                        {
671                            action = LayoutAction::Delete(layout.clone());
672                            ui.close();
673                        }
674                    });
675                }
676            });
677        if let Some(name) = starred {
678            if let Some(at) = self.favorites.iter().position(|f| *f == name) {
679                self.favorites.remove(at);
680            } else {
681                self.favorites.push(name);
682            }
683        }
684        action
685    }
686}
687
688/// Text size of the save menu's labels and shortcut.
689const LABEL_SIZE: f32 = 15.0;
690/// Size of the glyphs before the save menu's labels.
691const GLYPH_SIZE: f32 = 17.0;
692
693/// A 32 px save menu entry: an optional glyph, the label after the glyph column, and at the end a
694/// muted `shortcut` or, for an on and off entry, a `switch` showing its state.
695fn menu_row(
696    ui: &mut Ui,
697    icon: Option<&Icon>,
698    label: &str,
699    shortcut: Option<&str>,
700    switch: Option<bool>,
701) -> egui::Response {
702    ListItem::new().height(32.0).show(
703        ui,
704        MenuRow {
705            icon,
706            label,
707            shortcut,
708            switch,
709        },
710    )
711}
712
713struct MenuRow<'a> {
714    icon: Option<&'a Icon>,
715    label: &'a str,
716    shortcut: Option<&'a str>,
717    switch: Option<bool>,
718}
719
720impl ListItemContent for MenuRow<'_> {
721    fn ui(self, ui: &mut Ui, context: &ContentContext<'_>) -> egui::Response {
722        let theme = ui.active_theme();
723        let rect = context.rect;
724        let response = context.response;
725        let label = l10n::text(ui.ctx(), self.label);
726        response.widget_info(|| match self.switch {
727            Some(on) => WidgetInfo::selected(Role::Switch, response.enabled(), on, &label),
728            None => WidgetInfo::labeled(Role::MenuItem, response.enabled(), &label),
729        });
730        if let Some(icon) = self.icon {
731            let glyph = Rect::from_center_size(
732                pos2(rect.left() + GLYPH_SIZE / 2.0, rect.center().y),
733                vec2(GLYPH_SIZE, GLYPH_SIZE),
734            );
735            icon.as_image_tinted(glyph.size(), context.visuals.icon_tint(ui.visuals()))
736                .paint_at(ui, glyph);
737        }
738        let font = egui::FontId::proportional(LABEL_SIZE);
739        ui.painter().text(
740            pos2(rect.left() + GLYPH_SIZE + theme.spacing.lg, rect.center().y),
741            egui::Align2::LEFT_CENTER,
742            label,
743            font.clone(),
744            context.visuals.text_color(ui.visuals()),
745        );
746        if let Some(shortcut) = self.shortcut {
747            ui.painter().text(
748                rect.right_center(),
749                egui::Align2::RIGHT_CENTER,
750                shortcut,
751                font,
752                theme.ui.text_muted,
753            );
754        }
755        if let Some(on) = self.switch {
756            let switch = Rect::from_min_size(
757                pos2(rect.right() - 38.0, rect.center().y - 10.0),
758                vec2(38.0, 20.0),
759            );
760            let track = if on {
761                theme.ui.text
762            } else {
763                theme.ui.border_control
764            };
765            ui.painter().rect_filled(switch, 10.0, track);
766            let knob = if on {
767                switch.right() - 10.0
768            } else {
769                switch.left() + 10.0
770            };
771            ui.painter()
772                .circle_filled(pos2(knob, switch.center().y), 7.0, theme.ui.panel_bg);
773        }
774        response.clone()
775    }
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781    use crate::data::Reply;
782    use std::collections::BTreeMap;
783    use std::sync::Mutex;
784
785    #[derive(Default)]
786    struct Host(Mutex<BTreeMap<String, String>>, Mutex<Option<Reply<()>>>);
787
788    impl Storage for Host {
789        fn list(&self, _: StoreKind, reply: Reply<Vec<String>>) {
790            reply.send(Ok(self.0.lock().unwrap().keys().rev().cloned().collect()));
791        }
792        fn load(&self, _: StoreKind, name: &str, reply: Reply<Option<String>>) {
793            reply.send(Ok(self.0.lock().unwrap().get(name).cloned()));
794        }
795        fn save(&self, _: StoreKind, name: &str, document: String, reply: Reply<()>) {
796            self.0.lock().unwrap().insert(name.into(), document);
797            if name == "Delayed" {
798                assert!(
799                    self.1.lock().unwrap().replace(reply).is_none(),
800                    "writes must be serialized"
801                );
802            } else {
803                reply.send(Ok(()));
804            }
805        }
806        fn delete(&self, _: StoreKind, name: &str, reply: Reply<()>) {
807            self.0.lock().unwrap().remove(name);
808            reply.send(Ok(()));
809        }
810    }
811
812    #[test]
813    fn layouts_save_rename_open_and_autosave_through_storage() {
814        let ctx = egui::Context::default();
815        let at = |time: f64, run: &mut dyn FnMut(&egui::Context)| {
816            let input = egui::RawInput {
817                time: Some(time),
818                ..Default::default()
819            };
820            ctx.run_ui(input, |ui| run(ui.ctx()))
821                .drop_without_applying_deltas();
822        };
823        let layout = SavedLayout {
824            mode: crate::widget::ChartLayoutMode::TwoHorizontal,
825            charts: Vec::new(),
826        };
827        let host = Arc::new(Host::default());
828        host.0
829            .lock()
830            .unwrap()
831            .insert("B".into(), layout.to_json().unwrap());
832        let mut menu = LayoutMenu::new();
833        menu.set_storage(host.clone());
834        at(0.0, &mut |ctx| assert!(menu.poll(ctx).is_none()));
835        assert_eq!(menu.layouts, ["B"]);
836        menu.save("C", &layout);
837        at(0.0, &mut |ctx| assert!(menu.poll(ctx).is_none()));
838        assert_eq!(menu.layouts, ["B", "C"]);
839        // A rename writes the new name before it deletes the old one, and the star follows it.
840        menu.favorites = vec!["C".into(), "B".into()];
841        menu.rename("A", &layout);
842        at(0.0, &mut |ctx| assert!(menu.poll(ctx).is_none()));
843        assert!(host.0.lock().unwrap().contains_key("A"));
844        at(0.0, &mut |ctx| assert!(menu.poll(ctx).is_none()));
845        assert_eq!(menu.layouts, ["A", "B"]);
846        assert_eq!(
847            host.0.lock().unwrap().keys().collect::<Vec<_>>(),
848            ["A", "B"]
849        );
850        assert_eq!(menu.favorites, ["A", "B"]);
851        menu.rename("A", &layout);
852        at(0.0, &mut |ctx| {
853            menu.poll(ctx);
854        });
855        assert!(
856            host.0.lock().unwrap().contains_key("A"),
857            "renaming to the same name must not delete it"
858        );
859        menu.open("B");
860        at(0.0, &mut |ctx| {
861            let (name, opened) = menu.poll(ctx).unwrap();
862            assert_eq!((name.as_str(), opened.mode), ("B", layout.mode));
863        });
864        assert_eq!(menu.curr_layout.as_deref(), Some("B"));
865        // Autosave waits for the layout to stay unchanged after an edit.
866        menu.autosave = true;
867        let mut due = Vec::new();
868        for (time, edits) in [(0.0, 5), (1.0, 6), (2.5, 7), (4.0, 7), (4.6, 7), (5.0, 7)] {
869            at(time, &mut |ctx| due.push(menu.autosave_due(ctx, edits)));
870            if due.last() == Some(&true) {
871                menu.save("B", &layout);
872            }
873        }
874        assert_eq!(due, [false, false, false, false, true, false]);
875        menu.poll(&ctx);
876        menu.save("Delayed", &layout);
877        let mut latest = layout.clone();
878        latest.mode = crate::widget::ChartLayoutMode::Single;
879        menu.save("Delayed", &latest);
880        let first = host.0.lock().unwrap()["Delayed"].clone();
881        assert_eq!(SavedLayout::from_json(&first).unwrap().mode, layout.mode);
882        host.1.lock().unwrap().take().unwrap().send(Ok(()));
883        menu.poll(&ctx);
884        let last = host.0.lock().unwrap()["Delayed"].clone();
885        assert_eq!(SavedLayout::from_json(&last).unwrap().mode, latest.mode);
886        host.1.lock().unwrap().take().unwrap().send(Ok(()));
887        menu.poll(&ctx);
888        menu.delete("B");
889        menu.poll(&ctx);
890        assert_eq!(menu.favorites, ["A"]);
891        // Changing tabs starts a new debounce, without autosaving an unchanged tab.
892        menu.curr_layout = Some("A".into());
893        at(10.0, &mut |ctx| assert!(!menu.autosave_due(ctx, 99)));
894        at(20.0, &mut |ctx| assert!(!menu.autosave_due(ctx, 99)));
895    }
896
897    #[test]
898    fn long_layout_names_keep_menu_actions_inside_both_theme_windows() {
899        let entries = [
900            "Save layout",
901            "Download chart data",
902            "Create new layout",
903            "Open layout",
904            "Rename…",
905            "Save layout as…",
906            "Download chart image",
907            "Autosave",
908        ];
909        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
910            for size in [egui::vec2(320.0, 360.0), egui::vec2(1440.0, 900.0)] {
911                let ctx = egui::Context::default();
912                crate::theme::apply_to_egui(&ctx, &theme);
913                ctx.enable_accesskit();
914                let mut menu = LayoutMenu::new();
915                menu.layouts = (0..20)
916                    .map(|i| format!("{i} {}", "Long layout ".repeat(30)))
917                    .collect();
918                menu.curr_layout = Some(menu.layouts[0].clone());
919                menu.editing = Some(true);
920                menu.new_name = menu.layouts[0].clone();
921                for pass in 0..2 {
922                    let output = ctx.run_ui(
923                        egui::RawInput {
924                            screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)),
925                            ..Default::default()
926                        },
927                        |ui| {
928                            assert!(menu.width(ui) <= menu.config.menu_width);
929                            egui::Area::new(egui::Id::NULL.with("layout menu"))
930                                .fixed_pos(egui::pos2(8.0, 8.0))
931                                .show(ui.ctx(), |ui| {
932                                    menu.show_menu(ui);
933                                    assert!(ui.min_rect().right() <= size.x);
934                                    assert!(ui.min_rect().bottom() <= size.y);
935                                });
936                        },
937                    );
938                    if pass == 1 {
939                        // Every entry is a 32 px row with its label at one indent.
940                        let label_x: Vec<_> = entries
941                            .iter()
942                            .map(|entry| {
943                                output.shapes.iter().find_map(|shape| match &shape.shape {
944                                    egui::Shape::Text(text) if text.galley.text() == *entry => {
945                                        Some(text.pos.x)
946                                    }
947                                    _ => None,
948                                })
949                            })
950                            .collect();
951                        assert!(label_x.iter().all(|x| x.is_some() && *x == label_x[0]));
952                        let rows: Vec<_> = output
953                            .platform_output
954                            .accesskit_update
955                            .iter()
956                            .flat_map(|update| &update.nodes)
957                            .filter(|(_, node)| node.label().is_some_and(|l| entries.contains(&l)))
958                            .filter_map(|(_, node)| node.bounds())
959                            .collect();
960                        assert_eq!(rows.len(), entries.len());
961                        assert!(rows.iter().all(|row| row.height() == 32.0), "{rows:?}");
962                    }
963                    output.drop_without_applying_deltas();
964                }
965            }
966        }
967    }
968}