1use 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#[derive(Debug, Clone)]
23pub struct LayoutMenuConfig {
24 pub menu_width: f32,
26}
27
28impl Default for LayoutMenuConfig {
29 fn default() -> Self {
30 Self { menu_width: 220.0 }
31 }
32}
33
34#[derive(Debug, Clone, PartialEq)]
36#[non_exhaustive]
37pub enum LayoutAction {
38 None,
40 SelectGrid(crate::widget::ChartLayoutMode),
42 Save,
44 SaveAs(String),
46 DownloadData,
48 OpenLayout,
50 DownloadImage,
52 CreateNew,
54 Open(String),
56 Delete(String),
58 Rename(String, String),
60}
61
62enum Request {
63 List,
64 Open(String),
65 Save(String, String),
66 Rename(String, String, String),
68 Delete(String),
69}
70
71pub struct LayoutMenu {
77 pub curr_layout: Option<String>,
79 pub layouts: Vec<String>,
81 pub config: LayoutMenuConfig,
83 pub autosave: bool,
86 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 edits: u64,
100 saved_edits: Option<u64>,
101 observed_layout: Option<String>,
102 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 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 pub const AUTOSAVE_DELAY: Duration = Duration::from_secs(2);
143
144 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 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 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 pub fn open(&mut self, name: &str) {
209 self.request(Request::Open(name.to_owned()));
210 }
211
212 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 pub fn delete(&mut self, name: &str) {
229 self.request(Request::Delete(name.to_owned()));
230 }
231
232 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 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 pub fn take_error(&mut self) -> Option<String> {
430 self.error.take()
431 }
432
433 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 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 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 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
688const LABEL_SIZE: f32 = 15.0;
690const GLYPH_SIZE: f32 = 17.0;
692
693fn 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 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 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 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 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}