diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index a860fded3..7a138a84d 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -78,6 +78,18 @@ impl OpenCADStudio { } } + // LAYTRANS — translate this drawing's layers onto a set taken from + // another drawing. Bare opens the mapping dialog; with a file it + // maps every name the two drawings share and translates at once, + // which is what most drawings coming from outside need. (#624) + "LAYTRANS" => { + return Some(self.open_layer_translator(None)); + } + cmd if cmd.starts_with("LAYTRANS ") => { + let path = std::path::PathBuf::from(cmd.trim_start_matches("LAYTRANS").trim()); + return Some(self.layer_translate_same(&path)); + } + // LAYDEL — delete a layer and erase the objects on it. "LAYDEL" => { use crate::command::ValuePromptCommand; @@ -192,14 +204,13 @@ impl OpenCADStudio { return Some(Task::none()); } self.push_undo_snapshot(i, "LAYMRG"); - let mut moved = 0usize; - for e in self.tabs[i].scene.document.entities_mut() { - if e.common().layer == src { - e.common_mut().layer = dst.clone(); - moved += 1; - } - } - self.tabs[i].scene.document.layers.remove(&src); + // The same move a translation makes for each of its pairs. + let moved = crate::modules::draw::layers::laytrans::merge_layer( + &mut self.tabs[i].scene, + &src, + &dst, + false, + ); self.tabs[i].scene.invalidate_dependency_index(); self.tabs[i] .scene @@ -666,3 +677,155 @@ impl OpenCADStudio { Some(self.finish_dispatch(cmd)) } } + +impl OpenCADStudio { + /// Translate every layer the two drawings name alike, in one step. + /// + /// The common case for a drawing arriving from outside: the names already + /// line up and only the properties are wrong, so there is nothing to sit in + /// a dialog and decide. + pub(in crate::app) fn layer_translate_same(&mut self, path: &std::path::Path) -> Task { + use crate::modules::draw::layers::laytrans; + let i = self.active_tab; + let targets = match laytrans::load_targets(path) { + Ok(targets) => targets, + Err(why) => { + self.command_line + .push_error(crate::tf!("LAYTRANS: {why}.").as_ref()); + return Task::none(); + } + }; + let current = self.tabs[i].active_layer.clone(); + let sources = laytrans::source_layers(&self.tabs[i].scene, ¤t); + let mappings = laytrans::map_same(&sources, &targets); + if mappings.is_empty() { + self.command_line.push_info( + crate::t!("LAYTRANS: no layer names in common — use LAYTRANS with no file to map them.") + .as_ref(), + ); + return Task::none(); + } + self.push_undo_snapshot(i, "LAYTRANS"); + let report = laytrans::translate( + &mut self.tabs[i].scene, + &mappings, + &targets, + ¤t, + laytrans::Options::default(), + ); + self.finish_layer_translation(i, report) + } + + /// Open the translator, optionally with a standard already loaded. + pub(in crate::app) fn open_layer_translator( + &mut self, + preload: Option, + ) -> Task { + self.layer_translator + .get_or_insert_with(Default::default); + self.active_modal = Some(crate::app::ModalKind::LayerTranslator); + match preload { + Some(path) => Task::done(Message::LayerTranslatorLoaded(path)), + None => Task::none(), + } + } + + /// Read or write the mapping list as plain `from -> to` lines. + /// + /// A drawing standards file is where AutoCAD keeps these, but writing them + /// there would mean inventing a place inside the DWG for something no other + /// application reads back. A small text file beside the drawing carries the + /// same information without touching the drawing's own structure. + pub(in crate::app) fn layer_translator_mappings_file(&mut self, path: &std::path::Path, save: bool) { + use crate::modules::draw::layers::laytrans::Mapping; + if save { + let Some(state) = self.layer_translator.as_ref() else { + return; + }; + let body: String = state + .mappings + .iter() + .map(|m| format!("{} -> {}\n", m.from, m.to)) + .collect(); + match std::fs::write(path, body) { + Ok(()) => self.command_line.push_output( + crate::tf!("LAYTRANS: saved {n} mapping(s).", n = state.mappings.len()) + .as_ref(), + ), + Err(why) => self + .command_line + .push_error(crate::tf!("LAYTRANS: could not save — {why}.").as_ref()), + } + return; + } + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(why) => { + self.command_line + .push_error(crate::tf!("LAYTRANS: could not read — {why}.").as_ref()); + return; + } + }; + let parsed: Vec = text + .lines() + .filter_map(|line| { + let (from, to) = line.split_once("->")?; + let (from, to) = (from.trim(), to.trim()); + (!from.is_empty() && !to.is_empty()).then(|| Mapping { + from: from.to_string(), + to: to.to_string(), + }) + }) + .collect(); + let count = parsed.len(); + if let Some(state) = self.layer_translator.as_mut() { + state.mappings = parsed; + } + self.command_line + .push_output(crate::tf!("LAYTRANS: loaded {count} mapping(s).").as_ref()); + } + + /// Write what the last translation did beside the drawing. + pub(in crate::app) fn write_layer_translation_log(&mut self, i: usize) { + let Some(report) = self.last_layer_translation.as_ref() else { + return; + }; + let base = self.tabs[i] + .current_path + .clone() + .unwrap_or_else(|| std::path::PathBuf::from(&self.tabs[i].tab_title)); + let path = base.with_extension("laytrans.log"); + match std::fs::write(&path, report.to_log()) { + Ok(()) => self + .command_line + .push_output(crate::tf!("LAYTRANS: log written to {}.", path.display()).as_ref()), + Err(why) => self + .command_line + .push_error(crate::tf!("LAYTRANS: could not write log — {why}.").as_ref()), + } + } + + /// Apply a translation's result: mark the drawing, refresh the panel and + /// say what moved. Shared by the command and the dialog so both report the + /// same way. + pub(in crate::app) fn finish_layer_translation( + &mut self, + i: usize, + report: crate::modules::draw::layers::laytrans::Report, + ) -> Task { + let layers = report.translated.len(); + let objects = report.objects(); + self.tabs[i].dirty = true; + self.refresh_layer_panel(); + self.refresh_properties(); + for (layer, reason) in &report.skipped { + self.command_line + .push_info(crate::tf!("LAYTRANS: skipped \"{layer}\" — {reason}.").as_ref()); + } + self.command_line.push_output( + crate::tf!("LAYTRANS: translated {layers} layer(s), {objects} object(s).").as_ref(), + ); + self.last_layer_translation = Some(report); + Task::none() + } +} diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 65b7d98c4..c35ef2e6c 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -297,6 +297,7 @@ inventory::submit!(crate::command::CommandRegistration { "DBLIST", // Layer management. "LAYDEL", + "LAYTRANS", "LAYMRG", "LAYERSTATE", "LAS", diff --git a/src/app/mod.rs b/src/app/mod.rs index a3d902bf7..20a923ab1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -403,6 +403,11 @@ pub(super) struct OpenCADStudio { /// a pick *replaces* the set, so borrowing it here would wipe everything /// gathered so far rather than subtract one thing. (#596) select_remove_mode: bool, + /// What the last layer translation did, kept so the log can be written + /// after the fact rather than forcing a path out of the user up front. + last_layer_translation: Option, + /// The layer-translator dialog's working state while it is open. + layer_translator: Option, /// PICKDRAG (#226): false (default) = press-drag lassoes; true = /// press-drag draws a rectangle marquee. pick_drag_rect: bool, @@ -1489,6 +1494,7 @@ pub enum ModalKind { UpdateNotice, Layers, LayerStateManager, + LayerTranslator, LayerStateEditor, Plot, PrintAll, @@ -1765,6 +1771,27 @@ pub enum Message { LanguageChanged(crate::i18n::Language), /// Drop every entity from the active drawing. ClearScene, + // ── Layer Translator (#624) ────────────────────────────────────────── + /// Pick the drawing whose layers become the translation targets. + LayerTranslatorLoad, + /// A target set was read from `path`. + LayerTranslatorLoaded(std::path::PathBuf), + LayerTranslatorSelectFrom(String), + LayerTranslatorSelectTo(String), + /// Pair the two selected layers. + LayerTranslatorMap, + /// Pair every layer the two drawings name alike. + LayerTranslatorMapSame, + /// Drop the mapping that starts at this layer. + LayerTranslatorUnmap(String), + LayerTranslatorForceByLayer(bool), + LayerTranslatorWriteLog(bool), + LayerTranslatorSaveMappings, + LayerTranslatorLoadMappings, + /// Path chosen for saving or loading mappings. + LayerTranslatorMappingsPath(std::path::PathBuf, bool), + /// Apply every mapping. + LayerTranslatorTranslate, /// Set the active tab's render mode — one of the seven visual styles, and /// the only way a style is ever set. SetRenderMode(acadrust::entities::ViewportRenderMode), @@ -2977,6 +3004,8 @@ impl OpenCADStudio { selection_cycling: false, pick_add: true, select_remove_mode: false, + last_layer_translation: None, + layer_translator: None, pick_drag_rect: false, perf_hud: false, cycle_candidates: None, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index d92c40777..cab874d5e 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1858,6 +1858,142 @@ impl OpenCADStudio { self.active_modal = Some(super::ModalKind::LayerStateManager); Task::none() } + // ── Layer Translator (#624) ────────────────────────────────── + Message::LayerTranslatorLoad => Task::perform( + crate::io::pick_layer_standard_path(), + |path| match path { + Some(path) => Message::LayerTranslatorLoaded(path), + None => Message::Noop, + }, + ), + Message::LayerTranslatorLoaded(path) => { + use crate::modules::draw::layers::laytrans; + match laytrans::load_targets(&path) { + Ok(targets) => { + let state = self.layer_translator.get_or_insert_with(Default::default); + state.source_file = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + state.targets = targets; + // A target set that no longer contains a mapped name + // would translate onto nothing. + let names: Vec = + state.targets.iter().map(|t| t.name.clone()).collect(); + state + .mappings + .retain(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.to))); + state.selected_to = None; + } + Err(why) => self + .command_line + .push_error(crate::tf!("LAYTRANS: {why}.").as_ref()), + } + Task::none() + } + Message::LayerTranslatorSelectFrom(name) => { + if let Some(state) = self.layer_translator.as_mut() { + state.selected_from = Some(name); + } + Task::none() + } + Message::LayerTranslatorSelectTo(name) => { + if let Some(state) = self.layer_translator.as_mut() { + state.selected_to = Some(name); + } + Task::none() + } + Message::LayerTranslatorMap => { + use crate::modules::draw::layers::laytrans::Mapping; + if let Some(state) = self.layer_translator.as_mut() { + if let (Some(from), Some(to)) = + (state.selected_from.take(), state.selected_to.clone()) + { + state.mappings.retain(|m| !m.from.eq_ignore_ascii_case(&from)); + state.mappings.push(Mapping { from, to }); + } + } + Task::none() + } + Message::LayerTranslatorMapSame => { + use crate::modules::draw::layers::laytrans; + let i = self.active_tab; + let current = self.tabs[i].active_layer.clone(); + let sources = laytrans::source_layers(&self.tabs[i].scene, ¤t); + if let Some(state) = self.layer_translator.as_mut() { + for mapping in laytrans::map_same(&sources, &state.targets) { + if !state + .mappings + .iter() + .any(|m| m.from.eq_ignore_ascii_case(&mapping.from)) + { + state.mappings.push(mapping); + } + } + } + Task::none() + } + Message::LayerTranslatorUnmap(from) => { + if let Some(state) = self.layer_translator.as_mut() { + state.mappings.retain(|m| m.from != from); + } + Task::none() + } + Message::LayerTranslatorForceByLayer(value) => { + if let Some(state) = self.layer_translator.as_mut() { + state.force_bylayer = value; + } + Task::none() + } + Message::LayerTranslatorWriteLog(value) => { + if let Some(state) = self.layer_translator.as_mut() { + state.write_log = value; + } + Task::none() + } + Message::LayerTranslatorSaveMappings => Task::perform( + crate::io::pick_layer_mapping_path(true), + |path| match path { + Some(path) => Message::LayerTranslatorMappingsPath(path, true), + None => Message::Noop, + }, + ), + Message::LayerTranslatorLoadMappings => Task::perform( + crate::io::pick_layer_mapping_path(false), + |path| match path { + Some(path) => Message::LayerTranslatorMappingsPath(path, false), + None => Message::Noop, + }, + ), + Message::LayerTranslatorMappingsPath(path, save) => { + self.layer_translator_mappings_file(&path, save); + Task::none() + } + Message::LayerTranslatorTranslate => { + use crate::modules::draw::layers::laytrans; + let Some(state) = self.layer_translator.take() else { + return Task::none(); + }; + self.active_modal = None; + let i = self.active_tab; + let current = self.tabs[i].active_layer.clone(); + self.push_undo_snapshot(i, "LAYTRANS"); + let report = laytrans::translate( + &mut self.tabs[i].scene, + &state.mappings, + &state.targets, + ¤t, + laytrans::Options { + force_bylayer: state.force_bylayer, + }, + ); + let write_log = state.write_log; + let task = self.finish_layer_translation(i, report); + if write_log { + self.write_layer_translation_log(i); + } + task + } Message::LayerStateManagerSelect(name) => { self.load_layer_state_editor(Some(name)); Task::none() diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 7c0d1d616..7bc788519 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -19,6 +19,7 @@ impl OpenCADStudio { Some(K::UpdateNotice) => crate::tr!("modal-update-available"), Some(K::Layers) => crate::tr!("modal-layer-manager"), Some(K::LayerStateManager) => crate::tr!("modal-layer-state-manager"), + Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(), Some(K::LayerStateEditor) => crate::tr!("modal-edit-layer-state"), Some(K::Plot) => crate::tr!("modal-plot"), Some(K::PrintAll) => t!("Print All").into_owned(), @@ -169,6 +170,20 @@ impl OpenCADStudio { |flow| tab.layers.view_window(self.layer_name_col_w, flow), ) } + super::super::ModalKind::LayerTranslator => { + use crate::modules::draw::layers::laytrans; + let i = self.active_tab; + let current = self.tabs[i].active_layer.clone(); + let sources = laytrans::source_layers(&self.tabs[i].scene, ¤t); + let state = self.layer_translator.as_ref()?; + sized_flow(ex, 760, 460, |flow| { + crate::ui::window::layer_translator::view_window( + state, + sources.clone(), + flow, + ) + }) + } super::super::ModalKind::LayerStateManager => { let states = self.tabs[self.active_tab].scene.document.layer_states(); sized_flow( diff --git a/src/io/mod.rs b/src/io/mod.rs index a6d1c608d..c272087be 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -175,6 +175,40 @@ pub async fn pick_open_path() -> Option<(PathBuf, u64)> { Some((path, size)) } +/// Pick the drawing whose layers become translation targets. A standards file +/// is a drawing too, so `.dws` and `.dwt` sit alongside the ordinary formats +/// rather than needing anything of their own. (#624) +pub async fn pick_layer_standard_path() -> Option { + let handle = crate::sys::file_dialog() + .set_title("Load layer standard") + .add_filter( + "Drawings and standards", + &["dwg", "dws", "dwt", "dxf", "DWG", "DWS", "DWT", "DXF"], + ) + .add_filter("All Files", &["*"]) + .pick_file() + .await?; + Some(crate::sys::handle_path(&handle)) +} + +/// Pick where a set of layer mappings is written, or read back from. +pub async fn pick_layer_mapping_path(save: bool) -> Option { + let dialog = crate::sys::file_dialog() + .set_title(if save { + "Save layer mappings" + } else { + "Load layer mappings" + }) + .add_filter("Layer mappings", &["ocslmap"]) + .add_filter("All Files", &["*"]); + let handle = if save { + dialog.set_file_name("layers.ocslmap").save_file().await? + } else { + dialog.pick_file().await? + }; + Some(crate::sys::handle_path(&handle)) +} + /// Load a CAD file from a known path. Parsing and cache building run on a /// dedicated OS thread so the async executor stays free for rendering during /// the load. Writes phase markers into `phase` so the UI can show diff --git a/src/modules/draw/layers/laytrans.rs b/src/modules/draw/layers/laytrans.rs new file mode 100644 index 000000000..f7e81aa1a --- /dev/null +++ b/src/modules/draw/layers/laytrans.rs @@ -0,0 +1,226 @@ +// Layer translation — map this drawing's layers onto a set taken from another +// drawing, then move everything across. +// +// Drawings arrive from outside with someone else's layer names. Translating +// them is three separate jobs that the command, the dialog and the saved +// mappings all share: reading a target set out of another file, deciding which +// source layer becomes which target, and performing the move. Only the third +// touches the drawing, and it is the same move LAYMRG makes for a single pair, +// so both go through `merge_layer`. (#624) + +use std::collections::BTreeMap; +use std::path::Path; + +use acadrust::tables::Layer; + +use crate::scene::Scene; + +/// A target layer read out of another drawing, with the properties the +/// translated layer should end up carrying. +#[derive(Clone, Debug)] +pub struct TargetLayer { + pub name: String, + pub layer: Layer, +} + +/// One source layer and the target it becomes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Mapping { + pub from: String, + pub to: String, +} + +/// What a translation should do beyond moving the objects. +#[derive(Clone, Copy, Debug, Default)] +pub struct Options { + /// Reset each moved object's own colour and linetype to ByLayer, so the + /// target layer's properties are what actually shows. Objects that override + /// their layer would otherwise survive the translation looking unchanged. + pub force_bylayer: bool, +} + +/// What a translation did, for the command line and the log. +#[derive(Clone, Debug, Default)] +pub struct Report { + /// Source layer, target layer, objects moved. + pub translated: Vec<(String, String, usize)>, + /// Mappings that could not run, with the reason. + pub skipped: Vec<(String, String)>, +} + +impl Report { + pub fn objects(&self) -> usize { + self.translated.iter().map(|(_, _, n)| n).sum() + } + + /// The transaction log AutoCAD writes beside the drawing, as text. + pub fn to_log(&self) -> String { + let mut out = String::new(); + for (from, to, moved) in &self.translated { + out.push_str(&format!("{from} -> {to} ({moved} object(s))\n")); + } + for (layer, reason) in &self.skipped { + out.push_str(&format!("{layer}: skipped — {reason}\n")); + } + out + } +} + +/// The layers another drawing defines, as translation targets. +/// +/// Any drawing serves: a standards file, a template, or an already-correct +/// drawing. They are all the same format, so nothing here cares which. +pub fn load_targets(path: &Path) -> Result, String> { + let document = crate::io::load_file(path)?; + let mut targets: Vec = document + .layers + .iter() + // A layer the source drawing itself borrowed from a reference is not + // part of the standard it is offering. + .filter(|layer| !layer.name.contains('|') && !layer.flags.xref_dependent) + .map(|layer| TargetLayer { + name: layer.name.clone(), + layer: layer.clone(), + }) + .collect(); + targets.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + if targets.is_empty() { + return Err("no layers found in that file".into()); + } + Ok(targets) +} + +/// Layers of `scene` that a translation could move, current layer excluded — +/// the drawing has to keep drawing somewhere. +pub fn source_layers(scene: &Scene, current: &str) -> Vec { + let mut names: Vec = scene + .document + .layers + .names() + .filter(|name| *name != "0" && !name.eq_ignore_ascii_case(current)) + .map(|name| name.to_string()) + .collect(); + names.sort_by_key(|name| name.to_lowercase()); + names +} + +/// Pair up every source layer that a target names as well. +/// +/// This is the mapping most translations want and all of them start from: the +/// two drawings already agree on those names, so the only thing to settle is +/// the properties. +pub fn map_same(sources: &[String], targets: &[TargetLayer]) -> Vec { + let by_name: BTreeMap = targets + .iter() + .map(|target| (target.name.to_lowercase(), target)) + .collect(); + sources + .iter() + .filter_map(|source| { + by_name.get(&source.to_lowercase()).map(|target| Mapping { + from: source.clone(), + to: target.name.clone(), + }) + }) + .collect() +} + +/// Move everything on each mapped layer onto its target and drop the layer it +/// left. The target is created, with the properties the target drawing gave it, +/// when this drawing has no layer by that name. +pub fn translate( + scene: &mut Scene, + mappings: &[Mapping], + targets: &[TargetLayer], + current_layer: &str, + options: Options, +) -> Report { + let mut report = Report::default(); + for mapping in mappings { + if mapping.from.eq_ignore_ascii_case(&mapping.to) { + continue; + } + if mapping.from == "0" { + report + .skipped + .push((mapping.from.clone(), "layer \"0\" cannot be translated".into())); + continue; + } + if mapping.from.eq_ignore_ascii_case(current_layer) { + report.skipped.push(( + mapping.from.clone(), + "it is the current layer".into(), + )); + continue; + } + if !scene.document.layers.contains(&mapping.from) { + report + .skipped + .push((mapping.from.clone(), "no such layer in this drawing".into())); + continue; + } + // Bring the target in with the standard's own properties. An existing + // layer of that name is left as it is: the drawing already has an + // opinion about it, and silently restyling it would reach past what the + // translation was asked to do. + if !scene.document.layers.contains(&mapping.to) { + let Some(target) = targets + .iter() + .find(|target| target.name.eq_ignore_ascii_case(&mapping.to)) + else { + report + .skipped + .push((mapping.from.clone(), "target layer is not in the loaded set".into())); + continue; + }; + let mut layer = target.layer.clone(); + layer.name = target.name.clone(); + layer.handle = scene.document.allocate_handle(); + // Whatever reference the target drawing held it through does not + // come with it. + layer.flags.xref_dependent = false; + layer.xref_block_record_handle = acadrust::Handle::NULL; + let _ = scene.document.layers.add(layer); + } + let moved = merge_layer(scene, &mapping.from, &mapping.to, options.force_bylayer); + report + .translated + .push((mapping.from.clone(), mapping.to.clone(), moved)); + } + if !report.translated.is_empty() { + let touched: Vec = report + .translated + .iter() + .map(|(_, to, _)| to.clone()) + .collect(); + scene.invalidate_dependency_index(); + scene.invalidate_layer_dependencies(&touched); + } + report +} + +/// Move every object off `from` onto `to` and drop `from`. Returns how many +/// objects moved. +/// +/// The entity list is flat and holds block definitions too, so objects inside +/// blocks travel with the rest rather than being left behind on a layer that no +/// longer exists. +pub fn merge_layer(scene: &mut Scene, from: &str, to: &str, force_bylayer: bool) -> usize { + let mut moved = 0usize; + for entity in scene.document.entities_mut() { + if entity.common().layer != from { + continue; + } + let common = entity.common_mut(); + common.layer = to.to_string(); + if force_bylayer { + common.color = acadrust::Color::ByLayer; + common.linetype = String::new(); + common.linetype_handle = None; + common.line_weight = acadrust::LineWeight::ByLayer; + } + moved += 1; + } + scene.document.layers.remove(from); + moved +} diff --git a/src/modules/draw/layers/mod.rs b/src/modules/draw/layers/mod.rs index 9dda05ed2..7a486f7fb 100644 --- a/src/modules/draw/layers/mod.rs +++ b/src/modules/draw/layers/mod.rs @@ -8,5 +8,6 @@ pub(super) mod laythw; pub(super) mod layuniso; pub(super) mod layulk; pub(super) mod make_current; +pub mod laytrans; pub mod match_layer; pub(super) mod panel; diff --git a/src/ui/window/layer_translator.rs b/src/ui/window/layer_translator.rs new file mode 100644 index 000000000..7ff70cc05 --- /dev/null +++ b/src/ui/window/layer_translator.rs @@ -0,0 +1,221 @@ +//! Layer Translator — map this drawing's layers onto a set loaded from another +//! drawing, then translate. +//! +//! Two lists side by side: what the drawing has, and what the loaded standard +//! offers. Selecting one of each and mapping them builds up the list of moves, +//! which is only applied when Translate is pressed — so a mapping can be +//! reconsidered, and a translation is one undo step rather than many. + +use iced::widget::{button, checkbox, column, container, row, scrollable, text, Space}; +use iced::{Background, Border, Element, Fill, Length, Theme}; + +use crate::app::Message; +use crate::modules::draw::layers::laytrans::{Mapping, TargetLayer}; +use crate::t; + +/// The dialog's working state: what was loaded, what is selected, and the +/// mappings built so far. None of it touches the drawing until Translate. +#[derive(Default)] +pub struct State { + /// Where the target set came from, shown so it is clear what is being + /// translated to. + pub source_file: String, + pub targets: Vec, + pub mappings: Vec, + pub selected_from: Option, + pub selected_to: Option, + pub force_bylayer: bool, + pub write_log: bool, +} + +fn muted(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.6)), + } +} + +fn list_row<'a>(label: String, selected: bool, message: Message) -> Element<'a, Message> { + button(text(label).size(12).width(Fill)) + .on_press(message) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let mut style = button::text(theme, status); + if selected { + style.background = Some(Background::Color(palette.primary.weak.color)); + style.text_color = palette.primary.weak.text; + } + style.border = Border { + radius: 3.0.into(), + ..Default::default() + }; + style + }) + .width(Fill) + .padding([4, 8]) + .into() +} + +fn pane<'a>( + title: String, + rows: Vec>, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let body: Element<'_, Message> = if rows.is_empty() { + container(text(t!("(none)")).size(11).style(muted)) + .padding(8) + .into() + } else { + scrollable(column(rows).spacing(1)) + .height(sizing.height) + .into() + }; + container(column![text(title).size(11).style(muted), body].spacing(4)) + .padding(6) + .width(sizing.width) + .height(sizing.height) + .style(|theme: &Theme| container::Style { + border: Border { + width: 1.0, + radius: 4.0.into(), + color: theme.palette().background.strong.color, + }, + ..Default::default() + }) + .into() +} + +pub fn view_window<'a>( + state: &'a State, + sources: Vec, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + // A layer already spoken for should not be offered again; mapping it twice + // could only mean the second one wins, which is not a choice worth making + // silently. + let mapped: Vec<&str> = state.mappings.iter().map(|m| m.from.as_str()).collect(); + let from_rows: Vec> = sources + .iter() + .filter(|name| !mapped.contains(&name.as_str())) + .map(|name| { + list_row( + name.clone(), + state.selected_from.as_deref() == Some(name.as_str()), + Message::LayerTranslatorSelectFrom(name.clone()), + ) + }) + .collect(); + let to_rows: Vec> = state + .targets + .iter() + .map(|target| { + list_row( + target.name.clone(), + state.selected_to.as_deref() == Some(target.name.as_str()), + Message::LayerTranslatorSelectTo(target.name.clone()), + ) + }) + .collect(); + let mapping_rows: Vec> = state + .mappings + .iter() + .map(|mapping| { + row![ + text(format!("{} → {}", mapping.from, mapping.to)) + .size(12) + .width(Fill), + button(text(t!("Remove")).size(11)) + .on_press(Message::LayerTranslatorUnmap(mapping.from.clone())) + .style(button::text) + .padding([2, 6]), + ] + .align_y(iced::Center) + .padding([2, 6]) + .into() + }) + .collect(); + + let loaded: Element<'_, Message> = if state.source_file.is_empty() { + text(t!("No standard loaded.")).size(11).style(muted).into() + } else { + text(state.source_file.clone()).size(11).style(muted).into() + }; + + let can_map = state.selected_from.is_some() && state.selected_to.is_some(); + let controls = row![ + button(text(t!("Load…")).size(12)) + .on_press(Message::LayerTranslatorLoad) + .padding([4, 10]), + button(text(t!("Map")).size(12)) + .on_press_maybe(can_map.then_some(Message::LayerTranslatorMap)) + .padding([4, 10]), + button(text(t!("Map same")).size(12)) + .on_press_maybe( + (!state.targets.is_empty()).then_some(Message::LayerTranslatorMapSame) + ) + .padding([4, 10]), + Space::new().width(Fill), + loaded, + ] + .spacing(6) + .align_y(iced::Center); + + let options = row![ + checkbox(state.force_bylayer) + .on_toggle(Message::LayerTranslatorForceByLayer) + .size(14), + text(t!("Force objects to ByLayer")).size(11), + Space::new().width(Length::Fixed(10.0)), + checkbox(state.write_log) + .on_toggle(Message::LayerTranslatorWriteLog) + .size(14), + text(t!("Write translation log")).size(11), + ] + .spacing(6) + .align_y(iced::Center); + + let actions = row![ + button(text(t!("Save mappings…")).size(12)) + .on_press_maybe( + (!state.mappings.is_empty()).then_some(Message::LayerTranslatorSaveMappings) + ) + .padding([4, 10]), + button(text(t!("Load mappings…")).size(12)) + .on_press(Message::LayerTranslatorLoadMappings) + .padding([4, 10]), + Space::new().width(Fill), + button(text(t!("Cancel")).size(12)) + .on_press(Message::CloseModal) + .padding([4, 12]), + button(text(t!("Translate")).size(12)) + .on_press_maybe( + (!state.mappings.is_empty()).then_some(Message::LayerTranslatorTranslate) + ) + .style(button::primary) + .padding([4, 12]), + ] + .spacing(6) + .align_y(iced::Center); + + let lists = row![ + pane(t!("Translate from").into_owned(), from_rows, sizing), + pane(t!("Translate to").into_owned(), to_rows, sizing), + ] + .spacing(8) + .height(sizing.height); + + column![ + controls, + lists, + pane( + t!("%{n} mapping(s)", n = state.mappings.len()).into_owned(), + mapping_rows, + sizing, + ), + options, + actions, + ] + .spacing(8) + .padding(10) + .width(sizing.width) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 85dafe04a..41d84ebb6 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -2,6 +2,7 @@ pub mod about; pub mod block_palette; pub mod layout_manager; pub mod layer_state_manager; +pub mod layer_translator; pub mod plot; pub mod print_all; pub mod plugin_manager;