Skip to main content

rvlib/menu/
main.rs

1use crate::{
2    cfg::ExportPathConnection,
3    control::{Control, Info, PrjSettingImportSection},
4    file_util::{get_prj_name, path_to_str},
5    image_reader::LoadImageForGui,
6    menu::{
7        self,
8        annotations_menu::{AnnotationsParams, AutosaveMenu},
9        cfg_menu::CfgMenu,
10        file_counts::labels_and_sorting,
11        open_folder,
12        scroll_area::ShowFileOptions,
13        ui_util::text_edit_singleline,
14    },
15    tools::ToolState,
16    tools_data::{ToolSpecifics, ToolsDataMap},
17    util::version_label,
18};
19use core::f32;
20use egui::{Popup, Response, RichText, Ui};
21use rvimage_domain::{RvResult, rverr};
22use std::{
23    mem,
24    path::{Path, PathBuf},
25};
26
27use super::{
28    file_counts::Counts,
29    tools_menus::{attributes_menu, bbox_menu, brush_menu},
30};
31
32fn show_popup(msg: &str, icon: &str, info_message: Info, response: &Response) -> Info {
33    let mut new_msg = Info::None;
34    Popup::from_response(response)
35        .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
36        .show(|ui| {
37            let max_msg_len = 500;
38            let shortened_msg = if msg.len() > max_msg_len {
39                &msg[..max_msg_len]
40            } else {
41                msg
42            };
43            let mut txt = format!("{icon} {shortened_msg}");
44            ui.text_edit_multiline(&mut txt);
45            new_msg = if ui.button("Close").clicked() {
46                Info::None
47            } else {
48                info_message
49            }
50        });
51    new_msg
52}
53
54// evaluates an expression that is expected to return Result,
55// passes unpacked value to effect function in case of Ok,
56// sets according error message in case of Err.
57// Closure $f_err_cleanup will be called in case of an error.
58macro_rules! handle_error {
59    ($f_effect:expr, $f_err_cleanup:expr, $result:expr, $self:expr) => {
60        match $result {
61            Ok(r) => {
62                #[allow(clippy::redundant_closure_call)]
63                $f_effect(r);
64            }
65            Err(e) => {
66                #[allow(clippy::redundant_closure_call)]
67                $f_err_cleanup();
68                tracing::error!("{e:?}");
69                $self.info_message = Info::Error(e.to_string());
70            }
71        }
72    };
73    ($effect:expr, $result:expr, $self:expr) => {
74        handle_error!($effect, || (), $result, $self)
75    };
76    ($result:expr, $self:expr) => {
77        handle_error!(|_| {}, $result, $self);
78    };
79}
80
81pub struct ToolSelectMenu {
82    are_tools_active: bool, // can deactivate all tools, overrides activated_tool
83    recently_activated_tool: Option<usize>,
84}
85impl ToolSelectMenu {
86    fn new() -> Self {
87        Self {
88            are_tools_active: true,
89            recently_activated_tool: None,
90        }
91    }
92    pub fn recently_clicked_tool(&self) -> Option<usize> {
93        self.recently_activated_tool
94    }
95    pub fn ui(
96        &mut self,
97        ui: &mut Ui,
98        tools: &mut [ToolState],
99        tools_menu_map: &mut ToolsDataMap,
100    ) -> RvResult<()> {
101        ui.horizontal_top(|ui| {
102            self.recently_activated_tool = tools
103                .iter_mut()
104                .enumerate()
105                .filter(|(_, t)| !t.is_always_active())
106                .find(|(_, t)| ui.selectable_label(t.is_active(), t.button_label).clicked())
107                .map(|(i, _)| i);
108        });
109        for v in tools_menu_map.values_mut().filter(|v| v.menu_active) {
110            let mut v_result = Err(rverr!("Tool menu not implemented"));
111            egui::ScrollArea::vertical()
112                .auto_shrink([false, false])
113                .max_height(f32::INFINITY)
114                .show(ui, |ui| {
115                    let tmp = match &mut v.specifics {
116                        ToolSpecifics::Bbox(x) => bbox_menu(
117                            ui,
118                            v.menu_active,
119                            mem::take(x),
120                            &mut self.are_tools_active,
121                            v.visible_inactive_tools.clone(),
122                        ),
123                        ToolSpecifics::Brush(x) => brush_menu(
124                            ui,
125                            v.menu_active,
126                            mem::take(x),
127                            &mut self.are_tools_active,
128                            v.visible_inactive_tools.clone(),
129                        ),
130                        ToolSpecifics::Attributes(x) => attributes_menu(
131                            ui,
132                            v.menu_active,
133                            mem::take(x),
134                            &mut self.are_tools_active,
135                        ),
136                        _ => Ok(mem::take(v)),
137                    };
138                    v_result = tmp;
139                });
140            *v = v_result?;
141        }
142        Ok(())
143    }
144}
145impl Default for ToolSelectMenu {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151fn save_dialog_in_prjfolder(prj_path: &Path, opened_folder: Option<&str>) -> Option<PathBuf> {
152    let filename = get_prj_name(prj_path, opened_folder);
153    let dialog = rfd::FileDialog::new();
154    let dialog = if let Some(folder) = prj_path.parent() {
155        dialog.set_directory(folder)
156    } else {
157        dialog
158    };
159    dialog
160        .add_filter("project files", &["json", "rvi"])
161        .set_file_name(filename)
162        .save_file()
163}
164
165#[derive(Default)]
166pub struct TextBuffers {
167    pub filter_string: String,
168    pub label_propagation: String,
169    pub label_deletion: String,
170    pub import_coco_from_ssh_path: String,
171}
172
173pub struct Menu {
174    window_open: bool, // Only show the egui window when true.
175    info_message: Info,
176    are_tools_active: bool,
177    toggle_clear_cache_on_close: bool,
178    scroll_offset: f32,
179    stats: Counts,
180    text_buffers: TextBuffers,
181    show_file_options: ShowFileOptions,
182    annotations_menu_params: AnnotationsParams,
183    import_coco_from_ssh: bool,
184    new_file_idx_annoplot: Option<usize>,
185    prj_import_path: Option<PathBuf>,
186    prj_import_section: PrjSettingImportSection,
187    prj_settings_for_display: Option<String>,
188    cache_all_progress: Option<f32>,
189}
190
191impl Menu {
192    fn new() -> Self {
193        let text_buffers = TextBuffers {
194            filter_string: "".to_string(),
195            label_propagation: "".to_string(),
196            label_deletion: "".to_string(),
197            import_coco_from_ssh_path: "path on ssh server".to_string(),
198        };
199        Self {
200            window_open: true,
201            info_message: Info::None,
202            are_tools_active: true,
203            toggle_clear_cache_on_close: false,
204            scroll_offset: 0.0,
205            stats: Counts::default(),
206            text_buffers,
207            show_file_options: ShowFileOptions::default(),
208            annotations_menu_params: AnnotationsParams::default(),
209            import_coco_from_ssh: false,
210            new_file_idx_annoplot: None,
211            prj_import_path: None,
212            prj_import_section: PrjSettingImportSection::All,
213            prj_settings_for_display: None,
214            cache_all_progress: None,
215        }
216    }
217    pub fn popup(&mut self, info: Info) {
218        self.info_message = info;
219    }
220
221    pub fn toggle(&mut self) {
222        if self.window_open {
223            self.are_tools_active = true;
224            self.window_open = false;
225        } else {
226            self.window_open = true;
227        }
228    }
229
230    pub fn reload_opened_folder(&mut self, ctrl: &mut Control) {
231        if let Err(e) = ctrl.load_opened_folder_content(ctrl.cfg.prj.sort_params) {
232            self.info_message = Info::Error(format!("{e:?}"));
233        }
234    }
235
236    pub fn show_info(&mut self, msg: Info) {
237        self.info_message = msg;
238    }
239
240    /// Returns true if a project was loaded and if a new file load was triggered
241    pub fn ui(
242        &mut self,
243        ui: &mut egui::Ui,
244        ctrl: &mut Control,
245        tools_data_map: &mut ToolsDataMap,
246        active_tool_name: Option<&str>,
247    ) -> bool {
248        let mut project_loaded = false;
249        egui::Panel::top("top-menu-panel").show_inside(ui, |ui| {
250            // Top row with open folder and settings button
251            egui::MenuBar::new().ui(ui, |ui| {
252                let of_response = ui.button("Open Folder");
253                let pick_result = open_folder::pick_by_connection(ctrl, &of_response);
254                handle_error!(pick_result, self);
255                ui.menu_button("Project", |ui| {
256                    if ui
257                        .button("New")
258                        .on_hover_text(
259                            "Right click, old project will be closed, unsaved data will get lost",
260                        )
261                        .secondary_clicked()
262                    {
263                        *tools_data_map = ctrl.new_prj();
264                        ui.close();
265                    }
266                    if ui.button("Load").clicked() {
267                        let prj_path = rfd::FileDialog::new()
268                            .add_filter("project files", &["json", "rvi"])
269                            .pick_file();
270                        if let Some(prj_path) = prj_path {
271                            handle_error!(
272                                |tdm| {
273                                    *tools_data_map = tdm;
274                                    project_loaded = true;
275                                },
276                                ctrl.load(prj_path),
277                                self
278                            );
279                        }
280                        ui.close();
281                    }
282                    if ui.button("Save").clicked() {
283                        let prj_path = save_dialog_in_prjfolder(
284                            ctrl.cfg.current_prj_path(),
285                            ctrl.opened_folder_label(),
286                        );
287
288                        if let Some(prj_path) = prj_path {
289                            handle_error!(ctrl.save(prj_path, tools_data_map, true), self);
290                        }
291                        ui.close();
292                    }
293                    ui.separator();
294                    ui.label("Import ...");
295                    if ui.button("... Annotations").clicked() {
296                        let prj_path = rfd::FileDialog::new()
297                            .set_title("Import Annotations from Project")
298                            .add_filter("project files", &["json", "rvi"])
299                            .pick_file();
300                        if let Some(prj_path) = prj_path {
301                            handle_error!(
302                                |()| {
303                                    project_loaded = true;
304                                },
305                                ctrl.import_annos(&prj_path, tools_data_map),
306                                self
307                            );
308                        }
309                        ui.close();
310                    }
311
312                    if ui.button("... Settings").clicked() {
313                        // First pick a project file, then open the modal to confirm import options.
314                        if let Some(prj_path) = rfd::FileDialog::new()
315                            .set_title("Pick Project to Import Settings From")
316                            .add_filter("project files", &["json", "rvi"])
317                            .pick_file()
318                        {
319                            self.prj_import_path = Some(prj_path);
320                        }
321                        ui.close();
322                    }
323                    if ui.button("... Annotations and Settings").clicked() {
324                        let prj_path = rfd::FileDialog::new()
325                            .set_title("Import Annotations and Settings from Project")
326                            .add_filter("project files", &["json", "rvi"])
327                            .pick_file();
328                        if let Some(prj_path) = prj_path {
329                            handle_error!(
330                                |()| {
331                                    project_loaded = true;
332                                },
333                                ctrl.import_both(&prj_path, tools_data_map),
334                                self
335                            );
336                        }
337                        ui.close();
338                    }
339                    ui.horizontal(|ui| {
340                        if ui.button("... Annotations from COCO file").clicked() {
341                            let coco_path = if !self.import_coco_from_ssh {
342                                rfd::FileDialog::new()
343                                    .set_title("Annotations from COCO file")
344                                    .add_filter("coco files", &["json"])
345                                    .pick_file()
346                                    .and_then(|p| path_to_str(&p).ok().map(|s| s.to_string()))
347                            } else {
348                                Some(self.text_buffers.import_coco_from_ssh_path.clone())
349                            };
350                            if let Some(coco_path) = coco_path {
351                                handle_error!(
352                                    |()| {
353                                        project_loaded = true;
354                                    },
355                                    ctrl.import_from_coco(
356                                        &coco_path,
357                                        tools_data_map,
358                                        if self.import_coco_from_ssh {
359                                            ExportPathConnection::Ssh
360                                        } else {
361                                            ExportPathConnection::Local
362                                        }
363                                    ),
364                                    self
365                                );
366                            }
367                            ui.close();
368                        }
369                        ui.checkbox(&mut self.import_coco_from_ssh, "ssh")
370                    });
371
372                    if self.import_coco_from_ssh {
373                        text_edit_singleline(
374                            ui,
375                            &mut self.text_buffers.import_coco_from_ssh_path,
376                            &mut self.are_tools_active,
377                        );
378                    }
379                });
380
381                let autosave_gui = AutosaveMenu::new(
382                    ctrl,
383                    tools_data_map,
384                    &mut project_loaded,
385                    &mut self.are_tools_active,
386                    &mut self.annotations_menu_params,
387                    &mut self.new_file_idx_annoplot,
388                );
389                ui.add(autosave_gui);
390                ctrl.paths_navigator
391                    .select_label_idx(self.new_file_idx_annoplot);
392
393                let cfg_gui = CfgMenu::new(
394                    &mut ctrl.cfg,
395                    &mut self.are_tools_active,
396                    &mut self.toggle_clear_cache_on_close,
397                );
398                ui.add(cfg_gui);
399                if self.toggle_clear_cache_on_close {
400                    if let Some(reader) = &mut ctrl.reader {
401                        reader.toggle_clear_cache_on_close();
402                    }
403                    self.toggle_clear_cache_on_close = false;
404                }
405
406                ui.menu_button("Wand Server", |ui| {
407                    if ui.button("Start Wand Server").clicked() {
408                        handle_error!(ctrl.start_wandserver(), self);
409                    }
410                    if ui.button("Cleanup Wand Server").clicked() {
411                        handle_error!(ctrl.cleanup_wandserver(), self);
412                    }
413                });
414                ui.menu_button("Help", |ui| {
415                    ui.label("RV Image\n");
416                    const CODE: &str = env!("CARGO_PKG_REPOSITORY");
417                    let version_label = version_label();
418                    ui.label(version_label);
419                    if let Some(reader) = &mut ctrl.reader {
420                        ui.label("cache size in mb");
421                        ui.label(
422                            egui::RichText::new(format!("{:.3}", reader.cache_size_in_mb()))
423                                .monospace(),
424                        );
425                        ui.label("Hit F5 to clear the cache.");
426                        ui.label("");
427                    }
428                    ui.hyperlink_to("Docs, License, and Code", CODE);
429                    if ui.button("Export Logs").clicked() {
430                        let log_export_dst = rfd::FileDialog::new()
431                            .add_filter("zip", &["zip"])
432                            .set_file_name("logs.zip")
433                            .save_file();
434
435                        ctrl.log_export_path = log_export_dst;
436                        ui.close();
437                    }
438                    let resp_close = ui.button("Close");
439                    if resp_close.clicked() {
440                        ui.close();
441                    }
442                });
443            });
444        });
445        // Show project settings import modal when a file was picked.
446        if self.prj_import_path.is_some() {
447            egui::modal::Modal::new(egui::Id::new("prj-import-section")).show(ui.ctx(), |ui| {
448                ui.label("Project Settings Import");
449                if let Some(p) = &self.prj_import_path {
450                    ui.label(RichText::new(format!("{}", p.display())).monospace());
451                }
452                let mut changed = false;
453                ui.horizontal(|ui| {
454                    ui.vertical(|ui| {
455                        if ui
456                            .radio_value(
457                                &mut self.prj_import_section,
458                                PrjSettingImportSection::All,
459                                "All",
460                            )
461                            .clicked()
462                        {
463                            changed = true;
464                        }
465                        if ui
466                            .radio_value(
467                                &mut self.prj_import_section,
468                                PrjSettingImportSection::Connection,
469                                "Connection",
470                            )
471                            .clicked()
472                        {
473                            changed = true;
474                        }
475                        if ui
476                            .radio_value(
477                                &mut self.prj_import_section,
478                                PrjSettingImportSection::WandServer,
479                                "Wand Server",
480                            )
481                            .clicked()
482                        {
483                            changed = true;
484                        }
485                    });
486                    if let Some(p) = &self.prj_import_path {
487                        if self.prj_settings_for_display.is_none() || changed {
488                            self.prj_settings_for_display =
489                                Some(ctrl.show_settings(p, self.prj_import_section));
490                        }
491                        if let Some(settings_str) = &mut self.prj_settings_for_display {
492                            egui::ScrollArea::vertical()
493                                .min_scrolled_height(500.0)
494                                .show(ui, |ui| {
495                                    ui.add(
496                                        egui::TextEdit::multiline(settings_str)
497                                            .font(egui::FontSelection::Style(
498                                                egui::TextStyle::Monospace,
499                                            ))
500                                            .desired_width(f32::INFINITY)
501                                            .desired_rows(20) // control height
502                                            .interactive(false), // make it non-editable
503                                    );
504                                });
505                        }
506                    }
507                });
508                ui.horizontal(|ui| {
509                    let import_enabled = self.prj_import_path.is_some();
510                    if import_enabled
511                        && ui.button("Import").clicked()
512                        && let Some(prj_path) = self.prj_import_path.take()
513                    {
514                        handle_error!(
515                            |()| {
516                                project_loaded = true;
517                            },
518                            ctrl.import_settings(&prj_path, self.prj_import_section),
519                            self
520                        );
521                    }
522                    if ui.button("Cancel").clicked() {
523                        self.prj_import_path = None;
524                    }
525                });
526            });
527        }
528        egui::Panel::left("left-main-menu").show_inside(ui, |ui| {
529            let mut connected = false;
530            handle_error!(
531                |con| {
532                    connected = con;
533                },
534                ctrl.check_if_connected(ctrl.cfg.prj.sort_params),
535                self
536            );
537            if connected {
538                ui.label(
539                    RichText::from(ctrl.opened_folder_label().unwrap_or(""))
540                        .text_style(egui::TextStyle::Monospace),
541                );
542            } else {
543                ui.label(RichText::from("Connecting...").text_style(egui::TextStyle::Monospace));
544            }
545
546            let filter_txt_field = text_edit_singleline(
547                ui,
548                &mut self.text_buffers.filter_string,
549                &mut self.are_tools_active,
550            );
551
552            if filter_txt_field.changed() {
553                handle_error!(
554                    ctrl.paths_navigator.filter(
555                        &self.text_buffers.filter_string,
556                        tools_data_map,
557                        active_tool_name
558                    ),
559                    self
560                );
561            }
562            // Popup for error messages
563            self.info_message = match &self.info_message {
564                Info::Warning(msg) => {
565                    show_popup(msg, "❕", self.info_message.clone(), &filter_txt_field)
566                }
567                Info::Error(msg) => {
568                    show_popup(msg, "❌", self.info_message.clone(), &filter_txt_field)
569                }
570                Info::None => Info::None,
571            };
572
573            // scroll area showing image file names
574            let scroll_to_selected = ctrl.paths_navigator.scroll_to_selected_label();
575            let mut filtered_label_selected_idx = ctrl.paths_navigator.file_label_selected_idx();
576            if let Some(ps) = &ctrl.paths_navigator.paths_selector() {
577                ui.checkbox(&mut self.show_file_options.idx, "show file index");
578                ui.checkbox(
579                    &mut self.show_file_options.parentfolder,
580                    "show parent folder",
581                );
582
583                self.scroll_offset = menu::scroll_area::scroll_area_file_selector(
584                    ui,
585                    &mut filtered_label_selected_idx,
586                    ps,
587                    ctrl.file_info_selected.as_deref(),
588                    scroll_to_selected,
589                    self.scroll_offset,
590                    self.show_file_options,
591                );
592                ctrl.paths_navigator.deactivate_scroll_to_selected_label();
593                if ctrl.paths_navigator.file_label_selected_idx() != filtered_label_selected_idx {
594                    ctrl.paths_navigator
595                        .select_label_idx(filtered_label_selected_idx);
596                }
597            }
598
599            ui.separator();
600            let mut sort_params = ctrl.cfg.prj.sort_params;
601            handle_error!(
602                labels_and_sorting(
603                    ui,
604                    &mut sort_params,
605                    ctrl,
606                    tools_data_map,
607                    &mut self.text_buffers,
608                    &mut self.stats,
609                ),
610                self
611            );
612            ctrl.cfg.prj.sort_params = sort_params;
613            if ui
614                .button("Pre-cache filtered images")
615                .on_hover_text("double click")
616                .double_clicked()
617            {
618                self.cache_all_progress = Some(0.0);
619            }
620            if self.cache_all_progress.is_some() {
621                handle_error!(
622                    |prgs| {
623                        self.cache_all_progress = prgs;
624                    },
625                    ctrl.cache_all_filtered(),
626                    self
627                );
628                if let Some(prgs) = &self.cache_all_progress {
629                    ui.add(
630                        egui::ProgressBar::new(*prgs).text(
631                            RichText::new(format!(
632                                "loading images into cache {:2}%",
633                                (prgs * 100.0).floor() as u8
634                            ))
635                            .monospace(),
636                        ),
637                    );
638                }
639                if self.cache_all_progress > Some(0.999) {
640                    self.cache_all_progress = None;
641                }
642            }
643        });
644        project_loaded
645    }
646}
647
648impl Default for Menu {
649    fn default() -> Self {
650        Self::new()
651    }
652}
653
654pub fn are_tools_active(menu: &Menu, tsm: &ToolSelectMenu) -> bool {
655    menu.are_tools_active && tsm.are_tools_active
656}