diff --git a/src/app/block_editor.rs b/src/app/block_editor.rs index cbc22d2d7..529a4c845 100644 --- a/src/app/block_editor.rs +++ b/src/app/block_editor.rs @@ -224,6 +224,198 @@ impl OpenCADStudio { self.block_edit_delta(i).is_some() } + pub(super) fn validate_block_save_as_name( + &self, + i: usize, + raw: &str, + ) -> Result { + let name = + crate::scene::block_service::validate_block_name(raw).map_err(|e| e.to_string())?; + if self.tabs[i] + .scene + .document + .block_records + .get(&name) + .is_some() + { + return Err( + crate::scene::block_service::BlockServiceError::NameExists(name).to_string(), + ); + } + Ok(name) + } + + pub(super) fn open_block_save_as_dialog(&mut self, i: usize) { + let Some(session) = self.tabs[i].active_block_edit_session() else { + self.command_line + .push_error(crate::t!("No block editor is open.").as_ref()); + return; + }; + let source = session.br_handle; + let name = session.block_name.clone(); + let mut existing_names = self.tabs[i].scene.custom_block_names(); + existing_names.sort_by_key(|name| name.to_ascii_lowercase()); + self.block_save_as = Some(crate::ui::window::block_save_as::State { + document_id: self.tabs[i].id, + source, + name: String::new(), + existing_names, + preview: self.tabs[i].scene.block_preview_wires(&name), + error: self.validate_block_save_as_name(i, "").err(), + }); + self.active_modal = Some(ModalKind::BlockSaveAs); + self.reset_modal_geometry(); + } + + pub(super) fn apply_block_save_as_dialog(&mut self) -> Task { + let Some(state) = self.block_save_as.as_ref() else { + return Task::none(); + }; + let id = state.document_id; + let source = state.source; + let name = state.name.clone(); + let Some(i) = self.tabs.iter().position(|tab| tab.id == id) else { + return Task::none(); + }; + if self.tabs[i] + .active_block_edit_session() + .is_none_or(|session| session.br_handle != source) + { + if let Some(state) = self.block_save_as.as_mut() { + state.error = Some( + crate::t!("The block editor has changed; reopen Save Block As.").into_owned(), + ); + } + return Task::none(); + } + self.active_tab = i; + if self.save_block_edit_as(i, &name) { + self.block_save_as = None; + self.active_modal = None; + self.reset_modal_geometry(); + } else if let Some(state) = self.block_save_as.as_mut() { + state.error = self.command_line.last_error.clone(); + } + Task::none() + } + + pub(super) fn save_block_edit_as(&mut self, i: usize, raw: &str) -> bool { + let Some(session) = self.tabs[i].active_block_edit_session() else { + self.command_line + .push_error(crate::t!("No block editor is open.").as_ref()); + return false; + }; + let source_name = session.block_name.clone(); + let source_handle = session.br_handle; + let name = match self.validate_block_save_as_name(i, raw) { + Ok(name) => name, + Err(error) => { + self.command_line.push_error(&error); + return false; + } + }; + self.finish_pending_history(i); + self.reconcile_block_edit_membership(i); + let Some(history) = self.tabs[i].block_edit_history.as_ref() else { + return false; + }; + let copied = match super::block_save_as::copy_definition( + &self.tabs[i].scene.document, + history.checkpoint.document.clone(), + &source_name, + &name, + ) { + Ok(copied) => copied, + Err(error) => { + self.command_line.push_error(&error); + return false; + } + }; + let mut constraints = history.checkpoint.constraints.clone(); + for mut set in self.tabs[i].scene.parametric_constraints.iter().filter(|set| { + matches!(set.scope, crate::scene::parametric_constraints::ParametricScope::Block(handle) if copied.handles.contains_key(&handle)) + }).cloned() { + if !set.retained_standard_groups.is_empty() || set.constraints.iter().any(|c| c.native_origin.is_some()) { + self.command_line.push_error(crate::t!("Save Block As cannot copy retained native constraints yet.").as_ref()); + return false; + } + let crate::scene::parametric_constraints::ParametricScope::Block(old) = set.scope else { unreachable!() }; + set.scope = crate::scene::parametric_constraints::ParametricScope::Block(copied.handles[&old]); + for constraint in &mut set.constraints { + for reference in constraint.refs.iter_mut().chain(constraint.rigid_points.iter_mut().map(|(r, _)| r)) { + let Some(new) = copied.handles.get(&reference.entity) else { + self.command_line.push_error(crate::t!("A block constraint refers to geometry outside the definition.").as_ref()); + return false; + }; + reference.entity = *new; + } + } + for handle in set.dimensions.values_mut() { + let Some(new) = copied.handles.get(handle) else { + self.command_line.push_error(crate::t!("A block constraint dimension is missing.").as_ref()); + return false; + }; + *handle = *new; + } + set.dof = None; + set.conflicts.clear(); + constraints.push(set); + } + for entity in copied.document.entities_in_block(&name) { + if let EntityType::Insert(insert) = entity { + if crate::scene::block_service::would_create_cycle( + &copied.document, + &name, + &insert.block_name, + ) { + self.command_line.push_error( + &crate::scene::block_service::circular_dependency_error( + &name, + &insert.block_name, + ), + ); + return false; + } + } + } + let parameters = self.tabs[i].scene.named_parameters.clone(); + let active_layer = self.tabs[i].active_layer.clone(); + self.tabs[i].scene.document = copied.document; + self.tabs[i].scene.parametric_constraints = constraints; + self.tabs[i].scene.named_parameters = parameters; + self.tabs[i].active_layer = active_layer; + let mut delta = self + .block_edit_delta(i) + .expect("a new definition changes the document"); + delta.label = format!("Save block as {name}"); + let mut history = self.tabs[i].block_edit_history.take().unwrap(); + std::mem::swap(&mut self.tabs[i].history, &mut history.main); + self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); + std::mem::swap(&mut self.tabs[i].history, &mut history.main); + self.tabs[i].history = HistoryState::default(); + self.tabs[i].dirty = true; + let session = self.tabs[i].active_block_edit_session_mut().unwrap(); + debug_assert_eq!(session.br_handle, source_handle); + session.br_handle = copied.record; + session.block_name = name.clone(); + self.tabs[i].scene.block_edit_block = Some(copied.record); + self.tabs[i].scene.deselect_all(); + self.tabs[i].active_grip = None; + self.grip_hover = None; + self.grip_popup = None; + self.visibility_popup = None; + history.checkpoint = self.block_edit_checkpoint(i); + self.tabs[i].block_edit_history = Some(history); + self.tabs[i].scene.rebuild_derived_caches(); + self.tabs[i].refresh_active_ucs(); + self.refresh_block_palette(); + self.refresh_properties(); + self.sync_ribbon_layers(); + self.command_line + .push_output(crate::tf!("Block {} saved.", name).as_ref()); + true + } + pub(super) fn save_block_edit(&mut self, i: usize) -> bool { let Some(session) = self.tabs[i].active_block_edit_session() else { self.command_line diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 2c07c4172..48d452a52 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -54,6 +54,520 @@ fn main_depth(app: &OpenCADStudio) -> usize { .unwrap_or(tab.history.undo_stack.len()) } +#[test] +fn save_as_forks_unsaved_edits_and_undoes_as_one_drawing_step() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + circle(&mut app, 1.0); + app.save_block_edit(app.active_tab); + let saved = members(&app); + circle(&mut app, 2.0); + let edited = members(&app).len(); + assert!(app.save_block_edit_as(app.active_tab, " COPY ")); + let i = app.active_tab; + assert_eq!(members(&app), saved); + assert_eq!( + app.tabs[i].active_block_edit_session().unwrap().block_name, + "COPY" + ); + assert_eq!( + app.tabs[i] + .scene + .document + .block_records + .get("COPY") + .unwrap() + .entity_handles + .len(), + edited + ); + assert_eq!(main_depth(&app), 2); + assert!(app.tabs[i].history.undo_stack.is_empty()); + assert!(!app.block_edit_dirty(i)); + app.undo_steps(1); + assert!(app.tabs[i] + .scene + .document + .block_records + .get("COPY") + .is_some()); + circle(&mut app, 3.0); + app.close_block_edit(i, true); + assert_eq!( + app.tabs[i] + .scene + .document + .block_records + .get("COPY") + .unwrap() + .entity_handles + .len(), + edited + ); + app.undo_steps(1); + assert!(app.tabs[i] + .scene + .document + .block_records + .get("COPY") + .is_none()); + assert_eq!(members(&app), saved); + app.redo_steps(1); + assert_eq!( + app.tabs[i] + .scene + .document + .block_records + .get("COPY") + .unwrap() + .entity_handles + .len(), + edited + ); +} + +#[test] +fn save_as_name_refusals_do_not_change_the_session() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + circle(&mut app, 1.0); + let i = app.active_tab; + let document = app.tabs[i].scene.document.clone(); + let depth = app.tabs[i].history.undo_stack.len(); + for name in ["", "bad/name", "edit_me", "EDIT_ME"] { + assert!(!app.save_block_edit_as(i, name)); + assert_eq!(app.tabs[i].scene.document, document); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + assert_eq!(main_depth(&app), 0); + assert_eq!( + app.tabs[i].active_block_edit_session().unwrap().block_name, + "EDIT_ME" + ); + } + assert_eq!( + app.command_line.last_error.as_deref(), + Some("block \"EDIT_ME\" already exists") + ); +} + +#[test] +fn save_as_dialog_cancel_and_enter_follow_the_current_session() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + circle(&mut app, 1.0); + let i = app.active_tab; + let document = app.tabs[i].scene.document.clone(); + let _ = app.update(Message::DropdownSelectItem { + dropdown_id: "BLOCK_SAVE", + cmd: "BSAVEAS", + }); + assert_eq!(app.active_modal, Some(ModalKind::BlockSaveAs)); + let _ = app.update(Message::BlockSaveAsName("edit_me".into())); + assert!(app.block_save_as.as_ref().unwrap().error.is_some()); + let _ = app.update(Message::CommandEscape); + assert!(app.block_save_as.is_none()); + assert_eq!(app.tabs[i].scene.document, document); + let _ = app.dispatch_command("BSAVEAS"); + let _ = app.update(Message::BlockSaveAsName("COPY".into())); + assert!(app.block_save_as.as_ref().unwrap().error.is_none()); + let _ = app.update(Message::CommandFinalize); + assert!(app.active_modal.is_none()); + assert_eq!( + app.tabs[i].active_block_edit_session().unwrap().block_name, + "COPY" + ); +} + +#[test] +fn save_as_preserves_base_point_description_units_and_nested_attributes() { + use codec::entities::AttributeEntity; + let (mut app, reference) = fixture(); + let i = app.active_tab; + app.tabs[i] + .scene + .define_block_raw("CHILD", Vector3::ZERO, vec![]); + let owner = app.tabs[i] + .scene + .document + .block_records + .get("EDIT_ME") + .unwrap() + .handle; + let mut nested = Insert::new("CHILD", Vector3::new(4.0, 5.0, 0.0)); + let mut attribute = AttributeEntity::new("TAG".into(), "value".into()); + attribute.common.handle = app.tabs[i].scene.document.allocate_handle(); + nested.attributes.push(attribute); + nested.common.owner_handle = owner; + nested.seqend_handle = Some(app.tabs[i].scene.document.allocate_handle()); + let nested = app.tabs[i] + .scene + .document + .add_entity(EntityType::Insert(nested)) + .unwrap(); + let base = Vector3::new(12.0, 34.0, 0.0); + let record = app.tabs[i] + .scene + .document + .block_records + .get_mut("EDIT_ME") + .unwrap(); + record.base_point = base; + record.description = "A reusable definition".into(); + record.units = 4; + let source_marker = record.block_entity_handle; + if let Some(EntityType::Block(marker)) = + app.tabs[i].scene.document.get_entity_mut(source_marker) + { + marker.base_point = base; + } + let source_insert = app.tabs[i] + .scene + .document + .get_entity(nested) + .unwrap() + .clone(); + begin(&mut app, reference); + assert!(app.save_block_edit_as(i, "COPY")); + let doc = &app.tabs[i].scene.document; + let record = doc.block_records.get("COPY").unwrap(); + assert_eq!(record.base_point, base); + assert_eq!(record.units, 4); + assert_eq!(record.description, "A reusable definition"); + assert_eq!(doc.get_entity(nested).unwrap(), &source_insert); + let copied = doc + .entities_in_block("COPY") + .find_map(|e| match e { + EntityType::Insert(insert) => Some(insert), + _ => None, + }) + .unwrap(); + let EntityType::Insert(original) = &source_insert else { + unreachable!() + }; + assert_eq!(copied.block_name, "CHILD"); + assert_eq!(copied.insert_point, original.insert_point); + assert_ne!( + copied.attributes[0].common.handle, + original.attributes[0].common.handle + ); + assert_eq!( + copied.attributes[0].common.owner_handle, + copied.common.handle + ); + assert_ne!(copied.seqend_handle, original.seqend_handle); + assert_eq!(copied.attributes[0].value, original.attributes[0].value); +} + +#[test] +fn save_as_clones_extension_dictionaries_and_draw_order_independently() { + use codec::objects::{ + Dictionary, ObjectType, SortEntitiesTable, XRecord, XRecordEntry, XRecordValue, + }; + let (mut app, reference) = fixture(); + let i = app.active_tab; + let owner = app.tabs[i] + .scene + .document + .block_records + .get("EDIT_ME") + .unwrap() + .handle; + let first = members(&app)[0]; + let mut second = Line::from_points(Vector3::ZERO, Vector3::UNIT_Y); + second.common.owner_handle = owner; + let second = app.tabs[i] + .scene + .document + .add_entity(EntityType::Line(second)) + .unwrap(); + let root = app.tabs[i] + .scene + .document + .ensure_extension_dictionary(owner); + let mut record = XRecord::new(); + record.handle = app.tabs[i].scene.document.allocate_handle(); + record.owner = root; + record + .entries + .push(XRecordEntry::new(340, XRecordValue::Handle(first))); + let leaf = record.handle; + app.tabs[i] + .scene + .document + .objects + .insert(leaf, ObjectType::XRecord(record)); + let mut order = SortEntitiesTable::for_block(owner); + order.handle = app.tabs[i].scene.document.allocate_handle(); + order.owner_handle = root; + order.add_entry(first, Handle::new(0x200)); + order.send_to_back(second); + let order_handle = order.handle; + app.tabs[i] + .scene + .document + .objects + .insert(order_handle, ObjectType::SortEntitiesTable(order)); + let Some(ObjectType::Dictionary(dictionary)) = + app.tabs[i].scene.document.objects.get_mut(&root) + else { + unreachable!() + }; + dictionary.add_entry("DATA", leaf); + dictionary.add_entry("ACAD_SORTENTS", order_handle); + let original: Dictionary = dictionary.clone(); + begin(&mut app, reference); + assert!(app.save_block_edit_as(i, "COPY")); + let doc = &app.tabs[i].scene.document; + let owner = doc.block_records.get("COPY").unwrap().handle; + let copy_members = doc + .block_records + .get("COPY") + .unwrap() + .entity_handles + .clone(); + let copied_root = doc.extension_dictionary_handle(owner).unwrap(); + assert_ne!(root, copied_root); + assert_eq!( + doc.objects.get(&root), + Some(&ObjectType::Dictionary(original)) + ); + let Some(ObjectType::Dictionary(dictionary)) = doc.objects.get(&copied_root) else { + unreachable!() + }; + let Some(ObjectType::XRecord(record)) = doc.objects.get(&dictionary.get("DATA").unwrap()) + else { + unreachable!() + }; + assert_eq!(record.owner, copied_root); + assert_eq!( + record.entries[0].value, + XRecordValue::Handle(copy_members[0]) + ); + let Some(ObjectType::SortEntitiesTable(order)) = + doc.objects.get(&dictionary.get("ACAD_SORTENTS").unwrap()) + else { + unreachable!() + }; + assert_eq!(order.block_owner_handle, owner); + assert!( + order.effective_sort_handle(copy_members[1]) < order.effective_sort_handle(copy_members[0]) + ); + app.close_block_edit(i, true); + app.undo_steps(1); + assert!(!app.tabs[i] + .scene + .document + .objects + .contains_key(&copied_root)); + app.redo_steps(1); + assert!(app.tabs[i] + .scene + .document + .objects + .contains_key(&copied_root)); +} + +#[test] +fn save_as_preserves_block_parameters_and_remaps_constraint_geometry() { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; + let (mut app, reference) = fixture(); + let i = app.active_tab; + let owner = app.tabs[i] + .scene + .document + .block_records + .get("EDIT_ME") + .unwrap() + .handle; + let member = members(&app)[0]; + let set = app.tabs[i] + .scene + .parametric_constraint_set_mut(ParametricScope::Block(owner)); + set.local_parameters.set("width", "7").unwrap(); + set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(member)], + None, + ); + begin(&mut app, reference); + app.tabs[i] + .scene + .parametric_constraint_set_mut(ParametricScope::Block(owner)) + .local_parameters + .set("width", "9") + .unwrap(); + assert!(app.save_block_edit_as(i, "COPY")); + let new_owner = app.tabs[i] + .scene + .document + .block_records + .get("COPY") + .unwrap() + .handle; + let copied = app.tabs[i] + .scene + .parametric_constraint_set(ParametricScope::Block(new_owner)) + .unwrap(); + assert_eq!(copied.local_parameters.resolve("width").unwrap(), 9.0); + assert_ne!(copied.constraints[0].refs[0].entity, member); + assert_eq!( + app.tabs[i] + .scene + .parametric_constraint_set(ParametricScope::Block(owner)) + .unwrap() + .local_parameters + .resolve("width") + .unwrap(), + 7.0 + ); + app.close_block_edit(i, true); + app.undo_steps(1); + assert!(app.tabs[i] + .scene + .parametric_constraint_set(ParametricScope::Block(new_owner)) + .is_none()); + app.redo_steps(1); + assert_eq!( + app.tabs[i] + .scene + .parametric_constraint_set(ParametricScope::Block(new_owner)) + .unwrap() + .local_parameters + .resolve("width") + .unwrap(), + 9.0 + ); +} + +#[test] +fn save_as_refuses_unsupported_owned_graphs_without_losing_edits() { + use codec::objects::{DynamicBlockObject, ObjectType}; + let (mut app, reference) = fixture(); + let i = app.active_tab; + let mut object = DynamicBlockObject::new("BLOCKLINEARPARAMETER", "LinearParameter"); + object.handle = app.tabs[i].scene.document.allocate_handle(); + object.owner = app.tabs[i] + .scene + .document + .block_records + .get("EDIT_ME") + .unwrap() + .handle; + app.tabs[i] + .scene + .document + .objects + .insert(object.handle, ObjectType::DynamicBlock(object)); + begin(&mut app, reference); + circle(&mut app, 1.0); + let document = app.tabs[i].scene.document.clone(); + assert!(!app.save_block_edit_as(i, "COPY")); + assert_eq!(app.tabs[i].scene.document, document); + assert_eq!(members(&app).len(), 2); + assert_eq!(main_depth(&app), 0); + assert!(app + .command_line + .last_error + .as_ref() + .unwrap() + .contains("native object relationships")); +} + +#[test] +fn save_as_retains_new_shared_layers_and_nested_definitions() { + use codec::entities::{AttributeDefinition, AttributeEntity, MText}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let _ = app.dispatch_command("LAYER NEW COPY_LAYER"); + let _ = app.dispatch_command("CLAYER COPY_LAYER"); + let mut definition = AttributeDefinition::new("TAG".into(), "Prompt".into(), "first".into()); + definition.is_multiline = true; + definition.mtext_flag = codec::entities::attribute_definition::MTextFlag::MultiLine; + let mut text = MText::new(); + text.value = "first\\Psecond".into(); + definition.embedded_mtext = Some(Box::new(text)); + app.tabs[i].scene.define_block_raw( + "NEW_CHILD", + Vector3::ZERO, + vec![EntityType::AttributeDefinition(definition)], + ); + let source_definition = app.tabs[i] + .scene + .document + .block_records + .get("NEW_CHILD") + .unwrap() + .entity_handles[0]; + let mut child = Insert::new("NEW_CHILD", Vector3::ZERO); + let mut attribute = AttributeEntity::new("TAG".into(), "first".into()); + attribute.attdef_handle = source_definition; + child.attributes.push(attribute); + child.common.layer = "COPY_LAYER".into(); + child.common.owner_handle = app.tabs[i].active_block_edit_session().unwrap().br_handle; + app.tabs[i] + .scene + .document + .add_entity(EntityType::Insert(child)) + .unwrap(); + assert!(app.save_block_edit_as(i, "COPY")); + assert_eq!(app.tabs[i].active_layer, "COPY_LAYER"); + assert!(app.tabs[i] + .scene + .document + .layers + .get("COPY_LAYER") + .is_some()); + assert!(app.tabs[i] + .scene + .document + .block_records + .get("NEW_CHILD") + .is_some()); + assert_eq!(members(&app).len(), 1); + let doc = &app.tabs[i].scene.document; + let copied_definition = doc.block_records.get("NEW_CHILD").unwrap().entity_handles[0]; + assert_ne!(copied_definition, source_definition); + let EntityType::AttributeDefinition(definition) = doc.get_entity(copied_definition).unwrap() + else { + unreachable!() + }; + let text = definition.embedded_mtext.as_ref().unwrap(); + assert_eq!(text.value, "first\\Psecond"); + assert!(!text.common.handle.is_null()); + let copied_attribute = doc + .entities_in_block("COPY") + .find_map(|entity| match entity { + EntityType::Insert(insert) => Some(&insert.attributes[0]), + _ => None, + }) + .unwrap(); + assert_eq!(copied_attribute.attdef_handle, copied_definition); + assert!(app.tabs[i].scene.document.entities_in_block("COPY").any(|entity| matches!(entity, EntityType::Insert(insert) if insert.block_name=="NEW_CHILD" && insert.common.layer=="COPY_LAYER"))); +} + +#[test] +fn save_as_can_create_an_empty_definition() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let original = members(&app); + app.push_undo_snapshot(i, "Erase"); + app.tabs[i].scene.erase_entities(&original); + app.finish_pending_history(i); + assert!(app.save_block_edit_as(i, "EMPTY")); + assert_eq!(members(&app), original); + assert!(app.tabs[i] + .scene + .document + .block_records + .get("EMPTY") + .unwrap() + .entity_handles + .is_empty()); + assert!(!app.block_edit_dirty(i)); +} + #[test] fn session_undo_preserves_membership_and_cannot_reach_drawing_history() { let (mut app, reference) = fixture(); diff --git a/src/app/block_save_as.rs b/src/app/block_save_as.rs new file mode 100644 index 000000000..e6abcb4bd --- /dev/null +++ b/src/app/block_save_as.rs @@ -0,0 +1,517 @@ +use codec::entities::{Block, BlockEnd, EntityCommon}; +use codec::objects::ObjectType; +use codec::tables::BlockRecord; +use codec::{CadDocument, EntityType, Handle}; +use rustc_hash::{FxHashMap, FxHashSet}; +use std::collections::HashMap; + +/// The copy is built in the last saved document, so the source definition and +/// its references remain at their checkpoint even when the editor is dirty. +pub(super) struct DefinitionCopy { + pub document: CadDocument, + pub handles: FxHashMap, + pub record: Handle, +} + +fn remap_common(common: &mut EntityCommon, handles: &FxHashMap) { + let map = |h: Handle| handles.get(&h).copied().unwrap_or(h); + common.handle = map(common.handle); + common.owner_handle = map(common.owner_handle); + common.xdictionary_handle = common.xdictionary_handle.map(map); + common.reactors = common + .reactors + .iter() + .filter_map(|h| handles.get(h).copied()) + .collect(); + let mut data = codec::xdata::ExtendedData::new(); + for mut record in common.extended_data.records().iter().cloned() { + for value in &mut record.values { + if let codec::xdata::XDataValue::Handle(handle) = value { + *handle = map(*handle); + } + } + data.add_record(record); + } + common.extended_data = data; + common.raw_record = None; +} + +fn cloneable_object(object: &ObjectType) -> bool { + matches!( + object, + ObjectType::Dictionary(_) + | ObjectType::DictionaryWithDefault(_) + | ObjectType::DictionaryVariable(_) + | ObjectType::SpatialFilter(_) + | ObjectType::ObjectContextData(_) + | ObjectType::SortEntitiesTable(_) + | ObjectType::Field(_) + | ObjectType::FieldList(_) + | ObjectType::PlaceHolder(_) + | ObjectType::ImageDefinitionReactor(_) + | ObjectType::XRecord(codec::objects::XRecord { + entries_complete: true, + .. + }) + ) +} + +pub(super) fn copy_definition( + source: &CadDocument, + mut target: CadDocument, + source_name: &str, + name: &str, +) -> Result { + let root = source + .block_records + .get(source_name) + .ok_or_else(|| format!("block \"{source_name}\" does not exist"))?; + // Shared symbol records can be created while editing. Retain them when + // restoring the source definition to its checkpoint. + target.header = source.header.clone(); + target.layers = source.layers.clone(); + target.line_types = source.line_types.clone(); + target.text_styles = source.text_styles.clone(); + target.dim_styles = source.dim_styles.clone(); + target.app_ids = source.app_ids.clone(); + let mut records = vec![(root.clone(), name.to_string())]; + let mut names = FxHashSet::default(); + names.insert(source_name.to_ascii_uppercase()); + let mut index = 0; + let mut anonymous = 0; + while index < records.len() { + for entity in source.entities_in_block(&records[index].0.name) { + if let EntityType::Insert(insert) = entity { + if insert.block_name.eq_ignore_ascii_case(name) { + return Err(crate::scene::block_service::circular_dependency_error( + name, + &insert.block_name, + )); + } + if target.block_records.get(&insert.block_name).is_none() + && names.insert(insert.block_name.to_ascii_uppercase()) + { + let record = source + .block_records + .get(&insert.block_name) + .ok_or_else(|| format!("block \"{}\" does not exist", insert.block_name))?; + records.push((record.clone(), record.name.clone())); + } + } + if let EntityType::Dimension(dimension) = entity { + let picture = &dimension.base().block_name; + if picture.is_empty() || !names.insert(picture.to_ascii_uppercase()) { + continue; + } + let Some(record) = source.block_records.get(picture) else { + return Err(format!("Dimension picture block \"{picture}\" is missing.")); + }; + let new_name = loop { + let candidate = format!("*D{anonymous}"); + anonymous += 1; + if target.block_records.get(&candidate).is_none() + && !records + .iter() + .any(|(_, name)| name.eq_ignore_ascii_case(&candidate)) + { + break candidate; + } + }; + records.push((record.clone(), new_name)); + } + } + index += 1; + } + + let mut entities = Vec::new(); + let mut identities = FxHashSet::default(); + for (record, _) in &records { + identities.insert(record.handle); + for marker in [record.block_entity_handle, record.block_end_handle] { + if let Some(entity) = source.get_entity(marker) { + entities.push(entity.clone()); + } + } + for entity in source.entities_in_block(&record.name) { + if matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_)) { + continue; + } + if matches!(entity, EntityType::Unknown(_)) { + return Err("Save Block As cannot copy opaque entities yet.".to_string()); + } + entities.push(entity.clone()); + } + } + for entity in &entities { + identities.insert(entity.common().handle); + match entity { + EntityType::Insert(insert) => { + identities.extend(insert.attributes.iter().map(|a| a.common.handle)); + identities.extend( + insert + .attributes + .iter() + .filter_map(|a| a.embedded_mtext.as_ref().map(|text| text.common.handle)), + ); + identities.extend(insert.seqend_handle); + } + EntityType::AttributeDefinition(attribute) => { + identities.extend( + attribute + .embedded_mtext + .as_ref() + .map(|text| text.common.handle), + ); + } + EntityType::AttributeEntity(attribute) => { + identities.extend( + attribute + .embedded_mtext + .as_ref() + .map(|text| text.common.handle), + ); + } + EntityType::Polyline3D(polyline) => { + identities.extend(polyline.vertices.iter().map(|v| v.handle)) + } + _ => {} + } + } + identities.remove(&Handle::NULL); + // Ownership, rather than arbitrary references, determines what must be + // independent. Nested INSERT definitions and shared styles stay shared. + let mut objects = Vec::new(); + loop { + let added: Vec<_> = source + .objects + .iter() + .filter(|(handle, _)| { + !identities.contains(handle) + && source + .object_owner(**handle) + .is_some_and(|owner| identities.contains(&owner)) + }) + .map(|(handle, object)| (*handle, object.clone())) + .collect(); + if added.is_empty() { + break; + } + for (handle, object) in added { + if !cloneable_object(&object) { + return Err( + "Save Block As cannot copy this block's native object relationships yet." + .to_string(), + ); + } + identities.insert(handle); + objects.push((handle, object)); + } + } + for owner in &identities { + if let Some(dictionary) = source.extension_dictionary_handle(*owner) { + if !identities.contains(&dictionary) { + return Err( + "Save Block As requires an independently owned extension dictionary." + .to_string(), + ); + } + } + } + for (_, object) in &objects { + if let ObjectType::Dictionary(dictionary) = object { + if dictionary.entries.iter().any(|(key, handle)| { + !handle.is_null() + && (dictionary.hard_owner || dictionary.is_entry_hard_owner(key)) + && !identities.contains(handle) + }) { + return Err( + "Save Block As cannot duplicate a dictionary's foreign owned entries." + .to_string(), + ); + } + } + } + identities.remove(&Handle::NULL); + let floor = source + .next_handle() + .max(source.header.handle_seed) + .max(target.next_handle()); + if target.next_handle() < floor { + target.header.handle_seed = floor - 1; + target.allocate_handle(); + } + let mut identities: Vec<_> = identities.into_iter().collect(); + identities.sort_by_key(|h| h.value()); + let handles: FxHashMap<_, _> = identities + .into_iter() + .map(|h| (h, target.allocate_handle())) + .collect(); + let remap: HashMap<_, _> = handles.iter().map(|(a, b)| (*a, *b)).collect(); + let block_names: HashMap<_, _> = records + .iter() + .map(|(r, n)| (r.name.to_ascii_uppercase(), n.clone())) + .collect(); + for (record, new_name) in &records { + let mut copied: BlockRecord = record.clone(); + copied.name = new_name.clone(); + copied.handle = handles[&record.handle]; + copied.block_entity_handle = handles + .get(&record.block_entity_handle) + .copied() + .unwrap_or_else(|| target.allocate_handle()); + copied.block_end_handle = handles + .get(&record.block_end_handle) + .copied() + .unwrap_or_else(|| target.allocate_handle()); + copied.entity_handles.clear(); + copied.insert_handles.clear(); + copied.insert_count_bytes.clear(); + copied.preview_data.clear(); + copied.flags.anonymous = new_name.starts_with('*'); + copied.layout = Handle::NULL; + target + .block_records + .add(copied.clone()) + .map_err(|e| e.to_string())?; + if source.get_entity(record.block_entity_handle).is_none() { + let mut marker = Block::new(new_name, record.base_point); + marker.common.handle = copied.block_entity_handle; + marker.common.owner_handle = copied.handle; + target + .add_entity(EntityType::Block(marker)) + .map_err(|e| e.to_string())?; + } + if source.get_entity(record.block_end_handle).is_none() { + let mut marker = BlockEnd::new(); + marker.common.handle = copied.block_end_handle; + marker.common.owner_handle = copied.handle; + target + .add_entity(EntityType::BlockEnd(marker)) + .map_err(|e| e.to_string())?; + } + } + for mut entity in entities { + remap_common(entity.common_mut(), &handles); + match &mut entity { + EntityType::Block(block) => { + block.name = block_names[&block.name.to_ascii_uppercase()].clone(); + } + EntityType::Insert(insert) => { + insert.seqend_handle = insert.seqend_handle.map(|h| handles[&h]); + let copied_definition = block_names + .get(&insert.block_name.to_ascii_uppercase()) + .is_some_and(|name| name.eq_ignore_ascii_case(&insert.block_name)); + for attribute in &mut insert.attributes { + remap_common(&mut attribute.common, &handles); + if attribute.common.handle.is_null() { + attribute.common.handle = target.allocate_handle(); + } + attribute.common.owner_handle = insert.common.handle; + if copied_definition { + attribute.attdef_handle = handles + .get(&attribute.attdef_handle) + .copied() + .unwrap_or(attribute.attdef_handle); + } + if let Some(text) = &mut attribute.embedded_mtext { + remap_common(&mut text.common, &handles); + if text.common.handle.is_null() { + text.common.handle = target.allocate_handle(); + } + } + } + } + EntityType::AttributeDefinition(attribute) => { + if let Some(text) = &mut attribute.embedded_mtext { + remap_common(&mut text.common, &handles); + if text.common.handle.is_null() { + text.common.handle = target.allocate_handle(); + } + } + } + EntityType::AttributeEntity(attribute) => { + attribute.attdef_handle = handles + .get(&attribute.attdef_handle) + .copied() + .unwrap_or(attribute.attdef_handle); + if let Some(text) = &mut attribute.embedded_mtext { + remap_common(&mut text.common, &handles); + if text.common.handle.is_null() { + text.common.handle = target.allocate_handle(); + } + } + } + EntityType::Polyline3D(polyline) => { + for vertex in &mut polyline.vertices { + vertex.handle = handles + .get(&vertex.handle) + .copied() + .unwrap_or_else(|| target.allocate_handle()); + } + } + EntityType::Dimension(dimension) => { + if let Some(name) = + block_names.get(&dimension.base().block_name.to_ascii_uppercase()) + { + dimension.base_mut().block_name = name.clone(); + } + } + EntityType::Leader(leader) => { + if let Some(handle) = handles.get(&leader.annotation_handle) { + leader.annotation_handle = *handle; + } + } + EntityType::RasterImage(image) => { + image.definition_reactor_handle = image + .definition_reactor_handle + .map(|h| handles.get(&h).copied().unwrap_or(h)); + } + EntityType::Wipeout(image) => { + image.definition_reactor_handle = image + .definition_reactor_handle + .map(|h| handles.get(&h).copied().unwrap_or(h)); + } + _ => {} + } + target.add_entity(entity).map_err(|e| e.to_string())?; + } + crate::scene::remap_hatch_associations(&mut target, &handles); + for (old, mut object) in objects { + let new = handles[&old]; + match &mut object { + ObjectType::SortEntitiesTable(table) => { + let source_block = table.block_owner_handle; + let mut ordered: Vec<_> = source + .entities() + .filter(|e| { + e.common().owner_handle == source_block + && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) + }) + .map(|e| { + ( + table.effective_sort_handle(e.common().handle), + e.common().handle, + ) + }) + .collect(); + ordered.sort_by_key(|(sort, entity)| (sort.value(), entity.value())); + table.handle = new; + table.owner_handle = handles[&table.owner_handle]; + table.block_owner_handle = handles[&source_block]; + table.clear(); + table.raw_dxf_codes = None; + table.raw_dxf_version = None; + for (_, entity) in ordered { + table.add_entry(handles[&entity], target.allocate_handle()); + } + } + ObjectType::Field(field) => { + field.handle = new; + field.owner = handles[&field.owner]; + for h in field + .child_fields + .iter_mut() + .chain(field.referenced_objects.iter_mut()) + { + if let Some(new) = handles.get(h) { + *h = *new; + } + } + for value in std::iter::once(&mut field.value) + .chain(field.child_values.iter_mut().map(|c| &mut c.value)) + { + if let Some(handle) = value.handle_value.as_mut() { + if let Some(new) = handles.get(handle) { + *handle = *new; + } + } + } + } + ObjectType::FieldList(fields) => { + fields.handle = new; + fields.owner = handles[&fields.owner]; + for h in &mut fields.fields { + if let Some(new) = handles.get(h) { + *h = *new; + } + } + } + ObjectType::PlaceHolder(placeholder) => { + placeholder.handle = new; + placeholder.owner = handles[&placeholder.owner]; + } + ObjectType::ImageDefinitionReactor(reactor) => { + reactor.handle = new; + reactor.owner = handles[&reactor.owner]; + if let Some(new) = handles.get(&reactor.image_handle) { + reactor.image_handle = *new; + } + } + _ => super::command_driver::remap_object(&mut object, new, &remap), + } + if let ObjectType::XRecord(record) = &mut object { + for reference in &mut record.object_references { + if let Some(new) = handles.get(&reference.handle) { + reference.handle = *new; + } + } + record.reactors = record + .reactors + .iter() + .filter_map(|h| handles.get(h).copied()) + .collect(); + record.xdictionary_handle = record + .xdictionary_handle + .map(|h| handles.get(&h).copied().unwrap_or(h)); + record.raw_dwg_data = None; + record.raw_dwg_version = None; + } + target.objects.insert(new, object); + } + for (_, name) in &records { + let has_attributes = target + .entities_in_block(name) + .any(|entity| matches!(entity, EntityType::AttributeDefinition(_))); + target + .block_records + .get_mut(name) + .unwrap() + .flags + .has_attributes = has_attributes; + for entity in target.entities_in_block(name) { + if let EntityType::Insert(insert) = entity { + if crate::scene::block_service::would_create_cycle( + &target, + name, + &insert.block_name, + ) { + return Err(crate::scene::block_service::circular_dependency_error( + name, + &insert.block_name, + )); + } + } + } + } + for (old, new) in &handles { + if let Some(field) = source.fields.get(old) { + let mut field = field.clone(); + field.handle = *new; + field.owner = handles.get(&field.owner).copied().unwrap_or(field.owner); + for object in &mut field.objects { + if let Some(new) = handles.get(object) { + *object = *new; + } + } + target.fields.insert(*new, field); + } + if let Some(scale) = source.context_scales.get(old) { + target.context_scales.insert(*new, *scale); + } + } + Ok(DefinitionCopy { + record: handles[&root.handle], + document: target, + handles, + }) +} diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 1109c321d..62dc55ec2 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -95,7 +95,8 @@ mod utilities; mod viewport; pub(crate) use clipboard::{ - copy_to_clipboard_kernel, paste_entities_kernel, remap_ext_subtree_reference, PasteSpace, + copy_to_clipboard_kernel, paste_entities_kernel, remap_ext_subtree_reference, remap_object, + PasteSpace, }; impl OpenCADStudio { diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 1b6831d70..d5fbd9d9f 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -614,6 +614,12 @@ impl OpenCADStudio { "BSAVE" => { self.save_block_edit(i); } + "BSAVEAS" => { + self.open_block_save_as_dialog(i); + } + cmd if cmd.starts_with("BSAVEAS:") => { + self.save_block_edit_as(i, &cmd["BSAVEAS:".len()..]); + } "BCLOSE" => { if self.tabs[i].active_block_edit.is_none() { self.command_line diff --git a/src/app/history.rs b/src/app/history.rs index c294f9aca..04b2fb37f 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1035,6 +1035,20 @@ impl OpenCADStudio { scene.bump_constraints_epoch(); } for entry in &d.parametric_constraints { + if let crate::scene::parametric_constraints::ParametricScope::Block(owner) = entry.scope + { + if !scene + .document + .block_records + .iter() + .any(|record| record.handle == owner) + { + scene + .parametric_constraints + .retain(|set| set.scope != entry.scope); + continue; + } + } let value = if undo { &entry.before } else { &entry.after }; *scene.parametric_constraint_set_mut(entry.scope) = value.clone(); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 5fd2a51ca..9b39e5aee 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod config; pub use automation::{export_headless, serve}; mod annotation_data; mod block_editor; +mod block_save_as; #[cfg(test)] mod block_editor_tests; mod command_driver; @@ -570,6 +571,7 @@ pub(super) struct OpenCADStudio { drawing_units: Option, /// Working copy of the Block Definition dialog; `None` while it is closed. block_definition: Option, + block_save_as: Option, /// PDF dialogs' working copies; `None` while closed. pdf_attach: Option, point_cloud_attach: Option, @@ -2177,6 +2179,7 @@ pub enum ModalKind { DimStyle, Unsaved, BlockEditChanges, + BlockSaveAs, SaveDialog, Recovery, /// Fonts referenced by the drawing are missing on this machine. @@ -2807,6 +2810,8 @@ pub enum Message { BlockEditChangesSave, BlockEditChangesDiscard, BlockEditChangesCancel, + BlockSaveAsName(String), + BlockSaveAsApply, // ── AEC / unsupported-object drop warning (lossy Save-As) ────────────── /// Save as the source DWG version so the unsupported objects survive. AecDropSameVersion, @@ -4551,6 +4556,7 @@ impl OpenCADStudio { layer_translator: None, drawing_units: None, block_definition: None, + block_save_as: None, pdf_attach: None, point_cloud_attach: None, attdef_dialog: None, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 184afbe13..e9295a3fb 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -229,6 +229,7 @@ impl OpenCADStudio { } Some(GeometricTolerance) => self.geometric_tolerance = None, Some(BlockDefinition) => self.block_definition = None, + Some(BlockSaveAs) => self.block_save_as = None, Some(PdfAttach) => self.pdf_attach = None, Some(UnderlayLayers) => self.underlay_layers = None, Some(PdfImportSettings) => self.pdf_import_settings = None, @@ -443,6 +444,12 @@ impl OpenCADStudio { } } } + if self.active_modal == Some(super::ModalKind::BlockSaveAs) + && (matches!(msg, Message::CommandFinalize) + || matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN"))) + { + return self.apply_block_save_as_dialog(); + } if self.active_modal == Some(super::ModalKind::WriteBlock) { if matches!(msg, Message::CommandFinalize) || matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN")) @@ -6539,7 +6546,13 @@ impl OpenCADStudio { self.ribbon.close_dropdown(); return Task::none(); } - self.ribbon.select_dropdown_item(dropdown_id, cmd); + // Save As is a secondary operation; the save face keeps BSAVE. + let face = if dropdown_id == "BLOCK_SAVE" { + "BSAVE" + } else { + cmd + }; + self.ribbon.select_dropdown_item(dropdown_id, face); self.ribbon.activate_tool(cmd); self.dispatch_command(cmd) } @@ -10442,6 +10455,16 @@ impl OpenCADStudio { Message::UnsavedDialogSave => self.on_unsaved_dialog_save(), Message::BlockEditChangesSave => self.resolve_block_edit_close(true), + Message::BlockSaveAsName(name) => { + let i = self.active_tab; + let error = self.validate_block_save_as_name(i, &name).err(); + if let Some(state) = self.block_save_as.as_mut() { + state.name = name; + state.error = error; + } + Task::none() + } + Message::BlockSaveAsApply => self.apply_block_save_as_dialog(), Message::BlockEditChangesDiscard => self.resolve_block_edit_close(false), Message::BlockEditChangesCancel => { self.cancel_block_edit_close(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index bb3cb3ec3..b9a9b8ba6 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -77,6 +77,7 @@ impl OpenCADStudio { Some(K::LayerDeleteWarning) => crate::tr!("modal", "delete-layer"), Some(K::Unsaved) => crate::tr!("modal", "unsaved-changes"), Some(K::BlockEditChanges) => crate::t!("Block Editor").into_owned(), + Some(K::BlockSaveAs) => crate::t!("Save Block As").into_owned(), Some(K::PointStyle) => crate::tr!("modal", "point-style"), Some(K::AttributeEditor) => crate::tr!("modal", "attribute-editor"), Some(K::SaveDialog) => crate::tr!("modal", "save-drawing-as"), @@ -498,6 +499,12 @@ impl OpenCADStudio { crate::ui::window::block_definition::view_window(state, flow) }) } + super::super::ModalKind::BlockSaveAs => { + let state = self.block_save_as.as_ref()?; + sized_flow(ex, 430, 370, |flow| { + crate::ui::window::block_save_as::view(state, flow) + }) + } super::super::ModalKind::PdfAttach => { let state = self.pdf_attach.as_ref()?; sized_flow(ex, 880, 540, |flow| { diff --git a/src/modules/block_editor/mod.rs b/src/modules/block_editor/mod.rs index 7c3f4d8cf..26f0f4379 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -36,11 +36,28 @@ impl CadModule for BlockEditorModule { "Edit Block", include_bytes!("../../../assets/icons/edit_block.svg"), ), - command( - "BSAVE", - "Save Block", - include_bytes!("../../../assets/icons/mt_ok.svg"), - ), + RibbonItem::LargeDropdown { + id: "BLOCK_SAVE", + label: "Save Block", + icon: IconKind::Svg(include_bytes!("../../../assets/icons/mt_ok.svg")), + items: vec![ + ( + "BSAVE", + "Save Block", + IconKind::Svg(include_bytes!( + "../../../assets/icons/mt_ok.svg" + )), + ), + ( + "BSAVEAS", + "Save Block As", + IconKind::Svg(include_bytes!( + "../../../assets/icons/ui/save.svg" + )), + ), + ], + default: "BSAVE", + }, command( "BCLOSE", "Close Block Editor", diff --git a/src/modules/draw/modify/block_edit.rs b/src/modules/draw/modify/block_edit.rs index fcf41a47b..3d7c0a0d1 100644 --- a/src/modules/draw/modify/block_edit.rs +++ b/src/modules/draw/modify/block_edit.rs @@ -31,6 +31,12 @@ pub fn block_edit_tools() -> Vec { icon: IconKind::Svg(include_bytes!("../../../../assets/icons/mt_ok.svg")), event: ModuleEvent::Command("BSAVE".to_string()), }, + ToolDef { + id: "BSAVEAS", + label: "Save Block As", + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/ui/save.svg")), + event: ModuleEvent::Command("BSAVEAS".to_string()), + }, ToolDef { id: "BCLOSE", label: "Close Block Editor", @@ -111,5 +117,5 @@ inventory::submit!(crate::command::CommandRegistration { names: &["BEDIT_DISCARD"] }); inventory::submit!(crate::command::CommandRegistration { - names: &["BSAVE", "BCLOSE"] + names: &["BSAVE", "BSAVEAS", "BCLOSE"] }); diff --git a/src/ui/window/block_save_as.rs b/src/ui/window/block_save_as.rs new file mode 100644 index 000000000..6aca890cb --- /dev/null +++ b/src/ui/window/block_save_as.rs @@ -0,0 +1,52 @@ +use crate::app::Message; +use crate::scene::WireModel; +use iced::widget::{button, column, container, pick_list, row, text, text_input, Space}; +use iced::{Element, Fill}; + +pub(crate) struct State { + pub document_id: u64, + pub source: codec::Handle, + pub name: String, + pub existing_names: Vec, + pub preview: Vec, + pub error: Option, +} + +pub(crate) fn view(state: &State, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> { + let field = text_input(crate::t!("Block name").as_ref(), &state.name) + .on_input(Message::BlockSaveAsName) + .on_submit_maybe(state.error.is_none().then_some(Message::BlockSaveAsApply)) + .style(crate::ui::style::form::field_style); + let names = pick_list( + None::, + state.existing_names.as_slice(), + |name: &String| name.clone(), + ) + .on_select(Message::BlockSaveAsName) + .placeholder(crate::t!("Existing blocks").into_owned()) + .width(Fill); + container( + column![ + text(crate::t!("Block name").into_owned()).size(13), + field, + names, + super::block_palette::preview_canvas(&state.preview, 160.0), + text(state.error.clone().unwrap_or_default()) + .size(12) + .style(text::danger), + row![ + Space::new().width(Fill), + button(text(crate::t!("OK").into_owned())) + .on_press_maybe(state.error.is_none().then_some(Message::BlockSaveAsApply)) + .style(button::primary), + button(text(crate::t!("Cancel").into_owned())).on_press(Message::CloseModal), + ] + .spacing(8), + ] + .spacing(10), + ) + .padding(16) + .width(sizing.width) + .height(sizing.height) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index c6c870366..a2a55c9f9 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -5,6 +5,7 @@ pub mod attdef_dialog; pub mod attribute_editor; pub mod auto_constrain_settings; pub mod block_definition; +pub mod block_save_as; pub mod block_palette; pub mod browser; pub mod count_palette;