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