maps 1.10.1

Inspect, compare and align multiple grid maps in an intuitive & fast GUI
Documentation
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;

use eframe::egui;
use image::GenericImageView;
use log::{debug, error, info};

use crate::map_state::MapState;
use crate::persistence;
use crate::tiles::Pane;
use maps_io_ros::{Meta, load_image};
use maps_rendering::{ImagePyramid, TextureFilter};

use crate::app::{AppState, Error, ViewMode};
use crate::app_impl::compat::migrate_old_egui_color;
use maps_io_ros::MapPose;
use maps_io_ros::value_interpretation;

impl AppState {
    #[cfg(not(target_arch = "wasm32"))]
    fn load_meta(&mut self, yaml_path: &std::path::Path) -> Result<bool, Error> {
        let meta = Meta::load_from_file(yaml_path)?;
        self.load_map(meta)?;
        Ok(true)
    }

    #[cfg(not(target_arch = "wasm32"))]
    pub(crate) fn load_meta_button(&mut self, ui: &mut egui::Ui) {
        if ui.button("📂 Load Maps").clicked() {
            let mut dialog = rfd::FileDialog::new().add_filter("YAML", &["yaml", "yml"]);
            if let Some(dir) = &self.last_file_dir {
                dialog = dialog.set_directory(dir);
            }
            if let Some(paths) = dialog.pick_files() {
                for path in paths {
                    ui.ctx().request_repaint();
                    match self.load_meta(&path) {
                        Ok(_) => {
                            self.last_file_dir = path.parent().map(std::path::Path::to_path_buf);
                        }
                        Err(e) => {
                            self.status.error = e.to_string();
                            error!("{e}");
                        }
                    }
                }
            }
        }
    }

    pub(crate) fn add_map(&mut self, name: &String, meta: Meta, image_pyramid: &Arc<ImagePyramid>) {
        let use_interpretation = meta.value_interpretation.explicit_mode;
        if use_interpretation {
            // This map has an explicitly specified value interpretation.
            // We need to set this to not loose the values in the next frame.
            self.options.tint_settings.active_tint_selection = Some(name.clone());
        }
        self.tile_manager.add_pane(Pane { id: name.clone() });
        self.data.maps.insert(
            name.clone(),
            MapState {
                meta,
                pose: MapPose::default(),
                visible: true,
                image_pyramid: image_pyramid.clone(),
                texture_states: HashMap::new(),
                tint: None,
                color_to_alpha: None,
                texture_filter: TextureFilter::default(),
                use_value_interpretation: use_interpretation,
            },
        );
        self.data.draw_order.add(name.clone());
        info!("Loaded map: {name}");
        self.status.unsaved_changes = true;
        if self.options.view_mode == ViewMode::LoadScreen {
            self.options.view_mode = ViewMode::default();
        }
    }

    pub(crate) fn load_map(&mut self, meta: Meta) -> Result<String, Error> {
        if !meta.image_path.exists() {
            return Err(Error::app(format!(
                "Image file doesn't exist: {:?}",
                meta.image_path
            )));
        }

        let image = if self.options.advanced.dry_run {
            info!("Dry-run mode, not loading image {:?}.", meta.image_path);
            Ok(image::DynamicImage::new_rgba8(0, 0))
        } else {
            debug!("Loading image: {:?}", meta.image_path);
            load_image(&meta.image_path)
        }?;
        debug!(
            "Loaded image: {:?} {:?}",
            meta.image_path,
            image.dimensions()
        );

        let image_pyramid = Arc::new(ImagePyramid::new(image));
        let name = meta
            .yaml_path
            .to_str()
            .expect("invalid unicode path, can't use as map name")
            .to_owned();
        self.add_map(&name, meta, &image_pyramid);
        Ok(name)
    }

