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::text_edit_singleline,
14        wand_many::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_inside(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                    if ui.button("Predict").clicked() {
417                        self.show_wandmany = true;
418                    }
419                    ui.separator();
420                    if ui.button("Start Wand Server").clicked() {
421                        handle_error!(ctrl.start_wandserver(), self);
422                    }
423                    if ui.button("Cleanup Wand Server").clicked() {
424                        handle_error!(ctrl.cleanup_wandserver(), self);
425                    }
426                });
427                if self.show_wandmany
428                    && let Some((files, folders_to_exclude)) = wand_many_menu(
429                        ui,
430                        &mut ctrl.data.wand_many,
431                        &mut ctrl.cfg.prj.wand_many,
432                        &mut self.are_tools_active,
433                        &mut self.text_buffers.wand_many_buffers,
434                        &mut self.show_wandmany,
435                        ctrl.paths_navigator.paths_selector(),
436                    )
437                {
438                    ctrl.submit_files_to_wand(
439                        tools_data_map,
440                        &files,
441                        ctrl.file_selected_idx,
442                        &folders_to_exclude,
443                    );
444                }
445                ui.menu_button("Help", |ui| {
446                    ui.label("RV Image\n");
447                    const CODE: &str = env!("CARGO_PKG_REPOSITORY");
448                    let version_label = version_label();
449                    ui.label(version_label);
450                    if let Some(reader) = &mut ctrl.reader {
451                        ui.label("cache size in mb");
452                        ui.label(
453                            egui::RichText::new(format!("{:.3}", reader.cache_size_in_mb()))
454                                .monospace(),
455                        );
456                        ui.label("Hit F5 to clear the cache.");
457                        ui.label("");
458                    }
459                    ui.hyperlink_to("Docs, License, and Code", CODE);
460                    if ui.button("Export Logs").clicked() {
461                        let log_export_dst = rfd::FileDialog::new()
462                            .add_filter("zip", &["zip"])
463                            .set_file_name("logs.zip")
464                            .save_file();
465
466                        ctrl.log_export_path = log_export_dst;
467                        ui.close();
468                    }
469                    let resp_close = ui.button("Close");
470                    if resp_close.clicked() {
471                        ui.close();
472                    }
473                });
474            });
475        });
476        // Show project settings import modal when a file was picked.
477        if self.prj_import_path.is_some() {
478            egui::modal::Modal::new(egui::Id::new("prj-import-section")).show(ui.ctx(), |ui| {
479                ui.label("Project Settings Import");
480                if let Some(p) = &self.prj_import_path {
481                    ui.label(RichText::new(format!("{}", p.display())).monospace());
482                }
483                let mut changed = false;
484                ui.horizontal(|ui| {
485                    ui.vertical(|ui| {
486                        if ui
487                            .radio_value(
488                                &mut self.prj_import_section,
489                                PrjSettingImportSection::All,
490                                "All",
491                            )
492                            .clicked()
493                        {
494                            changed = true;
495                        }
496                        if ui
497                            .radio_value(
498                                &mut self.prj_import_section,
499                                PrjSettingImportSection::Connection,
500                                "Connection",
501                            )
502                            .clicked()
503                        {
504                            changed = true;
505                        }
506                        if ui
507                            .radio_value(
508                                &mut self.prj_import_section,
509                                PrjSettingImportSection::WandServer,
510                                "Wand Server",
511                            )
512                            .clicked()
513                        {
514                            changed = true;
515                        }
516                    });
517                    if let Some(p) = &self.prj_import_path {
518                        if self.prj_settings_for_display.is_none() || changed {
519                            self.prj_settings_for_display =
520                                Some(ctrl.show_settings(p, self.prj_import_section));
521                        }
522                        if let Some(settings_str) = &mut self.prj_settings_for_display {
523                            egui::ScrollArea::vertical()
524                                .min_scrolled_height(500.0)
525                                .show(ui, |ui| {
526                                    ui.add(
527                                        egui::TextEdit::multiline(settings_str)
528                                            .font(egui::FontSelection::Style(
529                                                egui::TextStyle::Monospace,
530                                            ))
531                                            .desired_width(f32::INFINITY)
532                                            .desired_rows(20) // control height
533                                            .interactive(false), // make it non-editable
534                                    );
535                                });
536                        }
537                    }
538                });
539                ui.horizontal(|ui| {
540                    let import_enabled = self.prj_import_path.is_some();
541                    if import_enabled
542                        && ui.button("Import").clicked()
543                        && let Some(prj_path) = self.prj_import_path.take()
544                    {
545                        handle_error!(
546                            |()| {
547                                project_loaded = true;
548                            },
549                            ctrl.import_settings(&prj_path, self.prj_import_section),
550                            self
551                        );
552                    }
553                    if ui.button("Cancel").clicked() {
554                        self.prj_import_path = None;
555                    }
556                });
557            });
558        }
559        egui::Panel::left("left-main-menu").show_inside(ui, |ui| {
560            let mut connected = false;
561            handle_error!(
562                |con| {
563                    connected = con;
564                },
565                ctrl.check_if_connected(ctrl.cfg.prj.sort_params),
566                self
567            );
568            if connected {
569                ui.label(
570                    RichText::from(ctrl.opened_folder_label().unwrap_or(""))
571                        .text_style(egui::TextStyle::Monospace),
572                );
573            } else {
574                ui.label(RichText::from("Connecting...").text_style(egui::TextStyle::Monospace));
575            }
576
577            let filter_txt_field =
578                text_edit_singleline(ui, &mut ctrl.data.filter_buffer, &mut self.are_tools_active);
579
580            if filter_txt_field.changed() {
581                handle_error!(
582                    ctrl.paths_navigator.filter(
583                        &ctrl.data.filter_buffer,
584                        tools_data_map,
585                        active_tool_name
586                    ),
587                    self
588                );
589            }
590            // Popup for error messages
591            self.info_message = match &self.info_message {
592                Info::Warning(msg) => {
593                    show_popup(msg, "❕", self.info_message.clone(), &filter_txt_field)
594                }
595                Info::Error(msg) => {
596                    show_popup(msg, "❌", self.info_message.clone(), &filter_txt_field)
597                }
598                Info::None => Info::None,
599            };
600
601            // scroll area showing image file names
602            let scroll_to_selected = ctrl.paths_navigator.scroll_to_selected_label();
603            let mut filtered_label_selected_idx = ctrl.paths_navigator.file_label_selected_idx();
604            if let Some(ps) = &ctrl.paths_navigator.paths_selector() {
605                ui.checkbox(&mut self.show_file_options.idx, "show file index");
606                ui.checkbox(
607                    &mut self.show_file_options.parentfolder,
608                    "show parent folder",
609                );
610
611                self.scroll_offset = menu::scroll_area::scroll_area_file_selector(
612                    ui,
613                    &mut filtered_label_selected_idx,
614                    ps,
615                    ctrl.file_info_selected.as_deref(),
616                    scroll_to_selected,
617                    self.scroll_offset,
618                    self.show_file_options,
619                );
620                ctrl.paths_navigator.deactivate_scroll_to_selected_label();
621                if ctrl.paths_navigator.file_label_selected_idx() != filtered_label_selected_idx {
622                    ctrl.paths_navigator
623                        .select_label_idx(filtered_label_selected_idx);
624                }
625            }
626
627            ui.separator();
628            let mut sort_params = ctrl.cfg.prj.sort_params;
629            handle_error!(
630                labels_and_sorting(ui, &mut sort_params, ctrl, tools_data_map, &mut self.stats,),
631                self
632            );
633            ctrl.cfg.prj.sort_params = sort_params;
634            if ui
635                .button("Pre-cache filtered images")
636                .on_hover_text("double click")
637                .double_clicked()
638            {
639                self.cache_all_progress = Some(0.0);
640            }
641            if self.cache_all_progress.is_some() {
642                handle_error!(
643                    |prgs| {
644                        self.cache_all_progress = prgs;
645                    },
646                    ctrl.cache_all_filtered(),
647                    self
648                );
649                if let Some(prgs) = &self.cache_all_progress {
650                    ui.add(
651                        egui::ProgressBar::new(*prgs).text(
652                            RichText::new(format!(
653                                "loading images into cache {:2}%",
654                                (prgs * 100.0).floor() as u8
655                            ))
656                            .monospace(),
657                        ),
658                    );
659                }
660                if self.cache_all_progress > Some(0.999) {
661                    self.cache_all_progress = None;
662                }
663            }
664        });
665        project_loaded
666    }
667}
668
669impl Default for Menu {
670    fn default() -> Self {
671        Self::new()
672    }
673}
674
675pub fn are_tools_active(menu: &Menu, tsm: &ToolSelectMenu) -> bool {
676    menu.are_tools_active && tsm.are_tools_active
677}