From 78bdfa3c747dfa9027c14aa3c245272405438de9 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 13 Oct 2026 16:47:51 +0100 Subject: [PATCH] feat(block-editor): add Save Block As Create a new named definition from current session edits, restore the source checkpoint, and continue editing the copy with fresh local history. Add the name dialog and Save dropdown, clone supported owned metadata onto independent handles, and refuse unsupported relationships atomically. --- src/app/block_editor.rs | 192 ++++++++++ src/app/block_editor_tests.rs | 514 +++++++++++++++++++++++++ src/app/block_save_as.rs | 517 ++++++++++++++++++++++++++ src/app/command_driver/mod.rs | 3 +- src/app/commands/inquiry.rs | 6 + src/app/history.rs | 14 + src/app/mod.rs | 6 + src/app/update/mod.rs | 25 +- src/app/view/modal.rs | 7 + src/modules/block_editor/mod.rs | 27 +- src/modules/draw/modify/block_edit.rs | 8 +- src/ui/window/block_save_as.rs | 52 +++ src/ui/window/mod.rs | 1 + 13 files changed, 1364 insertions(+), 8 deletions(-) create mode 100644 src/app/block_save_as.rs create mode 100644 src/ui/window/block_save_as.rs 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;