Skip to main content

rvlib/menu/
main.rs

1use crate::{
2    cfg::ExportPathConnection,
3    control::{Control, Info, PrjSettingImportSection},
4    file_util::{get_prj_name, path_to_str},
5    image_reader::LoadImageForGui,
6    menu::{
7        self,
8        annotations_menu::{AnnotationsParams, AutosaveMenu},
9        cfg_menu::CfgMenu,
10        file_counts::labels_and_sorting,
11        open_folder,
12        scroll_area::ShowFileOptions,
13        ui_util::{button_confirmed, text_edit_singleline},
14        wand_many::{self, WandManyMenuResult, wand_many_menu},
15    },
16    tools::ToolState,
17    tools_data::{ToolSpecifics, ToolsDataMap},
18    util::version_label,
19};
20use core::f32;
21use egui::{Popup, Response, RichText, Ui};
22use rvimage_domain::{RvResult, rverr};
23use std::{
24    mem,
25    path::{Path, PathBuf},
26};
27
28use super::{
29    file_counts::Counts,
30    tools_menus::{attributes_menu, bbox_menu, brush_menu},
31};
32
33fn show_popup(msg: &str, icon: &str, info_message: Info, response: &Response) -> Info {
34    let mut new_msg = Info::None;
35    Popup::from_response(response)
36        .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
37        .show(|ui| {
38            let max_msg_len = 500;
39            let shortened_msg = if msg.len() > max_msg_len {
40                &msg[..max_msg_len]
41            } else {
42                msg
43            };
44            let mut txt = format!("{icon} {shortened_msg}");
45            ui.text_edit_multiline(&mut txt);
46            new_msg = if ui.button("Close").clicked() {
47                Info::None
48            } else {
49                info_message
50            }
51        });
52    new_msg
53}
54
55// evaluates an expression that is expected to return Result,
56// passes unpacked value to effect function in case of Ok,
57// sets according error message in case of Err.
58// Closure $f_err_cleanup will be called in case of an error.
59macro_rules! handle_error {
60    ($f_effect:expr, $f_err_cleanup:expr, $result:expr, $self:expr) => {
61        match $result {
62            Ok(r) => {
63                #[allow(clippy::redundant_closure_call)]
64                $f_effect(r);
65            }
66            Err(e) => {
67                #[allow(clippy::redundant_closure_call)]
68                $f_err_cleanup();
69                tracing::error!("{e:?}");
70                $self.info_message = Info::Error(e.to_string());
71            }
72        }
73    };
74    ($effect:expr, $result:expr, $self:expr) => {
75        handle_error!($effect, || (), $result, $self)
76    };
77    ($result:expr, $self:expr) => {
78        handle_error!(|_| {}, $result, $self);
79    };
80}
81
82pub struct ToolSelectMenu {
83    are_tools_active: bool, // can deactivate all tools, overrides activated_tool
84    recently_activated_tool: Option<usize>,
85}
86impl ToolSelectMenu {
87    fn new() -> Self {
88        Self {
89            are_tools_active: true,
90            recently_activated_tool: None,
91        }
92    }
93    pub fn recently_clicked_tool(&self) -> Option<usize> {
94        self.recently_activated_tool
95    }
96    pub fn ui(
97        &mut self,
98        ui: &mut Ui,
99        tools: &mut [ToolState],
100        tools_menu_map: &mut ToolsDataMap,
101    ) -> RvResult<()> {
102        // recomputed every frame, widgets deactivate the tools while focused
103        self.are_tools_active = true;
104        ui.horizontal_top(|ui| {
105            self.recently_activated_tool = tools
106                .iter_mut()
107                .enumerate()
108                .filter(|(_, t)| !t.is_always_active())
109                .find(|(_, t)| ui.selectable_label(t.is_active(), t.button_label).clicked())
110                .map(|(i, _)| i);
111        });
112        for v in tools_menu_map.values_mut().filter(|v| v.menu_active) {
113            let mut v_result = Err(rverr!("Tool menu not implemented"));
114            egui::ScrollArea::vertical()
115                .auto_shrink([false, false])
116                .max_height(f32::INFINITY)
117                .show(ui, |ui| {
118                    let tmp = match &mut v.specifics {
119                        ToolSpecifics::Bbox(x) => bbox_menu(
120                            ui,
121                            v.menu_active,
122                            mem::take(x),
123                            &mut self.are_tools_active,
124                            v.visible_inactive_tools.clone(),
125                        ),
126                        ToolSpecifics::Brush(x) => brush_menu(
127                            ui,
128                            v.menu_active,
129                            mem::take(x),
130                            &mut self.are_tools_active,
131                            v.visible_inactive_tools.clone(),
132                        ),
133                        ToolSpecifics::Attributes(x) => attributes_menu(
134                            ui,
135                            v.menu_active,
136                            mem::take(x),
137                            &mut self.are_tools_active,
138                        ),
139                        _ => Ok(mem::take(v)),
140                    };
141                    v_result = tmp;
142                });
143            *v = v_result?;
144        }
145        Ok(())
146    }
147}
148impl Default for ToolSelectMenu {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154fn save_dialog_in_prjfolder(prj_path: &Path, opened_folder: Option<&str>) -> Option<PathBuf> {
155    let filename = get_prj_name(prj_path, opened_folder);
156    let dialog = rfd::FileDialog::new();
157    let dialog = if let Some(folder) = prj_path.parent() {
158        dialog.set_directory(folder)
159    } else {
160        dialog
161    };
162    dialog
163        .add_filter("project files", &["json", "rvi"])
164        .set_file_name(filename)
165        .save_file()
166}
167
168#[derive(Default)]
169pub struct WandManyMenuBuffers {
170    pub comment: String,
171    pub exclfolder: String,
172    pub timeout: String,
173}
174#[derive(Default)]
175pub struct TextBuffers {
176    pub label_propagation: String,
177    pub label_deletion: String,
178    pub import_coco_from_ssh_path: String,
179    pub wand_many_buffers: WandManyMenuBuffers,
180}
181
182pub struct Menu {
183    window_open: bool, // Only show the egui window when true.
184    info_message: Info,
185    are_tools_active: bool,
186    toggle_clear_cache_on_close: bool,
187    scroll_offset: f32,
188    stats: Counts,
189    text_buffers: TextBuffers,
190    show_file_options: ShowFileOptions,
191    annotations_menu_params: AnnotationsParams,
192    import_coco_from_ssh: bool,
193    new_file_idx_annoplot: Option<usize>,
194    prj_import_path: Option<PathBuf>,
195    prj_import_section: PrjSettingImportSection,
196    prj_settings_for_display: Option<String>,
197    cache_all_progress: Option<f32>,
198    show_wandmany: bool,
199}
200
201impl Menu {
202    fn new() -> Self {
203        let text_buffers = TextBuffers {
204            label_propagation: "".into(),
205            label_deletion: "".into(),
206            import_coco_from_ssh_path: "path on ssh server".into(),
207            wand_many_buffers: WandManyMenuBuffers::default(),
208        };
209        Self {
210            window_open: true,
211            info_message: Info::None,
212            are_tools_active: true,
213            toggle_clear_cache_on_close: false,
214            scroll_offset: 0.0,
215            stats: Counts::default(),
216            text_buffers,
217            show_file_options: ShowFileOptions::default(),
218            annotations_menu_params: AnnotationsParams::default(),
219            import_coco_from_ssh: false,
220            new_file_idx_annoplot: None,
221            prj_import_path: None,
222            prj_import_section: PrjSettingImportSection::All,
223            prj_settings_for_display: None,
224            cache_all_progress: None,
225            show_wandmany: false,
226        }
227    }
228    pub fn popup(&mut self, info: Info) {
229        self.info_message = info;
230    }
231
232    pub fn toggle(&mut self) {
233        if self.window_open {
234            self.are_tools_active = true;
235            self.window_open = false;
236        } else {
237            self.window_open = true;
238        }
239    }
240
241    pub fn reload_opened_folder(&mut self, ctrl: &mut Control) {
242        if let Err(e) = ctrl.load_opened_folder_content(ctrl.cfg.prj.sort_params) {
243            self.info_message = Info::Error(format!("{e:?}"));
244        }
245    }
246
247    pub fn show_info(&mut self, msg: Info) {
248        self.info_message = msg;
249    }
250
251    /// Returns true if a project was loaded and if a new file load was triggered
252    pub fn ui(
253        &mut self,
254        ui: &mut egui::Ui,
255        ctrl: &mut Control,
256        tools_data_map: &mut ToolsDataMap,
257        active_tool_name: Option<&str>,
258    ) -> bool {
259        let mut project_loaded = false;
260        // recomputed every frame, widgets deactivate the tools while focused
261        self.are_tools_active = true;
262        egui::Panel::top("top-menu-panel").show(ui, |ui| {
263            // Top row with open folder and settings button
264            egui::MenuBar::new().ui(ui, |ui| {
265                let of_response = ui.button("Open Folder");
266                let pick_result = open_folder::pick_by_connection(ctrl, &of_response);
267                handle_error!(pick_result, self);
268                ui.menu_button("Project", |ui| {
269                    if ui
270                        .button("New")
271                        .on_hover_text(
272                            "Right click, old project will be closed, unsaved data will get lost",
273                        )
274                        .secondary_clicked()
275                    {
276                        *tools_data_map = ctrl.new_prj();
277                        ui.close();
278                    }
279                    if ui.button("Load").clicked() {
280                        let prj_path = rfd::FileDialog::new()
281                            .add_filter("project files", &["json", "rvi"])
282                            .pick_file();
283                        if let Some(prj_path) = prj_path {
284                            handle_error!(
285                                |tdm| {
286                                    *tools_data_map = tdm;
287                                    project_loaded = true;
288                                },
289                                ctrl.load(prj_path),
290                                self
291                            );
292                        }
293                        ui.close();
294                    }
295                    if ui.button("Save").clicked() {
296                        let prj_path = save_dialog_in_prjfolder(
297                            ctrl.cfg.current_prj_path(),
298                            ctrl.opened_folder_label(),
299                        );
300
301                        if let Some(prj_path) = prj_path {
302                            handle_error!(ctrl.save(prj_path, tools_data_map, true), self);
303                        }
304                        ui.close();
305                    }
306                    ui.separator();
307                    ui.label("Import ...");
308                    if ui.button("... Annotations").clicked() {
309                        let prj_path = rfd::FileDialog::new()
310                            .set_title("Import Annotations from Project")
311                            .add_filter("project files", &["json", "rvi"])
312                            .pick_file();
313                        if let Some(prj_path) = prj_path {
314                            handle_error!(
315                                |()| {
316                                    project_loaded = true;
317                                },
318                                ctrl.import_annos(&prj_path, tools_data_map),
319                                self
320                            );
321                        }
322                        ui.close();
323                    }
324
325                    if ui.button("... Settings").clicked() {
326                        // First pick a project file, then open the modal to confirm import options.
327                        if let Some(prj_path) = rfd::FileDialog::new()
328                            .set_title("Pick Project to Import Settings From")
329                            .add_filter("project files", &["json", "rvi"])
330                            .pick_file()
331                        {
332                            self.prj_import_path = Some(prj_path);
333                        }
334                        ui.close();
335                    }
336                    if ui.button("... Annotations and Settings").clicked() {
337                        let prj_path = rfd::FileDialog::new()
338                            .set_title("Import Annotations and Settings from Project")
339                            .add_filter("project files", &["json", "rvi"])
340                            .pick_file();
341                        if let Some(prj_path) = prj_path {
342                            handle_error!(
343                                |()| {
344                                    project_loaded = true;
345                                },
346                                ctrl.import_both(&prj_path, tools_data_map),
347                                self
348                            );
349                        }
350                        ui.close();
351                    }
352                    ui.horizontal(|ui| {
353                        if ui.button("... Annotations from COCO file").clicked() {
354                            let coco_path = if !self.import_coco_from_ssh {
355                                rfd::FileDialog::new()
356                                    .set_title("Annotations from COCO file")
357                                    .add_filter("coco files", &["json"])
358                                    .pick_file()
359                                    .and_then(|p| path_to_str(&p).ok().map(|s| s.to_string()))
360                            } else {
361                                Some(self.text_buffers.import_coco_from_ssh_path.clone())
362                            };
363                            if let Some(coco_path) = coco_path {
364                                handle_error!(
365                                    |()| {
366                                        project_loaded = true;
367                                    },
368                                    ctrl.import_from_coco(
369                                        &coco_path,
370                                        tools_data_map,
371                                        if self.import_coco_from_ssh {
372                                            ExportPathConnection::Ssh
373                                        } else {
374                                            ExportPathConnection::Local
375                                        }
376                                    ),
377                                    self
378                                );
379                            }
380                            ui.close();
381                        }
382                        ui.checkbox(&mut self.import_coco_from_ssh, "ssh")
383                    });
384
385                    if self.import_coco_from_ssh {
386                        text_edit_singleline(
387                            ui,
388                            &mut self.text_buffers.import_coco_from_ssh_path,
389                            &mut self.are_tools_active,
390                        );
391                    }
392                });
393
394                let autosave_gui = AutosaveMenu::new(
395                    ctrl,
396                    tools_data_map,
397                    &mut project_loaded,
398                    &mut self.are_tools_active,
399                    &mut self.annotations_menu_params,
400                    &mut self.new_file_idx_annoplot,
401                );
402                ui.add(autosave_gui);
403                ctrl.paths_navigator
404                    .select_label_idx(self.new_file_idx_annoplot);
405
406                let cfg_gui = CfgMenu::new(
407                    &mut ctrl.cfg,
408                    &mut self.are_tools_active,
409                    &mut self.toggle_clear_cache_on_close,
410                );
411                ui.add(cfg_gui);
412                if self.toggle_clear_cache_on_close {
413                    if let Some(reader) = &mut ctrl.reader {
414                        reader.toggle_clear_cache_on_close();
415                    }
416                    self.toggle_clear_cache_on_close = false;
417                }
418
419                ui.menu_button("Wand", |ui| {
420                    let to_submit = wand_many::predict_button(
421                        ui,
422                        &ctrl.data.wand_many,
423                        ctrl.paths_navigator.paths_selector(),
424                    );
425                    if let Some(WandManyMenuResult::Submit((files, folders_to_exclude))) = to_submit
426                    {
427                        ctrl.submit_files_to_wand(
428                            tools_data_map,
429                            &files,
430                            ctrl.file_selected_idx,
431                            &folders_to_exclude,
432                        );
433                    } else if let Some(WandManyMenuResult::Cancel) = to_submit {
434                        ctrl.cancel_wandmany();
435                    }
436                    if ui.button("Settings").clicked() {
437                        self.show_wandmany = true;
438                    }
439                    ui.separator();
440                    if ui.button("Start Wand Server").clicked() {
441                        handle_error!(ctrl.start_wandserver(), self);
442                    }
443                    if ui.button("Cleanup Wand Server").clicked() {
444                        handle_error!(ctrl.cleanup_wandserver(), self);
445                    }
446                });
447                if self.show_wandmany {
448                    ctrl.data.wand_many.is_wandmany_running = ctrl.is_wandmany_running();
449                    let result = wand_many_menu(
450                        ui,
451                        &mut ctrl.data.wand_many,
452                        &mut ctrl.cfg.prj.wand_many,
453                        &mut self.are_tools_active,
454                        &mut self.text_buffers.wand_many_buffers,
455                        &mut self.show_wandmany,
456                        ctrl.paths_navigator.paths_selector(),
457                    );
458                    if let WandManyMenuResult::Submit((files, folders_to_exclude)) = result {
459                        ctrl.submit_files_to_wand(
460                            tools_data_map,
461                            &files,
462                            ctrl.file_selected_idx,
463                            &folders_to_exclude,
464                        );
465                    } else if let WandManyMenuResult::Cancel = result {
466                        ctrl.cancel_wandmany();
467                    }
468                }
469                ui.menu_button("Help", |ui| {
470                    ui.label("RV Image\n");
471                    const CODE: &str = env!("CARGO_PKG_REPOSITORY");
472                    let version_label = version_label();
473                    ui.label(version_label);
474                    if let Some(reader) = &mut ctrl.reader {
475                        ui.label("cache size in mb");
476                        ui.label(
477                            egui::RichText::new(format!("{:.3}", reader.cache_size_in_mb()))
478                                .monospace(),
479                        );
480                        ui.label("Hit F5 to clear the cache.");
481                        ui.label("");
482                    }
483                    ui.hyperlink_to("Docs, License, and Code", CODE);
484                    if ui.button("Export Logs").clicked() {
485                        let log_export_dst = rfd::FileDialog::new()
486                            .add_filter("zip", &["zip"])
487                            .set_file_name("logs.zip")
488                            .save_file();
489
490                        ctrl.log_export_path = log_export_dst;
491                        ui.close();
492                    }
493                    let resp_close = ui.button("Close");
494                    if resp_close.clicked() {
495                        ui.close();
496                    }
497                });
498            });
499        });
500        // Show project settings import modal when a file was picked.
501        if self.prj_import_path.is_some() {
502            egui::modal::Modal::new(egui::Id::new("prj-import-section")).show(ui.ctx(), |ui| {
503                ui.label("Project Settings Import");
504                if let Some(p) = &self.prj_import_path {
505                    ui.label(RichText::new(format!("{}", p.display())).monospace());
506                }
507                let mut changed = false;
508                ui.horizontal(|ui| {
509                    ui.vertical(|ui| {
510                        if ui
511                            .radio_value(
512                                &mut self.prj_import_section,
513                                PrjSettingImportSection::All,
514                                "All",
515                            )
516                            .clicked()
517                        {
518                            changed = true;
519                        }
520                        if ui
521                            .radio_value(
522                                &mut self.prj_import_section,
523                                PrjSettingImportSection::Connection,
524                                "Connection",
525                            )
526                            .clicked()
527                        {
528                            changed = true;
529                        }
530                        if ui
531                            .radio_value(
532                                &mut self.prj_import_section,
533                                PrjSettingImportSection::WandServer,
534                                "Wand Server",
535                            )
536                            .clicked()
537                        {
538                            changed = true;
539                        }
540                    });
541                    if let Some(p) = &self.prj_import_path {
542                        if self.prj_settings_for_display.is_none() || changed {
543                            self.prj_settings_for_display =
544                                Some(ctrl.show_settings(p, self.prj_import_section));
545                        }
546                        if let Some(settings_str) = &mut self.prj_settings_for_display {
547                            egui::ScrollArea::vertical()
548                                .min_scrolled_height(500.0)
549                                .show(ui, |ui| {
550                                    ui.add(
551                                        egui::TextEdit::multiline(settings_str)
552                                            .font(egui::FontSelection::Style(
553                                                egui::TextStyle::Monospace,
554                                            ))
555                                            .desired_width(f32::INFINITY)
556                                            .desired_rows(20) // control height
557                                            .interactive(false), // make it non-editable
558                                    );
559                                });
560                        }
561                    }
562                });
563                ui.horizontal(|ui| {
564                    let import_enabled = self.prj_import_path.is_some();
565                    if import_enabled
566                        && ui.button("Import").clicked()
567                        && let Some(prj_path) = self.prj_import_path.take()
568                    {
569                        handle_error!(
570                            |()| {
571                                project_loaded = true;
572                            },
573                            ctrl.import_settings(&prj_path, self.prj_import_section),
574                            self
575                        );
576                    }
577                    if ui.button("Cancel").clicked() {
578                        self.prj_import_path = None;
579                    }
580                });
581            });
582        }
583        egui::Panel::left("left-main-menu").show(ui, |ui| {
584            let mut connected = false;
585            handle_error!(
586                |con| {
587                    connected = con;
588                },
589                ctrl.check_if_connected(ctrl.cfg.prj.sort_params),
590                self
591            );
592            if connected {
593                ui.label(
594                    RichText::from(ctrl.opened_folder_label().unwrap_or(""))
595                        .text_style(egui::TextStyle::Monospace),
596                );
597            } else {
598                ui.label(RichText::from("Connecting...").text_style(egui::TextStyle::Monospace));
599            }
600
601            let filter_txt_field =
602                text_edit_singleline(ui, &mut ctrl.data.filter_buffer, &mut self.are_tools_active);
603
604            if filter_txt_field.changed() {
605                handle_error!(
606                    ctrl.paths_navigator.filter(
607                        &ctrl.data.filter_buffer,
608                        tools_data_map,
609                        active_tool_name
610                    ),
611                    self
612                );
613            }
614            // Popup for error messages
615            self.info_message = match &self.info_message {
616                Info::Warning(msg) => {
617                    show_popup(msg, "❕", self.info_message.clone(), &filter_txt_field)
618                }
619                Info::Error(msg) => {
620                    show_popup(msg, "❌", self.info_message.clone(), &filter_txt_field)
621                }
622                Info::None => Info::None,
623            };
624
625            // scroll area showing image file names
626            let scroll_to_selected = ctrl.paths_navigator.scroll_to_selected_label();
627            let mut filtered_label_selected_idx = ctrl.paths_navigator.file_label_selected_idx();
628            if let Some(ps) = &ctrl.paths_navigator.paths_selector() {
629                ui.checkbox(&mut self.show_file_options.idx, "show file index");
630                ui.checkbox(
631                    &mut self.show_file_options.parentfolder,
632                    "show parent folder",
633                );
634
635                self.scroll_offset = menu::scroll_area::scroll_area_file_selector(
636                    ui,
637                    &mut filtered_label_selected_idx,
638                    ps,
639                    ctrl.file_info_selected.as_deref(),
640                    scroll_to_selected,
641                    self.scroll_offset,
642                    self.show_file_options,
643                );
644                ctrl.paths_navigator.deactivate_scroll_to_selected_label();
645                if ctrl.paths_navigator.file_label_selected_idx() != filtered_label_selected_idx {
646                    ctrl.paths_navigator
647                        .select_label_idx(filtered_label_selected_idx);
648                }
649            }
650
651            ui.separator();
652            let mut sort_params = ctrl.cfg.prj.sort_params;
653            handle_error!(
654                labels_and_sorting(ui, &mut sort_params, ctrl, tools_data_map, &mut self.stats,),
655                self
656            );
657            ctrl.cfg.prj.sort_params = sort_params;
658            if button_confirmed(
659                ui,
660                "Pre-cache filtered images",
661                "Pre-cache filtered Images",
662                "Might take a while, are you sure?",
663            ) {
664                self.cache_all_progress = Some(0.0);
665            }
666            if self.cache_all_progress.is_some() {
667                handle_error!(
668                    |prgs| {
669                        self.cache_all_progress = prgs;
670                    },
671                    ctrl.cache_all_filtered(),
672                    self
673                );
674                if let Some(prgs) = &self.cache_all_progress {
675                    ui.add(
676                        egui::ProgressBar::new(*prgs).text(
677                            RichText::new(format!(
678                                "loading images into cache {:2}%",
679                                (prgs * 100.0).floor() as u8
680                            ))
681                            .monospace(),
682                        ),
683                    );
684                }
685                if self.cache_all_progress > Some(0.999) {
686                    self.cache_all_progress = None;
687                }
688            }
689        });
690        project_loaded
691    }
692}
693
694impl Default for Menu {
695    fn default() -> Self {
696        Self::new()
697    }
698}
699
700pub fn are_tools_active(menu: &Menu, tsm: &ToolSelectMenu) -> bool {
701    menu.are_tools_active && tsm.are_tools_active
702}