libsurfer/
menus.rs

1//! Menu handling.
2use color_eyre::eyre::WrapErr;
3use egui::{menu, Button, Context, TextWrapMode, TopBottomPanel, Ui};
4use futures::executor::block_on;
5use itertools::Itertools;
6use std::sync::atomic::Ordering;
7use surfer_translation_types::{TranslationPreference, Translator};
8
9use crate::displayed_item_tree::VisibleItemIndex;
10use crate::hierarchy::HierarchyStyle;
11use crate::message::MessageTarget;
12use crate::wave_container::{FieldRef, VariableRefExt};
13use crate::wave_source::LoadOptions;
14use crate::wcp::{proto::WcpEvent, proto::WcpSCMessage};
15use crate::{
16    clock_highlighting::clock_highlight_type_menu,
17    config::ArrowKeyBindings,
18    displayed_item::{DisplayedFieldRef, DisplayedItem},
19    file_dialog::OpenMode,
20    message::Message,
21    time::{timeformat_menu, timeunit_menu},
22    variable_name_type::VariableNameType,
23    SystemState,
24};
25
26// Button builder. Short name because we use it a ton
27struct ButtonBuilder {
28    text: String,
29    shortcut: Option<String>,
30    message: Message,
31    enabled: bool,
32}
33
34impl ButtonBuilder {
35    fn new(text: impl Into<String>, message: Message) -> Self {
36        Self {
37            text: text.into(),
38            message,
39            shortcut: None,
40            enabled: true,
41        }
42    }
43
44    fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
45        self.shortcut = Some(shortcut.into());
46        self
47    }
48
49    #[cfg_attr(not(feature = "python"), allow(dead_code))]
50    pub fn enabled(mut self, enabled: bool) -> Self {
51        self.enabled = enabled;
52        self
53    }
54
55    pub fn add_leave_menu(self, msgs: &mut Vec<Message>, ui: &mut Ui) {
56        self.add_inner(false, msgs, ui);
57    }
58
59    pub fn add_closing_menu(self, msgs: &mut Vec<Message>, ui: &mut Ui) {
60        self.add_inner(true, msgs, ui);
61    }
62
63    pub fn add_inner(self, close_menu: bool, msgs: &mut Vec<Message>, ui: &mut Ui) {
64        let button = Button::new(self.text);
65        let button = if let Some(s) = self.shortcut {
66            button.shortcut_text(s)
67        } else {
68            button
69        };
70        if ui.add_enabled(self.enabled, button).clicked() {
71            msgs.push(self.message);
72            if close_menu {
73                ui.close_menu();
74            }
75        }
76    }
77}
78
79impl SystemState {
80    pub fn add_menu_panel(&self, ctx: &Context, msgs: &mut Vec<Message>) {
81        TopBottomPanel::top("menu").show(ctx, |ui| {
82            menu::bar(ui, |ui| {
83                self.menu_contents(ui, msgs);
84            });
85        });
86    }
87
88    pub fn menu_contents(&self, ui: &mut Ui, msgs: &mut Vec<Message>) {
89        /// Helper function to get a new ButtonBuilder.
90        fn b(text: impl Into<String>, message: Message) -> ButtonBuilder {
91            ButtonBuilder::new(text, message)
92        }
93
94        let waves_loaded = self.user.waves.is_some();
95
96        ui.menu_button("File", |ui| {
97            b("Open file...", Message::OpenFileDialog(OpenMode::Open)).add_closing_menu(msgs, ui);
98            b("Switch file...", Message::OpenFileDialog(OpenMode::Switch))
99                .add_closing_menu(msgs, ui);
100            b(
101                "Reload",
102                Message::ReloadWaveform(self.user.config.behavior.keep_during_reload),
103            )
104            .shortcut("r")
105            .enabled(self.user.waves.is_some())
106            .add_closing_menu(msgs, ui);
107
108            b("Load state...", Message::LoadStateFile(None)).add_closing_menu(msgs, ui);
109            #[cfg(not(target_arch = "wasm32"))]
110            {
111                let save_text = if self.user.state_file.is_some() {
112                    "Save state"
113                } else {
114                    "Save state..."
115                };
116                b(
117                    save_text,
118                    Message::SaveStateFile(self.user.state_file.clone()),
119                )
120                .shortcut("Ctrl+s")
121                .add_closing_menu(msgs, ui);
122            }
123            b("Save state as...", Message::SaveStateFile(None)).add_closing_menu(msgs, ui);
124            b(
125                "Open URL...",
126                Message::SetUrlEntryVisible(
127                    true,
128                    Some(Box::new(|url: String| {
129                        Message::LoadWaveformFileFromUrl(url.clone(), LoadOptions::clean())
130                    })),
131                ),
132            )
133            .add_closing_menu(msgs, ui);
134            #[cfg(target_arch = "wasm32")]
135            b("Run command file...", Message::OpenCommandFileDialog)
136                .enabled(waves_loaded)
137                .add_closing_menu(msgs, ui);
138            #[cfg(not(target_arch = "wasm32"))]
139            b("Run command file...", Message::OpenCommandFileDialog).add_closing_menu(msgs, ui);
140            b(
141                "Run command file from URL...",
142                Message::SetUrlEntryVisible(
143                    true,
144                    Some(Box::new(|url: String| {
145                        Message::LoadCommandFileFromUrl(url.clone())
146                    })),
147                ),
148            )
149            .add_closing_menu(msgs, ui);
150
151            #[cfg(feature = "python")]
152            {
153                b("Add Python translator", Message::OpenPythonPluginDialog)
154                    .add_closing_menu(msgs, ui);
155                b("Reload Python translator", Message::ReloadPythonPlugin)
156                    .enabled(self.translators.has_python_translator())
157                    .add_closing_menu(msgs, ui);
158            }
159            #[cfg(not(target_arch = "wasm32"))]
160            b("Exit", Message::Exit).add_closing_menu(msgs, ui);
161        });
162        ui.menu_button("View", |ui: &mut Ui| {
163            ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
164            b(
165                "Zoom in",
166                Message::CanvasZoom {
167                    mouse_ptr: None,
168                    delta: 0.5,
169                    viewport_idx: 0,
170                },
171            )
172            .shortcut("+")
173            .enabled(waves_loaded)
174            .add_leave_menu(msgs, ui);
175
176            b(
177                "Zoom out",
178                Message::CanvasZoom {
179                    mouse_ptr: None,
180                    delta: 2.0,
181                    viewport_idx: 0,
182                },
183            )
184            .shortcut("-")
185            .enabled(waves_loaded)
186            .add_leave_menu(msgs, ui);
187
188            b("Zoom to fit", Message::ZoomToFit { viewport_idx: 0 })
189                .enabled(waves_loaded)
190                .add_closing_menu(msgs, ui);
191
192            ui.separator();
193
194            b("Go to start", Message::GoToStart { viewport_idx: 0 })
195                .shortcut("s")
196                .enabled(waves_loaded)
197                .add_closing_menu(msgs, ui);
198            b("Go to end", Message::GoToEnd { viewport_idx: 0 })
199                .shortcut("e")
200                .enabled(waves_loaded)
201                .add_closing_menu(msgs, ui);
202            ui.separator();
203            b("Add viewport", Message::AddViewport)
204                .enabled(waves_loaded)
205                .add_closing_menu(msgs, ui);
206            b("Remove viewport", Message::RemoveViewport)
207                .enabled(waves_loaded)
208                .add_closing_menu(msgs, ui);
209            ui.separator();
210
211            b("Toggle side panel", Message::ToggleSidePanel)
212                .shortcut("b")
213                .add_closing_menu(msgs, ui);
214            b("Toggle menu", Message::ToggleMenu)
215                .shortcut("Alt+m")
216                .add_closing_menu(msgs, ui);
217            b("Toggle toolbar", Message::ToggleToolbar)
218                .shortcut("t")
219                .add_closing_menu(msgs, ui);
220            b("Toggle overview", Message::ToggleOverview).add_closing_menu(msgs, ui);
221            b("Toggle statusbar", Message::ToggleStatusbar).add_closing_menu(msgs, ui);
222            b("Toggle timeline", Message::ToggleDefaultTimeline).add_closing_menu(msgs, ui);
223            #[cfg(not(target_arch = "wasm32"))]
224            b("Toggle full screen", Message::ToggleFullscreen)
225                .shortcut("F11")
226                .add_closing_menu(msgs, ui);
227            ui.menu_button("Theme", |ui| {
228                ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
229                b("Default theme", Message::SelectTheme(None)).add_closing_menu(msgs, ui);
230                for theme_name in self.user.config.theme.theme_names.clone() {
231                    b(theme_name.clone(), Message::SelectTheme(Some(theme_name)))
232                        .add_closing_menu(msgs, ui);
233                }
234            });
235            ui.menu_button("UI zoom factor", |ui| {
236                ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
237                for scale in &self.user.config.layout.zoom_factors {
238                    ui.radio(
239                        self.ui_zoom_factor() == *scale,
240                        format!("{} %", scale * 100.),
241                    )
242                    .clicked()
243                    .then(|| {
244                        ui.close_menu();
245                        msgs.push(Message::SetUIZoomFactor(*scale))
246                    });
247                }
248            });
249        });
250
251        ui.menu_button("Settings", |ui| {
252            ui.menu_button("Clock highlighting", |ui| {
253                clock_highlight_type_menu(ui, msgs, self.user.config.default_clock_highlight_type);
254            });
255            ui.menu_button("Time unit", |ui| {
256                timeunit_menu(ui, msgs, &self.user.wanted_timeunit);
257            });
258            ui.menu_button("Time format", |ui| {
259                timeformat_menu(ui, msgs, &self.get_time_format());
260            });
261            if let Some(waves) = &self.user.waves {
262                let variable_name_type = waves.default_variable_name_type;
263                ui.menu_button("Variable names", |ui| {
264                    for name_type in enum_iterator::all::<VariableNameType>() {
265                        ui.radio(variable_name_type == name_type, name_type.to_string())
266                            .clicked()
267                            .then(|| {
268                                ui.close_menu();
269                                msgs.push(Message::ForceVariableNameTypes(name_type));
270                            });
271                    }
272                });
273            }
274            ui.menu_button("Variable name alignment", |ui| {
275                let align_right = self
276                    .user
277                    .align_names_right
278                    .unwrap_or_else(|| self.user.config.layout.align_names_right());
279                ui.radio(!align_right, "Left").clicked().then(|| {
280                    ui.close_menu();
281                    msgs.push(Message::SetNameAlignRight(false));
282                });
283                ui.radio(align_right, "Right").clicked().then(|| {
284                    ui.close_menu();
285                    msgs.push(Message::SetNameAlignRight(true));
286                });
287            });
288            ui.menu_button("Variable filter type", |ui| {
289                self.variable_filter_type_menu(ui, msgs);
290            });
291
292            ui.menu_button("Hierarchy", |ui| {
293                for style in enum_iterator::all::<HierarchyStyle>() {
294                    ui.radio(
295                        self.user.config.layout.hierarchy_style == style,
296                        style.to_string(),
297                    )
298                    .clicked()
299                    .then(|| {
300                        ui.close_menu();
301                        msgs.push(Message::SetHierarchyStyle(style));
302                    });
303                }
304            });
305
306            ui.menu_button("Arrow keys", |ui| {
307                for binding in enum_iterator::all::<ArrowKeyBindings>() {
308                    ui.radio(
309                        self.user.config.behavior.arrow_key_bindings == binding,
310                        binding.to_string(),
311                    )
312                    .clicked()
313                    .then(|| {
314                        ui.close_menu();
315                        msgs.push(Message::SetArrowKeyBindings(binding));
316                    });
317                }
318            });
319
320            ui.radio(self.show_ticks(), "Show tick lines")
321                .clicked()
322                .then(|| {
323                    ui.close_menu();
324                    msgs.push(Message::ToggleTickLines);
325                });
326
327            ui.radio(self.show_tooltip(), "Show variable tooltip")
328                .clicked()
329                .then(|| {
330                    ui.close_menu();
331                    msgs.push(Message::ToggleVariableTooltip);
332                });
333
334            ui.radio(self.show_scope_tooltip(), "Show scope tooltip")
335                .clicked()
336                .then(|| {
337                    ui.close_menu();
338                    msgs.push(Message::ToggleScopeTooltip);
339                });
340
341            ui.radio(self.show_variable_indices(), "Show variable indices")
342                .clicked()
343                .then(|| {
344                    ui.close_menu();
345                    msgs.push(Message::ToggleIndices);
346                });
347
348            ui.radio(self.show_variable_direction(), "Show variable direction")
349                .clicked()
350                .then(|| {
351                    ui.close_menu();
352                    msgs.push(Message::ToggleDirection);
353                });
354
355            ui.radio(self.show_empty_scopes(), "Show empty scopes")
356                .clicked()
357                .then(|| {
358                    ui.close_menu();
359                    msgs.push(Message::ToggleEmptyScopes);
360                });
361
362            ui.radio(
363                self.show_parameters_in_scopes(),
364                "Show parameters in scopes",
365            )
366            .clicked()
367            .then(|| {
368                ui.close_menu();
369                msgs.push(Message::ToggleParametersInScopes);
370            });
371
372            ui.radio(self.highlight_focused(), "Highlight focused")
373                .clicked()
374                .then(|| {
375                    ui.close_menu();
376                    msgs.push(Message::SetHighlightFocused(!self.highlight_focused()))
377                });
378
379            ui.radio(self.fill_high_values(), "Fill high values")
380                .clicked()
381                .then(|| {
382                    ui.close_menu();
383                    msgs.push(Message::SetFillHighValues(!self.fill_high_values()));
384                });
385        });
386        ui.menu_button("Help", |ui| {
387            b("Quick start", Message::SetQuickStartVisible(true)).add_closing_menu(msgs, ui);
388            b("Control keys", Message::SetKeyHelpVisible(true)).add_closing_menu(msgs, ui);
389            b("Mouse gestures", Message::SetGestureHelpVisible(true)).add_closing_menu(msgs, ui);
390
391            ui.separator();
392            b("Show logs", Message::SetLogsVisible(true)).add_closing_menu(msgs, ui);
393
394            ui.separator();
395            b("License information", Message::SetLicenseVisible(true)).add_closing_menu(msgs, ui);
396            ui.separator();
397            b("About", Message::SetAboutVisible(true)).add_closing_menu(msgs, ui);
398        });
399    }
400
401    pub fn item_context_menu(
402        &self,
403        path: Option<&FieldRef>,
404        msgs: &mut Vec<Message>,
405        ui: &mut Ui,
406        vidx: VisibleItemIndex,
407    ) {
408        let Some(waves) = &self.user.waves else {
409            return;
410        };
411
412        let (displayed_item_id, displayed_item) = waves
413            .items_tree
414            .get_visible(vidx)
415            .map(|node| (node.item_ref, &waves.displayed_items[&node.item_ref]))
416            .unwrap();
417
418        let affect_selected = waves
419            .items_tree
420            .iter_visible_selected()
421            .map(|node| node.item_ref)
422            .contains(&displayed_item_id);
423        let affected_vidxs = if affect_selected { None } else { Some(vidx) };
424
425        if let Some(path) = path {
426            let dfr = DisplayedFieldRef {
427                item: displayed_item_id,
428                field: path.field.clone(),
429            };
430            self.add_format_menu(&dfr, displayed_item, path, msgs, ui);
431        }
432
433        ui.menu_button("Color", |ui| {
434            let selected_color = &displayed_item.color().unwrap_or("__nocolor__");
435            for color_name in self.user.config.theme.colors.keys() {
436                ui.radio(selected_color == color_name, color_name)
437                    .clicked()
438                    .then(|| {
439                        ui.close_menu();
440                        msgs.push(Message::ItemColorChange(
441                            affected_vidxs.into(),
442                            Some(color_name.clone()),
443                        ));
444                    });
445            }
446            ui.separator();
447            ui.radio(*selected_color == "__nocolor__", "Default")
448                .clicked()
449                .then(|| {
450                    ui.close_menu();
451                    msgs.push(Message::ItemColorChange(
452                        MessageTarget::Explicit(vidx),
453                        None,
454                    ));
455                });
456        });
457
458        ui.menu_button("Background color", |ui| {
459            let selected_color = &displayed_item.background_color().unwrap_or("__nocolor__");
460            for color_name in self.user.config.theme.colors.keys() {
461                ui.radio(selected_color == color_name, color_name)
462                    .clicked()
463                    .then(|| {
464                        ui.close_menu();
465                        msgs.push(Message::ItemBackgroundColorChange(
466                            affected_vidxs.into(),
467                            Some(color_name.clone()),
468                        ));
469                    });
470            }
471            ui.separator();
472            ui.radio(*selected_color == "__nocolor__", "Default")
473                .clicked()
474                .then(|| {
475                    ui.close_menu();
476                    msgs.push(Message::ItemBackgroundColorChange(
477                        MessageTarget::Explicit(vidx),
478                        None,
479                    ));
480                });
481        });
482
483        if let DisplayedItem::Variable(variable) = displayed_item {
484            ui.menu_button("Name", |ui| {
485                let variable_name_type = variable.display_name_type;
486                for name_type in enum_iterator::all::<VariableNameType>() {
487                    ui.radio(variable_name_type == name_type, name_type.to_string())
488                        .clicked()
489                        .then(|| {
490                            ui.close_menu();
491                            msgs.push(Message::ChangeVariableNameType(
492                                MessageTarget::Explicit(vidx),
493                                name_type,
494                            ));
495                        });
496                }
497            });
498
499            ui.menu_button("Height", |ui| {
500                let selected_size = displayed_item.height_scaling_factor();
501                for size in &self.user.config.layout.waveforms_line_height_multiples {
502                    ui.radio(selected_size == *size, format!("{}", size))
503                        .clicked()
504                        .then(|| {
505                            ui.close_menu();
506                            msgs.push(Message::ItemHeightScalingFactorChange(
507                                affected_vidxs.into(),
508                                *size,
509                            ));
510                        });
511                }
512            });
513
514            if self.wcp_greeted_signal.load(Ordering::Relaxed) {
515                if self.wcp_client_capabilities.goto_declaration
516                    && ui.button("Go to declaration").clicked()
517                {
518                    let variable = variable.variable_ref.full_path_string();
519                    self.channels.wcp_s2c_sender.as_ref().map(|ch| {
520                        block_on(
521                            ch.send(WcpSCMessage::event(WcpEvent::goto_declaration { variable })),
522                        )
523                    });
524                    ui.close_menu();
525                }
526                if self.wcp_client_capabilities.add_drivers && ui.button("Add drivers").clicked() {
527                    let variable = variable.variable_ref.full_path_string();
528                    self.channels.wcp_s2c_sender.as_ref().map(|ch| {
529                        block_on(ch.send(WcpSCMessage::event(WcpEvent::add_drivers { variable })))
530                    });
531                    ui.close_menu();
532                }
533                if self.wcp_client_capabilities.add_loads && ui.button("Add loads").clicked() {
534                    let variable = variable.variable_ref.full_path_string();
535                    self.channels.wcp_s2c_sender.as_ref().map(|ch| {
536                        block_on(ch.send(WcpSCMessage::event(WcpEvent::add_loads { variable })))
537                    });
538                    ui.close_menu();
539                }
540            }
541        }
542
543        if ui.button("Rename").clicked() {
544            ui.close_menu();
545            msgs.push(Message::RenameItem(Some(vidx)));
546        }
547
548        if ui.button("Remove").clicked() {
549            msgs.push(
550                if waves
551                    .items_tree
552                    .iter_visible_selected()
553                    .map(|node| node.item_ref)
554                    .contains(&displayed_item_id)
555                {
556                    Message::Batch(vec![
557                        Message::RemoveItems(
558                            waves
559                                .items_tree
560                                .iter_visible_selected()
561                                .map(|node| node.item_ref)
562                                .collect_vec(),
563                        ),
564                        Message::UnfocusItem,
565                    ])
566                } else {
567                    Message::RemoveItems(vec![displayed_item_id])
568                },
569            );
570            msgs.push(Message::InvalidateCount);
571            ui.close_menu();
572        }
573        if path.is_some() {
574            // Actual signal. Not one of: divider, timeline, marker.
575            ui.menu_button("Copy", |ui| {
576                #[allow(clippy::collapsible_if)]
577                if waves.cursor.is_some() {
578                    if ui.button("Value").clicked() {
579                        ui.close_menu();
580                        msgs.push(Message::VariableValueToClipbord(MessageTarget::Explicit(
581                            vidx,
582                        )));
583                    }
584                }
585                if ui.button("Name").clicked() {
586                    ui.close_menu();
587                    msgs.push(Message::VariableNameToClipboard(MessageTarget::Explicit(
588                        vidx,
589                    )));
590                }
591                if ui.button("Full name").clicked() {
592                    ui.close_menu();
593                    msgs.push(Message::VariableFullNameToClipboard(
594                        MessageTarget::Explicit(vidx),
595                    ));
596                }
597            });
598        }
599        ui.separator();
600        ui.menu_button("Insert", |ui| {
601            if ui.button("Divider").clicked() {
602                ui.close_menu();
603                msgs.push(Message::AddDivider(None, Some(vidx)));
604            }
605            if ui.button("Timeline").clicked() {
606                ui.close_menu();
607                msgs.push(Message::AddTimeLine(Some(vidx)));
608            }
609        });
610
611        ui.menu_button("Group", |ui| {
612            let info = waves
613                .items_tree
614                .iter_visible_extra()
615                .find(|info| info.node.item_ref == displayed_item_id)
616                .expect("Inconsistent, could not find displayed signal in tree");
617
618            if ui.button("Create").clicked() {
619                ui.close_menu();
620
621                let mut items = if affect_selected {
622                    waves
623                        .items_tree
624                        .iter_visible_selected()
625                        .map(|node| node.item_ref)
626                        .collect::<Vec<_>>()
627                } else {
628                    vec![]
629                };
630                // the focused item may not yet be selected, so add it
631                if affect_selected {
632                    if let Some(focused_item_node) = waves
633                        .focused_item
634                        .and_then(|focused_item| waves.items_tree.get_visible(focused_item))
635                    {
636                        items.push(focused_item_node.item_ref);
637                    }
638                }
639
640                // the clicked item may not be selected yet, add it
641                items.push(displayed_item_id);
642
643                msgs.push(Message::GroupNew {
644                    name: None,
645                    before: Some(info.idx),
646                    items: Some(items),
647                })
648            }
649            if matches!(displayed_item, DisplayedItem::Group(_)) {
650                if ui.button("Dissolve").clicked() {
651                    ui.close_menu();
652                    msgs.push(Message::GroupDissolve(Some(displayed_item_id)))
653                }
654
655                let (text, msg, msg_recursive) = if info.node.unfolded {
656                    (
657                        "Collapse",
658                        Message::GroupFold(Some(displayed_item_id)),
659                        Message::GroupFoldRecursive(Some(displayed_item_id)),
660                    )
661                } else {
662                    (
663                        "Expand",
664                        Message::GroupUnfold(Some(displayed_item_id)),
665                        Message::GroupUnfold(Some(displayed_item_id)),
666                    )
667                };
668                if ui.button(text).clicked() {
669                    ui.close_menu();
670                    msgs.push(msg)
671                }
672                if ui.button(text.to_owned() + " recursive").clicked() {
673                    ui.close_menu();
674                    msgs.push(msg_recursive)
675                }
676            }
677        });
678    }
679
680    fn add_format_menu(
681        &self,
682        displayed_field_ref: &DisplayedFieldRef,
683        displayed_item: &DisplayedItem,
684        path: &FieldRef,
685        msgs: &mut Vec<Message>,
686        ui: &mut Ui,
687    ) {
688        // Should not call this unless a variable is selected, and, hence, a VCD is loaded
689        let Some(waves) = &self.user.waves else {
690            return;
691        };
692
693        let (mut preferred_translators, mut bad_translators) = if path.field.is_empty() {
694            self.translators
695                .all_translator_names()
696                .into_iter()
697                .partition(|translator_name| {
698                    let t = self.translators.get_translator(translator_name);
699
700                    if self
701                        .user
702                        .blacklisted_translators
703                        .contains(&(path.root.clone(), translator_name.to_string()))
704                    {
705                        false
706                    } else {
707                        match waves
708                            .inner
709                            .as_waves()
710                            .unwrap()
711                            .variable_meta(&path.root)
712                            .and_then(|meta| t.translates(&meta))
713                            .context(format!(
714                                "Failed to check if {translator_name} translates {:?}",
715                                path.root.full_path(),
716                            )) {
717                            Ok(TranslationPreference::Yes) => true,
718                            Ok(TranslationPreference::Prefer) => true,
719                            Ok(TranslationPreference::No) => false,
720                            Err(e) => {
721                                msgs.push(Message::BlacklistTranslator(
722                                    path.root.clone(),
723                                    translator_name.to_string(),
724                                ));
725                                msgs.push(Message::Error(e));
726                                false
727                            }
728                        }
729                    }
730                })
731        } else {
732            (self.translators.basic_translator_names(), vec![])
733        };
734
735        preferred_translators.sort_by(|a, b| numeric_sort::cmp(a, b));
736        bad_translators.sort_by(|a, b| numeric_sort::cmp(a, b));
737
738        let selected_translator = match displayed_item {
739            DisplayedItem::Variable(var) => Some(var),
740            _ => None,
741        }
742        .and_then(|displayed_variable| displayed_variable.get_format(&displayed_field_ref.field));
743
744        let mut menu_entry = |ui: &mut Ui, name: &str| {
745            ui.radio(selected_translator.is_some_and(|st| st == name), name)
746                .clicked()
747                .then(|| {
748                    ui.close_menu();
749                    msgs.push(Message::VariableFormatChange(
750                        if waves
751                            .items_tree
752                            .iter_visible_selected()
753                            .map(|node| node.item_ref)
754                            .contains(&displayed_field_ref.item)
755                        {
756                            MessageTarget::CurrentSelection
757                        } else {
758                            MessageTarget::Explicit(displayed_field_ref.clone())
759                        },
760                        name.to_string(),
761                    ));
762                });
763        };
764
765        ui.menu_button("Format", |ui| {
766            for name in preferred_translators {
767                menu_entry(ui, name);
768            }
769            if !bad_translators.is_empty() {
770                ui.separator();
771                ui.menu_button("Not recommended", |ui| {
772                    for name in bad_translators {
773                        menu_entry(ui, name);
774                    }
775                });
776            }
777        });
778    }
779}