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    new_file_idx_annoplot: Option<usize>,
187}
188
189impl Menu {
190    fn new() -> Self {
191        let text_buffers = TextBuffers {
192            filter_string: "".to_string(),
193            label_propagation: "".to_string(),
194            label_deletion: "".to_string(),
195            import_coco_from_ssh_path: "path on ssh server".to_string(),
196        };
197        Self {
198            window_open: true,
199            info_message: Info::None,
200            are_tools_active: true,
201            toggle_clear_cache_on_close: false,
202            scroll_offset: 0.0,
203            open_folder_popup_open: false,
204            stats: Counts::default(),
205            text_buffers,
206            show_file_idx: true,
207            annotations_menu_params: AnnotationsParams::default(),
208            import_coco_from_ssh: false,
209            new_file_idx_annoplot: None,
210        }
211    }
212    pub fn popup(&mut self, info: Info) {
213        self.info_message = info;
214    }
215
216    pub fn toggle(&mut self) {
217        if self.window_open {
218            self.are_tools_active = true;
219            self.window_open = false;
220        } else {
221            self.window_open = true;
222        }
223    }
224
225    pub fn reload_opened_folder(&mut self, ctrl: &mut Control) {
226        if let Err(e) = ctrl.load_opened_folder_content(ctrl.cfg.prj.sort_params) {
227            self.info_message = Info::Error(format!("{e:?}"));
228        }
229    }
230
231    pub fn show_info(&mut self, msg: Info) {
232        self.info_message = msg;
233    }
234
235    /// Returns true if a project was loaded and if a new file load was triggered
236    pub fn ui(
237        &mut self,
238        ctx: &Context,
239        ctrl: &mut Control,
240        tools_data_map: &mut ToolsDataMap,
241        active_tool_name: Option<&str>,
242    ) -> bool {
243        let mut projected_loaded = false;
244        egui::TopBottomPanel::top("top-menu-bar").show(ctx, |ui| {
245            // Top row with open folder and settings button
246            egui::menu::bar(ui, |ui| {
247                let button_resp = open_folder::button(ui, ctrl, self.open_folder_popup_open);
248                handle_error!(
249                    |open| {
250                        self.open_folder_popup_open = open;
251                    },
252                    || self.open_folder_popup_open = false,
253                    button_resp,
254                    self
255                );
256                ui.menu_button("Project", |ui| {
257                    if ui
258                        .button("New")
259                        .on_hover_text(
260                            "Double click, old project will be closed, unsaved data will get lost",
261                        )
262                        .double_clicked()
263                    {
264                        *tools_data_map = ctrl.new_prj();
265                        ui.close_menu();
266                    }
267                    if ui.button("Load").clicked() {
268                        let prj_path = rfd::FileDialog::new()
269                            .add_filter("project files", &["json", "rvi"])
270                            .pick_file();
271                        if let Some(prj_path) = prj_path {
272                            handle_error!(
273                                |tdm| {
274                                    *tools_data_map = tdm;
275                                    projected_loaded = true;
276                                },
277                                ctrl.load(prj_path),
278                                self
279                            );
280                        }
281                        ui.close_menu();
282                    }
283                    if ui.button("Save").clicked() {
284                        let prj_path = save_dialog_in_prjfolder(
285                            ctrl.cfg.current_prj_path(),
286                            ctrl.opened_folder_label(),
287                        );
288
289                        if let Some(prj_path) = prj_path {
290                            handle_error!(ctrl.save(prj_path, tools_data_map, true), self);
291                        }
292                        ui.close_menu();
293                    }
294                    ui.separator();
295                    ui.label("Import ...");
296                    if ui.button("... Annotations").clicked() {
297                        let prj_path = rfd::FileDialog::new()
298                            .set_title("Import Annotations from Project")
299                            .add_filter("project files", &["json", "rvi"])
300                            .pick_file();
301                        if let Some(prj_path) = prj_path {
302                            handle_error!(
303                                |()| {
304                                    projected_loaded = true;
305                                },
306                                ctrl.import_annos(&prj_path, tools_data_map),
307                                self
308                            );
309                        }
310                        ui.close_menu();
311                    }
312                    if ui.button("... Settings").clicked() {
313                        let prj_path = rfd::FileDialog::new()
314                            .set_title("Import Settings from Project")
315                            .add_filter("project files", &["json", "rvi"])
316                            .pick_file();
317                        if let Some(prj_path) = prj_path {
318                            handle_error!(
319                                |()| {
320                                    projected_loaded = true;
321                                },
322                                ctrl.import_settings(&prj_path),
323                                self
324                            );
325                        }
326                        ui.close_menu();
327                    }
328                    if ui.button("... Annotations and Settings").clicked() {
329                        let prj_path = rfd::FileDialog::new()
330                            .set_title("Import Annotations and Settings from Project")
331                            .add_filter("project files", &["json", "rvi"])
332                            .pick_file();
333                        if let Some(prj_path) = prj_path {
334                            handle_error!(
335                                |()| {
336                                    projected_loaded = true;
337                                },
338                                ctrl.import_both(&prj_path, tools_data_map),
339                                self
340                            );
341                        }
342                        ui.close_menu();
343                    }
344                    ui.horizontal(|ui| {
345                        if ui.button("... Annotations from COCO file").clicked() {
346                            let coco_path = if !self.import_coco_from_ssh {
347                                rfd::FileDialog::new()
348                                    .set_title("Annotations from COCO file")
349                                    .add_filter("coco files", &["json"])
350                                    .pick_file()
351                                    .and_then(|p| path_to_str(&p).ok().map(|s| s.to_string()))
352                            } else {
353                                Some(self.text_buffers.import_coco_from_ssh_path.clone())
354                            };
355                            if let Some(coco_path) = coco_path {
356                                handle_error!(
357                                    |()| {
358                                        projected_loaded = true;
359                                    },
360                                    ctrl.import_from_coco(
361                                        &coco_path,
362                                        tools_data_map,
363                                        if self.import_coco_from_ssh {
364                                            ExportPathConnection::Ssh
365                                        } else {
366                                            ExportPathConnection::Local
367                                        }
368                                    ),
369                                    self
370                                );
371                            }
372                            ui.close_menu();
373                        }
374                        ui.checkbox(&mut self.import_coco_from_ssh, "ssh")
375                    });
376
377                    if self.import_coco_from_ssh {
378                        text_edit_singleline(
379                            ui,
380                            &mut self.text_buffers.import_coco_from_ssh_path,
381                            &mut self.are_tools_active,
382                        );
383                    }
384                });
385
386                let popup_id = ui.make_persistent_id("autosave-popup");
387                let autosave_gui = AutosaveMenu::new(
388                    popup_id,
389                    ctrl,
390                    tools_data_map,
391                    &mut projected_loaded,
392                    &mut self.are_tools_active,
393                    &mut self.annotations_menu_params,
394                    &mut self.new_file_idx_annoplot,
395                );
396                ui.add(autosave_gui);
397                ctrl.paths_navigator
398                    .select_label_idx(self.new_file_idx_annoplot);
399
400                let popup_id = ui.make_persistent_id("cfg-popup");
401                let cfg_gui = CfgMenu::new(
402                    popup_id,
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("Help", |ui| {
416                    ui.label("RV Image\n");
417                    const CODE: &str = env!("CARGO_PKG_REPOSITORY");
418                    let version_label = version_label();
419                    ui.label(version_label);
420                    if let Some(reader) = &mut ctrl.reader {
421                        ui.label("cache size in mb");
422                        ui.label(
423                            egui::RichText::new(format!("{:.3}", reader.cache_size_in_mb()))
424                                .monospace(),
425                        );
426                        ui.label("Hit F5 to clear the cache.");
427                        ui.label("");
428                    }
429                    ui.hyperlink_to("Docs, License, and Code", CODE);
430                    if ui.button("Export Logs").clicked() {
431                        let log_export_dst = rfd::FileDialog::new()
432                            .add_filter("zip", &["zip"])
433                            .set_file_name("logs.zip")
434                            .save_file();
435
436                        ctrl.log_export_path = log_export_dst;
437                        ui.close_menu();
438                    }
439                    let resp_close = ui.button("Close");
440                    if resp_close.clicked() {
441                        ui.close_menu();
442                    }
443                });
444            });
445        });
446        egui::SidePanel::left("left-main-menu").show(ctx, |ui| {
447            let mut connected = false;
448            handle_error!(
449                |con| {
450                    connected = con;
451                },
452                ctrl.check_if_connected(ctrl.cfg.prj.sort_params),
453                self
454            );
455            if connected {
456                ui.label(
457                    RichText::from(ctrl.opened_folder_label().unwrap_or(""))
458                        .text_style(egui::TextStyle::Monospace),
459                );
460            } else {
461                ui.label(RichText::from("Connecting...").text_style(egui::TextStyle::Monospace));
462            }
463
464            let filter_txt_field = text_edit_singleline(
465                ui,
466                &mut self.text_buffers.filter_string,
467                &mut self.are_tools_active,
468            );
469
470            if filter_txt_field.changed() {
471                handle_error!(
472                    ctrl.paths_navigator.filter(
473                        &self.text_buffers.filter_string,
474                        tools_data_map,
475                        active_tool_name
476                    ),
477                    self
478                );
479            }
480            // Popup for error messages
481            let popup_id = ui.make_persistent_id("info-popup");
482            self.info_message = match &self.info_message {
483                Info::Warning(msg) => show_popup(
484                    ui,
485                    msg,
486                    "❕",
487                    popup_id,
488                    self.info_message.clone(),
489                    &filter_txt_field,
490                ),
491                Info::Error(msg) => show_popup(
492                    ui,
493                    msg,
494                    "❌",
495                    popup_id,
496                    self.info_message.clone(),
497                    &filter_txt_field,
498                ),
499                Info::None => Info::None,
500            };
501
502            // scroll area showing image file names
503            let scroll_to_selected = ctrl.paths_navigator.scroll_to_selected_label();
504            let mut filtered_label_selected_idx = ctrl.paths_navigator.file_label_selected_idx();
505            if let Some(ps) = &ctrl.paths_navigator.paths_selector() {
506                ui.checkbox(&mut self.show_file_idx, "show file index");
507
508                self.scroll_offset = menu::scroll_area::scroll_area_file_selector(
509                    ui,
510                    &mut filtered_label_selected_idx,
511                    ps,
512                    ctrl.file_info_selected.as_deref(),
513                    scroll_to_selected,
514                    self.scroll_offset,
515                    self.show_file_idx,
516                );
517                ctrl.paths_navigator.deactivate_scroll_to_selected_label();
518                if ctrl.paths_navigator.file_label_selected_idx() != filtered_label_selected_idx {
519                    ctrl.paths_navigator
520                        .select_label_idx(filtered_label_selected_idx);
521                }
522            }
523
524            ui.separator();
525            let mut sort_params = ctrl.cfg.prj.sort_params;
526            handle_error!(
527                labels_and_sorting(
528                    ui,
529                    &mut sort_params,
530                    ctrl,
531                    tools_data_map,
532                    &mut self.text_buffers,
533                    &mut self.stats,
534                ),
535                self
536            );
537            ctrl.cfg.prj.sort_params = sort_params;
538        });
539        projected_loaded
540    }
541}
542
543impl Default for Menu {
544    fn default() -> Self {
545        Self::new()
546    }
547}
548
549pub fn are_tools_active(menu: &Menu, tsm: &ToolSelectMenu) -> bool {
550    menu.are_tools_active && tsm.are_tools_active
551}