rvlib/menu/
main.rs

1use crate::{
2    cfg::ExportPathConnection,
3    control::{Control, Info},
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        ui_util::text_edit_singleline,
13    },
14    tools::ToolState,
15    tools_data::{ToolSpecifics, ToolsDataMap},
16    util::version_label,
17};
18use egui::{Context, Popup, Response, RichText, Ui};
19use rvimage_domain::{rverr, RvResult};
20use std::{
21    f32, mem,
22    path::{Path, PathBuf},
23};
24
25use super::{
26    file_counts::Counts,
27    tools_menus::{attributes_menu, bbox_menu, brush_menu},
28};
29
30fn show_popup(msg: &str, icon: &str, info_message: Info, response: &Response) -> Info {
31    let mut new_msg = Info::None;
32    Popup::from_response(response)
33        .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
34        .show(|ui| {
35            let max_msg_len = 500;
36            let shortened_msg = if msg.len() > max_msg_len {
37                &msg[..max_msg_len]
38            } else {
39                msg
40            };
41            let mut txt = format!("{icon} {shortened_msg}");
42            ui.text_edit_multiline(&mut txt);
43            new_msg = if ui.button("Close").clicked() {
44                Info::None
45            } else {
46                info_message
47            }
48        });
49    new_msg
50}
51
52// evaluates an expression that is expected to return Result,
53// passes unpacked value to effect function in case of Ok,
54// sets according error message in case of Err.
55// Closure $f_err_cleanup will be called in case of an error.
56macro_rules! handle_error {
57    ($f_effect:expr, $f_err_cleanup:expr, $result:expr, $self:expr) => {
58        match $result {
59            Ok(r) => {
60                #[allow(clippy::redundant_closure_call)]
61                $f_effect(r);
62            }
63            Err(e) => {
64                #[allow(clippy::redundant_closure_call)]
65                $f_err_cleanup();
66                tracing::error!("{e:?}");
67                $self.info_message = Info::Error(e.to_string());
68            }
69        }
70    };
71    ($effect:expr, $result:expr, $self:expr) => {
72        handle_error!($effect, || (), $result, $self)
73    };
74    ($result:expr, $self:expr) => {
75        handle_error!(|_| {}, $result, $self);
76    };
77}
78
79pub struct ToolSelectMenu {
80    are_tools_active: bool, // can deactivate all tools, overrides activated_tool
81    recently_activated_tool: Option<usize>,
82}
83impl ToolSelectMenu {
84    fn new() -> Self {
85        Self {
86            are_tools_active: true,
87            recently_activated_tool: None,
88        }
89    }
90    pub fn recently_clicked_tool(&self) -> Option<usize> {
91        self.recently_activated_tool
92    }
93    pub fn ui(
94        &mut self,
95        ui: &mut Ui,
96        tools: &mut [ToolState],
97        tools_menu_map: &mut ToolsDataMap,
98    ) -> RvResult<()> {
99        ui.horizontal_top(|ui| {
100            self.recently_activated_tool = tools
101                .iter_mut()
102                .enumerate()
103                .filter(|(_, t)| !t.is_always_active())
104                .find(|(_, t)| ui.selectable_label(t.is_active(), t.button_label).clicked())
105                .map(|(i, _)| i);
106        });
107        for v in tools_menu_map.values_mut().filter(|v| v.menu_active) {
108            let mut v_result = Err(rverr!("Tool menu not implemented"));
109            egui::ScrollArea::vertical()
110                .auto_shrink([false, false])
111                .max_height(f32::INFINITY)
112                .show(ui, |ui| {
113                    let tmp = match &mut v.specifics {
114                        ToolSpecifics::Bbox(x) => bbox_menu(
115                            ui,
116                            v.menu_active,
117                            mem::take(x),
118                            &mut self.are_tools_active,
119                            v.visible_inactive_tools.clone(),
120                        ),
121                        ToolSpecifics::Brush(x) => brush_menu(
122                            ui,
123                            v.menu_active,
124                            mem::take(x),
125                            &mut self.are_tools_active,
126                            v.visible_inactive_tools.clone(),
127                        ),
128                        ToolSpecifics::Attributes(x) => attributes_menu(
129                            ui,
130                            v.menu_active,
131                            mem::take(x),
132                            &mut self.are_tools_active,
133                        ),
134                        _ => Ok(mem::take(v)),
135                    };
136                    v_result = tmp;
137                });
138            *v = v_result?;
139        }
140        Ok(())
141    }
142}
143impl Default for ToolSelectMenu {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149fn save_dialog_in_prjfolder(prj_path: &Path, opened_folder: Option<&str>) -> Option<PathBuf> {
150    let filename = get_prj_name(prj_path, opened_folder);
151    let dialog = rfd::FileDialog::new();
152    let dialog = if let Some(folder) = prj_path.parent() {
153        dialog.set_directory(folder)
154    } else {
155        dialog
156    };
157    dialog
158        .add_filter("project files", &["json", "rvi"])
159        .set_file_name(filename)
160        .save_file()
161}
162
163#[derive(Default)]
164pub struct TextBuffers {
165    pub filter_string: String,
166    pub label_propagation: String,
167    pub label_deletion: String,
168    pub import_coco_from_ssh_path: String,
169}
170
171pub struct Menu {
172    window_open: bool, // Only show the egui window when true.
173    info_message: Info,
174    are_tools_active: bool,
175    toggle_clear_cache_on_close: bool,
176    scroll_offset: f32,
177    stats: Counts,
178    text_buffers: TextBuffers,
179    show_file_idx: bool,
180    annotations_menu_params: AnnotationsParams,
181    import_coco_from_ssh: bool,
182    new_file_idx_annoplot: Option<usize>,
183}
184
185impl Menu {
186    fn new() -> Self {
187        let text_buffers = TextBuffers {
188            filter_string: "".to_string(),
189            label_propagation: "".to_string(),
190            label_deletion: "".to_string(),
191            import_coco_from_ssh_path: "path on ssh server".to_string(),
192        };
193        Self {
194            window_open: true,
195            info_message: Info::None,
196            are_tools_active: true,
197            toggle_clear_cache_on_close: false,
198            scroll_offset: 0.0,
199            stats: Counts::default(),
200            text_buffers,
201            show_file_idx: true,
202            annotations_menu_params: AnnotationsParams::default(),
203            import_coco_from_ssh: false,
204            new_file_idx_annoplot: None,
205        }
206    }
207    pub fn popup(&mut self, info: Info) {
208        self.info_message = info;
209    }
210
211    pub fn toggle(&mut self) {
212        if self.window_open {
213            self.are_tools_active = true;
214            self.window_open = false;
215        } else {
216            self.window_open = true;
217        }
218    }
219
220    pub fn reload_opened_folder(&mut self, ctrl: &mut Control) {
221        if let Err(e) = ctrl.load_opened_folder_content(ctrl.cfg.prj.sort_params) {
222            self.info_message = Info::Error(format!("{e:?}"));
223        }
224    }
225
226    pub fn show_info(&mut self, msg: Info) {
227        self.info_message = msg;
228    }
229
230    /// Returns true if a project was loaded and if a new file load was triggered
231    pub fn ui(
232        &mut self,
233        ctx: &Context,
234        ctrl: &mut Control,
235        tools_data_map: &mut ToolsDataMap,
236        active_tool_name: Option<&str>,
237    ) -> bool {
238        let mut project_loaded = false;
239        egui::TopBottomPanel::top("top-menu-bar").show(ctx, |ui| {
240            // Top row with open folder and settings button
241            egui::MenuBar::new().ui(ui, |ui| {
242                let of_response = ui.button("Open Folder");
243                let pick_result = open_folder::pick_by_connection(ctrl, &of_response);
244                handle_error!(pick_result, self);
245                ui.menu_button("Project", |ui| {
246                    if ui
247                        .button("New")
248                        .on_hover_text(
249                            "Right click, old project will be closed, unsaved data will get lost",
250                        )
251                        .secondary_clicked()
252                    {
253                        *tools_data_map = ctrl.new_prj();
254                        ui.close();
255                    }
256                    if ui.button("Load").clicked() {
257                        let prj_path = rfd::FileDialog::new()
258                            .add_filter("project files", &["json", "rvi"])
259                            .pick_file();
260                        if let Some(prj_path) = prj_path {
261                            handle_error!(
262                                |tdm| {
263                                    *tools_data_map = tdm;
264                                    project_loaded = true;
265                                },
266                                ctrl.load(prj_path),
267                                self
268                            );
269                        }
270                        ui.close();
271                    }
272                    if ui.button("Save").clicked() {
273                        let prj_path = save_dialog_in_prjfolder(
274                            ctrl.cfg.current_prj_path(),
275                            ctrl.opened_folder_label(),
276                        );
277
278                        if let Some(prj_path) = prj_path {
279                            handle_error!(ctrl.save(prj_path, tools_data_map, true), self);
280                        }
281                        ui.close();
282                    }
283                    ui.separator();
284                    ui.label("Import ...");
285                    if ui.button("... Annotations").clicked() {
286                        let prj_path = rfd::FileDialog::new()
287                            .set_title("Import Annotations from Project")
288                            .add_filter("project files", &["json", "rvi"])
289                            .pick_file();
290                        if let Some(prj_path) = prj_path {
291                            handle_error!(
292                                |()| {
293                                    project_loaded = true;
294                                },
295                                ctrl.import_annos(&prj_path, tools_data_map),
296                                self
297                            );
298                        }
299                        ui.close();
300                    }
301                    if ui.button("... Settings").clicked() {
302                        let prj_path = rfd::FileDialog::new()
303                            .set_title("Import Settings from Project")
304                            .add_filter("project files", &["json", "rvi"])
305                            .pick_file();
306                        if let Some(prj_path) = prj_path {
307                            handle_error!(
308                                |()| {
309                                    project_loaded = true;
310                                },
311                                ctrl.import_settings(&prj_path),
312                                self
313                            );
314                        }
315                        ui.close();
316                    }
317                    if ui.button("... Annotations and Settings").clicked() {
318                        let prj_path = rfd::FileDialog::new()
319                            .set_title("Import Annotations and Settings from Project")
320                            .add_filter("project files", &["json", "rvi"])
321                            .pick_file();
322                        if let Some(prj_path) = prj_path {
323                            handle_error!(
324                                |()| {
325                                    project_loaded = true;
326                                },
327                                ctrl.import_both(&prj_path, tools_data_map),
328                                self
329                            );
330                        }
331                        ui.close();
332                    }
333                    ui.horizontal(|ui| {
334                        if ui.button("... Annotations from COCO file").clicked() {
335                            let coco_path = if !self.import_coco_from_ssh {
336                                rfd::FileDialog::new()
337                                    .set_title("Annotations from COCO file")
338                                    .add_filter("coco files", &["json"])
339                                    .pick_file()
340                                    .and_then(|p| path_to_str(&p).ok().map(|s| s.to_string()))
341                            } else {
342                                Some(self.text_buffers.import_coco_from_ssh_path.clone())
343                            };
344                            if let Some(coco_path) = coco_path {
345                                handle_error!(
346                                    |()| {
347                                        project_loaded = true;
348                                    },
349                                    ctrl.import_from_coco(
350                                        &coco_path,
351                                        tools_data_map,
352                                        if self.import_coco_from_ssh {
353                                            ExportPathConnection::Ssh
354                                        } else {
355                                            ExportPathConnection::Local
356                                        }
357                                    ),
358                                    self
359                                );
360                            }
361                            ui.close();
362                        }
363                        ui.checkbox(&mut self.import_coco_from_ssh, "ssh")
364                    });
365
366                    if self.import_coco_from_ssh {
367                        text_edit_singleline(
368                            ui,
369                            &mut self.text_buffers.import_coco_from_ssh_path,
370                            &mut self.are_tools_active,
371                        );
372                    }
373                });
374
375                let autosave_gui = AutosaveMenu::new(
376                    ctrl,
377                    tools_data_map,
378                    &mut project_loaded,
379                    &mut self.are_tools_active,
380                    &mut self.annotations_menu_params,
381                    &mut self.new_file_idx_annoplot,
382                );
383                ui.add(autosave_gui);
384                ctrl.paths_navigator
385                    .select_label_idx(self.new_file_idx_annoplot);
386
387                let cfg_gui = CfgMenu::new(
388                    &mut ctrl.cfg,
389                    &mut self.are_tools_active,
390                    &mut self.toggle_clear_cache_on_close,
391                );
392                ui.add(cfg_gui);
393                if self.toggle_clear_cache_on_close {
394                    if let Some(reader) = &mut ctrl.reader {
395                        reader.toggle_clear_cache_on_close();
396                    }
397                    self.toggle_clear_cache_on_close = false;
398                }
399
400                ui.menu_button("Help", |ui| {
401                    ui.label("RV Image\n");
402                    const CODE: &str = env!("CARGO_PKG_REPOSITORY");
403                    let version_label = version_label();
404                    ui.label(version_label);
405                    if let Some(reader) = &mut ctrl.reader {
406                        ui.label("cache size in mb");
407                        ui.label(
408                            egui::RichText::new(format!("{:.3}", reader.cache_size_in_mb()))
409                                .monospace(),
410                        );
411                        ui.label("Hit F5 to clear the cache.");
412                        ui.label("");
413                    }
414                    ui.hyperlink_to("Docs, License, and Code", CODE);
415                    if ui.button("Export Logs").clicked() {
416                        let log_export_dst = rfd::FileDialog::new()
417                            .add_filter("zip", &["zip"])
418                            .set_file_name("logs.zip")
419                            .save_file();
420
421                        ctrl.log_export_path = log_export_dst;
422                        ui.close();
423                    }
424                    let resp_close = ui.button("Close");
425                    if resp_close.clicked() {
426                        ui.close();
427                    }
428                });
429            });
430        });
431        egui::SidePanel::left("left-main-menu").show(ctx, |ui| {
432            let mut connected = false;
433            handle_error!(
434                |con| {
435                    connected = con;
436                },
437                ctrl.check_if_connected(ctrl.cfg.prj.sort_params),
438                self
439            );
440            if connected {
441                ui.label(
442                    RichText::from(ctrl.opened_folder_label().unwrap_or(""))
443                        .text_style(egui::TextStyle::Monospace),
444                );
445            } else {
446                ui.label(RichText::from("Connecting...").text_style(egui::TextStyle::Monospace));
447            }
448
449            let filter_txt_field = text_edit_singleline(
450                ui,
451                &mut self.text_buffers.filter_string,
452                &mut self.are_tools_active,
453            );
454
455            if filter_txt_field.changed() {
456                handle_error!(
457                    ctrl.paths_navigator.filter(
458                        &self.text_buffers.filter_string,
459                        tools_data_map,
460                        active_tool_name
461                    ),
462                    self
463                );
464            }
465            // Popup for error messages
466            self.info_message = match &self.info_message {
467                Info::Warning(msg) => {
468                    show_popup(msg, "❕", self.info_message.clone(), &filter_txt_field)
469                }
470                Info::Error(msg) => {
471                    show_popup(msg, "❌", self.info_message.clone(), &filter_txt_field)
472                }
473                Info::None => Info::None,
474            };
475
476            // scroll area showing image file names
477            let scroll_to_selected = ctrl.paths_navigator.scroll_to_selected_label();
478            let mut filtered_label_selected_idx = ctrl.paths_navigator.file_label_selected_idx();
479            if let Some(ps) = &ctrl.paths_navigator.paths_selector() {
480                ui.checkbox(&mut self.show_file_idx, "show file index");
481
482                self.scroll_offset = menu::scroll_area::scroll_area_file_selector(
483                    ui,
484                    &mut filtered_label_selected_idx,
485                    ps,
486                    ctrl.file_info_selected.as_deref(),
487                    scroll_to_selected,
488                    self.scroll_offset,
489                    self.show_file_idx,
490                );
491                ctrl.paths_navigator.deactivate_scroll_to_selected_label();
492                if ctrl.paths_navigator.file_label_selected_idx() != filtered_label_selected_idx {
493                    ctrl.paths_navigator
494                        .select_label_idx(filtered_label_selected_idx);
495                }
496            }
497
498            ui.separator();
499            let mut sort_params = ctrl.cfg.prj.sort_params;
500            handle_error!(
501                labels_and_sorting(
502                    ui,
503                    &mut sort_params,
504                    ctrl,
505                    tools_data_map,
506                    &mut self.text_buffers,
507                    &mut self.stats,
508                ),
509                self
510            );
511            ctrl.cfg.prj.sort_params = sort_params;
512        });
513        project_loaded
514    }
515}
516
517impl Default for Menu {
518    fn default() -> Self {
519        Self::new()
520    }
521}
522
523pub fn are_tools_active(menu: &Menu, tsm: &ToolSelectMenu) -> bool {
524    menu.are_tools_active && tsm.are_tools_active
525}