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