    pub(crate) fn delete(&mut self, to_delete: &Vec<String>) {
        for name in to_delete {
            info!("Removing {name}");
            self.data.maps.remove(name);
            self.data.draw_order.remove(name);
            self.tile_manager.remove_pane(name);
            if let Some(active_tool) = &self.status.active_tool
                && active_tool == name
            {
                self.status.active_tool = None;
            }
            if let Some(active_tint_selection) = &self.options.tint_settings.active_tint_selection
                && active_tint_selection == name
            {
                // Set the selection to one of the remaining maps if possible.
                // This avoids falling back to "All" (None) when there are still
                // other maps with potentially custom tints.
                self.options.tint_settings.active_tint_selection =
                    self.data.maps.keys().last().map(|s| s.to_string());
            }
            if self.options.pose_edit.selected_map == *name {
                self.options.pose_edit.selected_map = "".to_string();
            }
            self.status.unsaved_changes = true;
        }
    }

    pub(crate) fn add_map_pose(&mut self, map_name: &str, map_pose: MapPose) {
        if let Some(map) = self.data.maps.get_mut(map_name) {
            map.pose = map_pose;
            info!("Loaded pose for: {map_name}");
            self.status.unsaved_changes = true;
        } else {
            error!("Tried to add pose to non-existing map: {map_name}");
        }
    }

    #[cfg(not(target_arch = "wasm32"))]
    pub(crate) fn load_map_pose_button(&mut self, ui: &mut egui::Ui, map_name: &str) {
        if ui
            .button("📂 Load Pose")
            .on_hover_text("Load a map pose from a YAML file.")
            .clicked()
        {
            let mut dialog = rfd::FileDialog::new().add_filter("YAML", &["yaml", "yml"]);
            if let Some(dir) = &self.last_file_dir {
                dialog = dialog.set_directory(dir);
            }
            if let Some(path) = dialog.pick_file() {
                debug!("Loading pose file: {path:?}");
                match MapPose::from_yaml_file(&path) {
                    Ok(map_pose) => {
                        self.add_map_pose(map_name, map_pose);
                        self.last_file_dir = path.parent().map(std::path::Path::to_path_buf);
                        self.status.unsaved_changes = true;
                    }
                    Err(e) => {
                        self.status.error = e.to_string();
                        error!("{e}");
                    }
                }
            }
        }
    }

    #[cfg(not(target_arch = "wasm32"))]
    pub(crate) fn save_map_pose_button(&mut self, ui: &mut egui::Ui, map_name: &str) {
        if ui
            .button("💾 Save Pose")
            .on_hover_text("Save the map pose to a YAML file.")
            .clicked()
        {
            let mut dialog = rfd::FileDialog::new()
                .add_filter("YAML", &["yaml", "yml"])
                .set_file_name("map_pose.yaml");
            if let Some(dir) = &self.last_file_dir {
                dialog = dialog.set_directory(dir);
            }
            if let Some(path) = dialog.save_file() {
                ui.ctx().request_repaint();
                debug!("Saving pose file: {path:?}");
                let Some(map) = self.data.maps.get(map_name) else {
                    self.status.error = format!("Can't save pose, map doesn't exist: {map_name}");
                    error!("{}", self.status.error);
                    return;
                };
                match map.pose.to_yaml_file(&path) {
                    Ok(_) => {
                        info!("Saved pose file: {path:?}");
                        self.last_file_dir = path.parent().map(std::path::Path::to_path_buf);
                    }
                    Err(e) => {
                        self.status.error = e.to_string();
                        error!("{e}");
                    }
                }
            }
        }
    }

