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