    pub fn load_session(&mut self, path: &PathBuf) -> Result<(), Error> {
        let deserialized_session = persistence::load_session(path)?;

        // Start from the same path the next time.
        self.last_file_dir = path.parent().map(std::path::Path::to_path_buf);

        // Keep the draw order of the session, if it was saved.
        // If it was not saved (older versions), add_map() will take care of it.
        self.data
            .draw_order
            .extend(&deserialized_session.draw_order);

        // If the session has no version field, it was saved with maps < 1.7.0.
        // This means that the tint color was serialized with egui < 0.32 and
        // might need migration.
        let migrate_colors = deserialized_session.version.is_none();
        if migrate_colors {
            debug!("Session was saved with maps < 1.7.0, migrating serialized colors.");
        }

        // Not everything gets serialized. Load actual data.
        for (name, map) in deserialized_session.maps {
            debug!("Restoring map state: {name}");
            let map_name = self.load_map(map.meta).inspect_err(|_| {
                // Make sure we have no dangling names in draw_order if we fail to load one map.
                self.data
                    .draw_order
                    .retain(|key| self.data.maps.contains_key(key));
            })?;
            let map_state = self.data.maps.get_mut(&map_name).expect("missing map");
            map_state.pose = map.pose;
            map_state.visible = map.visible;
            map_state.tint = map.tint;
            if migrate_colors {
                map_state.tint = migrate_old_egui_color(map_state.tint);
            }
            map_state.texture_filter = map.texture_filter;
            if map_state.tint.is_some()
                || map_state.meta.value_interpretation.mode != value_interpretation::Mode::Raw
            {
                // We need to set this because we would lose this map's tint
                // in the next frame if "All" is selected in the settings panel.
                self.options.tint_settings.active_tint_selection = Some(name.clone());
            }
            self.tile_manager
                .set_visible(map_name.as_str(), map.visible);
            map_state.color_to_alpha = map.color_to_alpha;
            if migrate_colors {
                map_state.color_to_alpha = migrate_old_egui_color(map_state.color_to_alpha);
            }
            self.status.unsaved_changes = false;
        }

        for (id, lens_pos) in deserialized_session.grid_lenses {
            debug!("Restoring lens {id}");
            self.data.grid_lenses.insert(id, lens_pos);
        }

        Ok(())
    }

    pub(crate) fn load_session_button(&mut self, ui: &mut egui::Ui) {
        if ui
            .button("📂 Load Session")
            .on_hover_text("Load a session from a file.")
            .on_disabled_hover_text("Only supported in native builds.")
            .clicked()
        {
            #[cfg(not(target_arch = "wasm32"))]
            {
                let mut dialog = rfd::FileDialog::new().add_filter("TOML", &["toml"]);
                if let Some(dir) = &self.last_file_dir {
                    dialog = dialog.set_directory(dir);
                }
                if let Some(path) = dialog.pick_file() {
                    self.load_session(&path).unwrap_or_else(|e| {
                        self.status.error = e.to_string();
                        error!("{e}");
                    });
                }
            }
        }
    }

    pub(crate) fn save_session_button(&mut self, ui: &mut egui::Ui, quit_after_save: bool) {
        let text = if quit_after_save {
            "💾 Save Session and Quit"
        } else {
            "💾 Save Session"
        };

        let disabled_hover_text = if cfg!(target_arch = "wasm32") {
            "Only supported in native builds."
        } else {
            "No maps to save."
        };

        if ui
            .button(text.to_owned())
            .on_hover_text("Save the current session to a file.")
            .on_disabled_hover_text(disabled_hover_text)
            .clicked()
        {
            #[cfg(not(target_arch = "wasm32"))]
            {
                let mut dialog = rfd::FileDialog::new()
                    .add_filter("TOML", &["toml"])
                    .set_file_name("maps_session.toml");
                if let Some(dir) = &self.last_file_dir {
                    dialog = dialog.set_directory(dir);
                }
                if let Some(path) = dialog.save_file() {
                    match persistence::save_session(&path, &self.data) {
                        Ok(_) => {
                            self.last_file_dir = path.parent().map(std::path::Path::to_path_buf);
                            self.status.unsaved_changes = false;
                            if quit_after_save {
                                ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
                            }
                        }
                        Err(e) => {
                            self.status.error = e.to_string();
                            error!("{e}");
                        }
                    }
                }
                self.status.quit_modal_active = false;
            }
        }
    }
}