diff --git a/src/app/automation.rs b/src/app/automation.rs index dfd666579..a6e10ddba 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -700,6 +700,9 @@ impl OpenCADStudio { }; match req["op"].as_str().unwrap_or("") { "new" => { + if self.tabs[self.active_tab].block_test.is_some() { + return err("Close Test Block before replacing the drawing."); + } if self.tabs[self.active_tab].block_edit_history.is_some() { return err("Close the block editor before replacing the drawing."); } @@ -726,6 +729,9 @@ impl OpenCADStudio { } #[cfg(not(target_arch = "wasm32"))] "open" => { + if self.tabs[self.active_tab].block_test.is_some() { + return err("Close Test Block before replacing the drawing."); + } if self.tabs[self.active_tab].block_edit_history.is_some() { return err("Close the block editor before replacing the drawing."); } @@ -792,6 +798,7 @@ impl OpenCADStudio { if self.command_line.error_revision != error_revision { return err(self.command_line.last_error.clone().unwrap_or_default()); } + let i = self.active_tab; let after = self.tabs[i].scene.document.entities().count(); // A command can finish with an interactive surface still open: // the in-place text editor (the `TEXT` content step), the MTEXT @@ -996,6 +1003,9 @@ impl OpenCADStudio { json!({ "ok": true, "selected": self.tabs[i].scene.selected_entities().len() }) } "save" => { + if self.tabs[self.active_tab].block_test.is_some() { + return err("Test Block is temporary and cannot be saved."); + } let i = self.active_tab; let path = req["path"] .as_str() @@ -1533,6 +1543,11 @@ impl OpenCADStudio { #[cfg(not(target_arch = "wasm32"))] pub(super) fn save_verified_request(&mut self, req: &Value) -> Result { let i = self.active_tab; + if self.tabs[i].block_test.is_some() { + return Err( + json!({"ok":false,"code":"temporary_document","error":"Test Block is temporary and cannot be saved."}), + ); + } let Some(raw_path) = req["path"].as_str() else { return Err( json!({"ok":false,"status":"failed","code":"path_required","error":"save_verified requires an explicit absolute path"}), diff --git a/src/app/block_editor.rs b/src/app/block_editor.rs index 529a4c845..a74bbd65f 100644 --- a/src/app/block_editor.rs +++ b/src/app/block_editor.rs @@ -499,6 +499,7 @@ impl OpenCADStudio { &session.return_camera, ); self.cancel_attr_editor(); + self.discard_block_tests(self.tabs[i].id); } pub(super) fn request_block_edit_close( @@ -506,6 +507,7 @@ impl OpenCADStudio { i: usize, continuation: BlockEditContinuation, ) -> Task { + let id = self.tabs[i].id; if self.tabs[i].active_block_edit_session().is_some() { if self.block_edit_dirty(i) { self.active_tab = i; @@ -515,6 +517,7 @@ impl OpenCADStudio { } self.close_block_edit(i, true); } + let i = self.tabs.iter().position(|tab| tab.id == id).unwrap(); self.continue_after_block_edit(continuation, i) } @@ -564,6 +567,7 @@ impl OpenCADStudio { self.pending_block_close = None; self.active_modal = None; self.close_block_edit(i, !save); + let i = self.tabs.iter().position(|tab| tab.id == id).unwrap(); self.continue_after_block_edit(continuation, i) } @@ -583,7 +587,11 @@ impl OpenCADStudio { { return self.request_block_edit_close(i, BlockEditContinuation::Quit); } - if self.tabs.iter().any(|tab| tab.dirty) { + if self + .tabs + .iter() + .any(|tab| tab.dirty && tab.block_test.is_none()) + { self.pending_close = Some(super::PendingClose::Quit); self.open_unsaved_dialog_window() } else { diff --git a/src/app/block_test.rs b/src/app/block_test.rs new file mode 100644 index 000000000..729c4d829 --- /dev/null +++ b/src/app/block_test.rs @@ -0,0 +1,163 @@ +use super::{document::DocumentTab, Message, OpenCADStudio}; +use codec::entities::{AttributeEntity, Entity, Insert}; +use codec::EntityType; +use iced::Task; + +pub(super) struct BlockTestSession { + pub source_id: u64, +} + +impl OpenCADStudio { + pub(super) fn refuse_block_test_save(&mut self, i: usize) -> bool { + if self.tabs[i].block_test.is_none() { + return false; + } + self.command_line.push_error( + crate::t!("Test Block is temporary. Close it and save from the Block Editor.").as_ref(), + ); + true + } + + pub(super) fn open_block_test(&mut self, i: usize) -> Task { + let source_id = self.tabs[i] + .block_test + .as_ref() + .map_or(self.tabs[i].id, |test| test.source_id); + let Some(i) = self.tabs.iter().position(|tab| tab.id == source_id) else { + return Task::none(); + }; + 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 Task::none(); + }; + let name = session.block_name.clone(); + let definition = session.br_handle; + self.finish_pending_history(i); + let mut document = self.tabs[i].scene.document.clone(); + // Keep definitions and their native relationships in the clone. Only + // drawing-space contents are replaced by the temporary reference. + let spaces: Vec<_> = document + .block_records + .iter() + .filter(|record| record.is_model_space() || record.is_paper_space()) + .map(|record| record.handle) + .collect(); + let removed: Vec<_> = document + .entities() + .filter(|entity| { + spaces.contains(&entity.common().owner_handle) + && !matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_)) + }) + .map(|entity| entity.common().handle) + .collect(); + for handle in removed { + document.remove_entity(handle); + } + for record in document + .block_records + .iter_mut() + .filter(|record| spaces.contains(&record.handle)) + { + record.entity_handles.retain(|handle| { + *handle == record.block_entity_handle || *handle == record.block_end_handle + }); + record.insert_handles.clear(); + record.insert_count_bytes.clear(); + } + document.header.show_model_space = true; + crate::scene::block_visibility::finish_editing(&mut document, definition); + let record = document.block_records.get(&name).unwrap(); + let base = record.base_point; + let mut insert = Insert::new(&name, codec::types::Vector3::ZERO); + insert.common.layer = self.tabs[i].active_layer.clone(); + insert.common.handle = document.allocate_handle(); + insert.common.owner_handle = document.header.model_space_block_handle; + let transform = crate::scene::render_graph::insert_transform_with_base(&insert, base); + for entity in document.entities_in_block(&name) { + if let EntityType::AttributeDefinition(definition) = entity { + if !definition.flags.constant { + let mut attribute = AttributeEntity::from_definition(definition, None); + attribute.attdef_handle = definition.common.handle; + attribute.apply_transform(&transform); + insert.attributes.push(attribute); + } + } + } + for attribute in &mut insert.attributes { + attribute.common.handle = document.allocate_handle(); + attribute.common.owner_handle = insert.common.handle; + if let Some(text) = &mut attribute.embedded_mtext { + text.common.handle = document.allocate_handle(); + text.common.owner_handle = attribute.common.handle; + text.common.raw_record = None; + text.common.xdictionary_handle = None; + text.common.reactors.clear(); + } + } + if !insert.attributes.is_empty() { + insert.seqend_handle = Some(document.allocate_handle()); + } + if let Err(error) = document.add_entity(EntityType::Insert(insert)) { + self.command_line.push_error(&error.to_string()); + return Task::none(); + } + let mut tab = DocumentTab::new_drawing(0); + tab.tab_title = crate::tf!("Test Block Window - {}", name).into_owned(); + tab.block_test = Some(BlockTestSession { source_id }); + tab.active_layer = self.tabs[i].active_layer.clone(); + tab.scene.document = document; + tab.scene.named_parameters = self.tabs[i].scene.named_parameters.clone(); + tab.scene.parametric_constraints = self.tabs[i] + .scene + .parametric_constraints + .iter() + .filter(|set| { + matches!( + set.scope, + crate::scene::parametric_constraints::ParametricScope::Block(_) + ) + }) + .cloned() + .collect(); + tab.scene.material_base_dir = self.tabs[i].scene.material_base_dir.clone(); + tab.scene.sync_annotation_scale_from_header(); + tab.scene.rebuild_derived_caches(); + tab.scene.set_current_layout("Model".into()); + self.discard_block_tests(source_id); + self.visibility_popup = None; + self.grip_popup = None; + self.grip_hover = None; + self.tabs.push(tab); + self.active_tab = self.tabs.len() - 1; + self.sync_ribbon_layers(); + self.sync_ribbon_styles(); + self.sync_ribbon_from_selection(); + self.refresh_block_palette(); + self.refresh_properties(); + self.ribbon.set_block_editing(false); + self.ribbon.set_block_testing(true); + self.dispatch_command("ZOOM EXTENTS") + } + + pub(super) fn close_block_test(&mut self, i: usize) -> Task { + if self.tabs[i].block_test.is_none() { + self.command_line + .push_error(crate::t!("No Test Block is open.").as_ref()); + return Task::none(); + } + self.on_tab_close(i) + } + + pub(super) fn discard_block_tests(&mut self, source_id: u64) { + for index in (0..self.tabs.len()).rev() { + if self.tabs[index] + .block_test + .as_ref() + .is_some_and(|test| test.source_id == source_id) + { + let _ = self.on_tab_close(index); + } + } + } +} diff --git a/src/app/block_test_tests.rs b/src/app/block_test_tests.rs new file mode 100644 index 000000000..8fa9a2647 --- /dev/null +++ b/src/app/block_test_tests.rs @@ -0,0 +1,346 @@ +use super::{Message, ModalKind, OpenCADStudio}; +use codec::entities::{Insert, Line}; +use codec::types::Vector3; +use codec::{EntityType, Handle}; + +fn fixture() -> (OpenCADStudio, usize, Handle) { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.tabs[i].scene.define_block_raw( + "SAMPLE", + Vector3::new(10.0, 20.0, 0.0), + vec![EntityType::Line(Line::from_points( + Vector3::new(10.0, 20.0, 0.0), + Vector3::new(15.0, 20.0, 0.0), + ))], + ); + app.tabs[i] + .scene + .document + .block_records + .get_mut("SAMPLE") + .unwrap() + .base_point = Vector3::new(10.0, 20.0, 0.0); + let reference = app.tabs[i] + .scene + .document + .add_entity(EntityType::Insert(Insert::new( + "SAMPLE", + Vector3::new(100.0, 200.0, 0.0), + ))) + .unwrap(); + let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", reference.value())); + (app, i, reference) +} + +#[test] +fn block_test_uses_unsaved_definition_and_preserves_editor_history_and_view() { + let (mut app, source, _) = fixture(); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 12,20 2"}"#)["ok"], + true + ); + app.finish_pending_history(source); + let document = app.tabs[source].scene.document.clone(); + let depth = app.tabs[source].history.undo_stack.len(); + let camera = app.tabs[source].scene.camera.borrow().clone(); + let source_id = app.tabs[source].id; + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"BTESTBLOCK"}"#)["ok"], + true + ); + let test = app.active_tab; + assert_ne!(app.tabs[test].id, source_id); + assert!(app.tabs[test].active_block_edit.is_none()); + assert_eq!( + app.tabs[test].scene.document.block_records.get("SAMPLE"), + document.block_records.get("SAMPLE") + ); + let model = app.tabs[test] + .scene + .document + .header + .model_space_block_handle; + let top: Vec<_> = app.tabs[test] + .scene + .document + .entities() + .filter(|e| { + e.common().owner_handle == model + && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) + }) + .collect(); + assert_eq!(top.len(), 1); + let EntityType::Insert(reference) = top[0] else { + panic!("missing test reference") + }; + assert_eq!(reference.insert_point, Vector3::ZERO); + assert_eq!(reference.block_name, "SAMPLE"); + assert!(app.tabs[test].history.undo_stack.is_empty()); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 1000,1000 8"}"#)["ok"], + true + ); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"BCLOSETESTBLOCK"}"#)["ok"], + true + ); + assert_eq!(app.active_tab, source); + assert_eq!(app.tabs[source].scene.document, document); + assert_eq!(app.tabs[source].history.undo_stack.len(), depth); + assert_eq!(app.tabs[source].scene.camera.borrow().target, camera.target); + assert!(app.active_modal.is_none()); + app.undo_steps(1); + assert_eq!( + app.tabs[source] + .scene + .document + .entities_in_block("SAMPLE") + .count(), + 1 + ); +} + +#[test] +fn block_test_keytips_follow_context_and_offer_close() { + let (mut app, source, _) = fixture(); + let tab_tip = |app: &OpenCADStudio, target: &str| { + app.ribbon.keytips_json(false)["tips"] + .as_array() + .unwrap() + .iter() + .any(|tip| tip["target"] == target) + }; + app.ribbon.show_keytips(false); + assert!(!tab_tip(&app, "block_test")); + let _ = app.ribbon.keytip_type(Some("H")); + assert!(tab_tip(&app, "LINE")); + assert!(!tab_tip(&app, "BCLOSETESTBLOCK")); + let _ = app.ribbon.hide_keytips(); + let _ = app.open_block_test(source); + app.ribbon.show_keytips(false); + assert_eq!(app.ribbon.keytips_json(false)["tab"], "draw"); + assert!(!tab_tip(&app, "block_test")); + assert!(!tab_tip(&app, "block_editor")); + let _ = app.ribbon.keytip_type(Some("H")); + assert!(tab_tip(&app, "LINE")); + assert!(tab_tip(&app, "Close")); + assert!(tab_tip(&app, "BCLOSETESTBLOCK")); + let _ = app.ribbon.keytip_type(Some("C")); + let crate::ui::ribbon::KeyTipStep::Run(close) = app.ribbon.keytip_type(Some("T")) else { + panic!("close key tip did not dispatch a ribbon action"); + }; + assert!(matches!( + &close, + Message::RibbonToolClick { event: crate::modules::ModuleEvent::Command(command), .. } + if command == "BCLOSETESTBLOCK" + )); + let _ = app.ribbon.hide_keytips(); + let _ = app.update(close); + app.ribbon.show_keytips(false); + assert!(!tab_tip(&app, "block_test")); + assert!(tab_tip(&app, "block_editor")); + assert_eq!(app.ribbon.keytips_json(false)["tab"], "block_editor"); + let _ = app.ribbon.keytip_type(Some("H")); + assert!(!tab_tip(&app, "Close")); + assert!(!tab_tip(&app, "BCLOSETESTBLOCK")); + let _ = app.ribbon.hide_keytips(); +} + +#[test] +fn block_test_close_x_discards_dirty_edits_and_repeated_test_refreshes() { + let (mut app, source, _) = fixture(); + let _ = app.open_block_test(source); + let first = app.tabs[app.active_tab].id; + let _ = app.open_block_test(app.active_tab); + assert!(!app.tabs.iter().any(|tab| tab.id == first)); + assert_eq!( + app.tabs + .iter() + .filter(|tab| tab.block_test.is_some()) + .count(), + 1 + ); + let i = app.active_tab; + app.tabs[i].dirty = true; + let _ = app.on_tab_close(i); + assert_eq!(app.active_tab, source); + assert!(app.pending_close.is_none()); + assert!(app.active_modal.is_none()); +} + +#[test] +fn block_test_refuses_persistence_and_replacing_the_scratch_drawing() { + let (mut app, source, _) = fixture(); + let _ = app.open_block_test(source); + let test = app.active_tab; + for command in [ + "SAVE", + "QSAVE", + "SAVEAS", + "SAVEALL", + "BSAVE", + "BSAVEAS:OTHER", + "BEDIT_OPEN:SAMPLE", + "REFEDIT", + ] { + let request = serde_json::json!({"op":"run", "cmd":command}); + assert_eq!( + app.automation_op(&request.to_string())["ok"], + false, + "{command}" + ); + } + assert_eq!( + app.automation_op(r#"{"op":"save","path":"blocked-test.dwg"}"#)["ok"], + false + ); + assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], false); + let _ = app.update(Message::SaveFile); + let _ = app.update(Message::SaveAs); + assert!(app.active_modal.is_none()); + assert_eq!(app.active_tab, test); + assert!(app.tabs[test].current_path.is_none()); + assert!(app.active_save_jobs.is_empty()); + app.tabs[test].dirty = true; + let _ = app.update(Message::AutoSave); + assert!(app.active_save_jobs.is_empty()); +} + +#[test] +fn block_test_parent_close_cancel_keeps_test_and_discard_removes_it() { + let (mut app, source, _) = fixture(); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 12,20 2"}"#); + let _ = app.open_block_test(source); + let test_id = app.tabs[app.active_tab].id; + let _ = app.on_tab_close(source); + assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges)); + let _ = app.update(Message::BlockEditChangesCancel); + assert!(app.tabs.iter().any(|tab| tab.id == test_id)); + let _ = app.on_tab_close(source); + let _ = app.update(Message::BlockEditChangesDiscard); + assert!(!app.tabs.iter().any(|tab| tab.id == test_id)); + assert!(!app.tabs.iter().any(|tab| tab.block_test.is_some())); +} + +#[test] +fn block_test_no_editor_is_refused() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let count = app.tabs.len(); + let _ = app.open_block_test(app.active_tab); + assert_eq!(app.tabs.len(), count); + assert!(app.command_line.last_error.is_some()); +} + +#[test] +fn block_test_reordering_before_its_source_keeps_close_targets_stable() { + let (mut app, source, _) = fixture(); + let source_id = app.tabs[source].id; + let _ = app.open_block_test(source); + let test = app.active_tab; + app.tabs.swap(source, test); + app.active_tab = test; + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert!(!app.tabs.iter().any(|tab| tab.block_test.is_some())); + assert_eq!(app.tabs[app.active_tab].id, source_id); + assert!(app.tabs[app.active_tab].active_block_edit.is_none()); + assert_eq!(app.tabs.len(), 1); +} + +#[test] +fn block_test_unsaved_visibility_states_work_without_changing_the_editor() { + let (mut app, source, _) = fixture(); + let doc = &mut app.tabs[source].scene.document; + let owner = doc.block_records.get("SAMPLE").unwrap().handle; + let first = doc.block_records.get("SAMPLE").unwrap().entity_handles[0]; + let mut line = Line::from_points(Vector3::new(10.0, 23.0, 0.0), Vector3::new(15.0, 23.0, 0.0)); + line.common.owner_handle = owner; + let second = doc.add_entity(EntityType::Line(line)).unwrap(); + let parameter = crate::scene::block_visibility::create(doc, owner, Vector3::ZERO).unwrap(); + crate::scene::block_visibility::add_state(doc, parameter, "Alternative", 0).unwrap(); + crate::scene::block_visibility::set_visible(doc, parameter, 0, &[second], false); + crate::scene::block_visibility::set_visible(doc, parameter, 1, &[first], false); + let original = app.tabs[source].scene.document.clone(); + let _ = app.open_block_test(source); + let test = app.active_tab; + let doc = &app.tabs[test].scene.document; + let model = doc.header.model_space_block_handle; + let reference = doc + .entities() + .find(|e| e.common().owner_handle == model && matches!(e, EntityType::Insert(_))) + .unwrap() + .common() + .handle; + assert!(doc.dynamic_visibility_for_insert(reference).is_some()); + app.apply_visibility_state(reference, 1); + app.finish_pending_history(test); + let doc = &app.tabs[test].scene.document; + let EntityType::Insert(insert) = doc.get_entity(reference).unwrap() else { + unreachable!() + }; + assert_ne!(insert.block_name, "SAMPLE"); + let visible: Vec<_> = doc + .entities_in_block(&insert.block_name) + .filter(|e| !e.common().invisible) + .collect(); + assert_eq!(visible.len(), 1); + let EntityType::Line(line) = visible[0] else { + panic!("expected the alternative line") + }; + assert_eq!(line.start.y, 23.0); + assert_eq!(app.tabs[source].scene.document, original); + app.visibility_popup = Some(super::visibility::VisibilityPopup { + insert_handle: reference, + anchor: iced::Point::ORIGIN, + items: vec!["Alternative".into()], + current: Some(1), + }); + let _ = app.close_block_test(test); + assert!(app.visibility_popup.is_none()); + assert_eq!(app.tabs[source].scene.document, original); +} + +#[test] +fn block_test_attributes_use_defaults_and_the_stored_base_point() { + use codec::entities::AttributeDefinition; + let (mut app, source, _) = fixture(); + let doc = &mut app.tabs[source].scene.document; + let owner = doc.block_records.get("SAMPLE").unwrap().handle; + let mut attribute = AttributeDefinition::new("TAG".into(), "Value".into(), "default".into()); + attribute.insertion_point = Vector3::new(12.0, 23.0, 0.0); + attribute.common.owner_handle = owner; + let definition = doc + .add_entity(EntityType::AttributeDefinition(attribute)) + .unwrap(); + let _ = app.open_block_test(source); + let doc = &app.tabs[app.active_tab].scene.document; + let model = doc.header.model_space_block_handle; + let insert = doc + .entities() + .find_map(|entity| match entity { + EntityType::Insert(insert) if insert.common.owner_handle == model => Some(insert), + _ => None, + }) + .unwrap(); + assert_eq!(insert.attributes.len(), 1); + let attribute = &insert.attributes[0]; + assert_eq!(attribute.value, "default"); + assert_eq!(attribute.insertion_point, Vector3::new(2.0, 3.0, 0.0)); + assert_eq!(attribute.attdef_handle, definition); + assert!(!attribute.common.handle.is_null()); + assert_eq!(attribute.common.owner_handle, insert.common.handle); +} + +#[test] +fn block_test_close_others_discards_the_test_without_prompting() { + let (mut app, source, _) = fixture(); + let _ = app.open_block_test(source); + app.tabs[app.active_tab].dirty = true; + let test = app.tabs[app.active_tab].id; + let _ = app.update(Message::DocTabCloseOthers(source)); + assert!(!app.tabs.iter().any(|tab| tab.id == test)); + assert!(app.active_modal.is_none()); + assert!(app.tabs[source].active_block_edit.is_some()); +} diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index b9e6d75a9..825763781 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -38,7 +38,7 @@ impl OpenCADStudio { let mut saved = 0usize; let mut skipped = 0usize; for t in 0..self.tabs.len() { - if self.tabs[t].is_start { + if self.tabs[t].is_start || self.tabs[t].block_test.is_some() { continue; } if let Some(path) = self.tabs[t].current_path.clone() { diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index d5fbd9d9f..43b944a28 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -614,6 +614,8 @@ impl OpenCADStudio { "BSAVE" => { self.save_block_edit(i); } + "BTESTBLOCK" => return Some(self.open_block_test(i)), + "BCLOSETESTBLOCK" => return Some(self.close_block_test(i)), "BSAVEAS" => { self.open_block_save_as_dialog(i); } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 1ea774d8c..467319a6d 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -149,6 +149,33 @@ impl OpenCADStudio { }; let resolved = self.resolve_alias(cmd); let cmd = resolved.as_deref().unwrap_or(cmd); + if self.tabs[i].block_test.is_some() { + let name = cmd.split([' ', ':']).next().unwrap_or(cmd); + if matches!( + name, + "BEDIT" + | "BEDIT_BEGIN" + | "BEDIT_OPEN" + | "REFEDIT" + | "REFEDIT_BEGIN" + | "BSAVE" + | "BSAVEAS" + | "SAVE" + | "QSAVE" + | "SAVEAS" + | "SAVEALL" + | "WBLOCK" + | "EXPORT" + | "ETRANSMIT" + | "ARCHIVE" + ) { + self.command_line.push_error( + crate::t!("Close Test Block before saving or opening another block editor.") + .as_ref(), + ); + return Task::none(); + } + } if is_spacemouse_command(cmd) { return self.run_action(cmd); } diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index b87927eca..923f1d775 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -410,6 +410,12 @@ impl OpenCADStudio { /// this needs no undo snapshot. Format follows the path extension. #[cfg(not(target_arch = "wasm32"))] pub(super) fn control_wblock(&mut self, req: &Value) -> Result, Value> { + if self.tabs[self.active_tab].block_test.is_some() { + return Err(failure( + "temporary_document", + "Test Block is temporary and cannot be saved", + )); + } let path = string(req, "path")?; let document = &self.tabs[self.active_tab].scene.document; // A template base keeps its tables/styles in the exported file diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 1ee429364..caaff5e31 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1148,6 +1148,12 @@ impl OpenCADStudio { "view_focus" => self.control_view_focus(req)?, #[cfg(not(target_arch = "wasm32"))] "save" => { + if self.tabs[i].block_test.is_some() { + return Err(failure( + "temporary_document", + "Test Block is temporary and cannot be saved", + )); + } let path = req["path"] .as_str() .map(std::path::PathBuf::from) @@ -1198,6 +1204,12 @@ impl OpenCADStudio { // Web: no filesystem path; the drawing comes back in the reply. #[cfg(target_arch = "wasm32")] "save" => { + if self.tabs[i].block_test.is_some() { + return Err(failure( + "temporary_document", + "Test Block is temporary and cannot be saved", + )); + } use base64::Engine as _; let default_is_dxf = crate::io::source_is_dxf(None, &self.tabs[i].scene.document); let (version, is_dxf) = super::automation::requested_save_target( diff --git a/src/app/control/sheets.rs b/src/app/control/sheets.rs index 42cdb2f2c..aafd39cd8 100644 --- a/src/app/control/sheets.rs +++ b/src/app/control/sheets.rs @@ -20,7 +20,10 @@ impl OpenCADStudio { self.set_control_result(json!({ "closed": false, "reason": "start_page" })); return Ok(Task::none()); } - if self.tabs[index].dirty && !req["discard"].as_bool().unwrap_or(false) { + if self.tabs[index].dirty + && self.tabs[index].block_test.is_none() + && !req["discard"].as_bool().unwrap_or(false) + { return Err(failure( "document_dirty", "Save the document or pass discard:true", diff --git a/src/app/document.rs b/src/app/document.rs index 2fcceaaeb..bc753b254 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -207,6 +207,7 @@ pub(super) struct DocumentTab { pub(super) history: HistoryState, /// Drawing history is suspended while `history` belongs to BEDIT. pub(super) block_edit_history: Option, + pub(super) block_test: Option, pub(super) active_layer: String, /// Block references whose properties panel shows per-axis Scale X/Y/Z even /// though the three factors are currently equal — the user unchecked the @@ -660,6 +661,7 @@ impl DocumentTab { dyn_active: 0, history: HistoryState::default(), block_edit_history: None, + block_test: None, active_layer: "0".to_string(), props_asym_scale: std::collections::HashSet::new(), graph: Default::default(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 076f2a07b..3aeeaa516 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,6 +9,9 @@ pub(crate) mod config; pub use automation::{export_headless, serve}; mod annotation_data; mod block_editor; +mod block_test; +#[cfg(test)] +mod block_test_tests; mod block_save_as; #[cfg(test)] mod block_editor_tests; diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 8c476f78d..7ca474523 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -160,7 +160,7 @@ impl OpenCADStudio { if self.tabs[idx].is_start { continue; } - if self.tabs[idx].active_block_edit.is_some() { + if self.tabs[idx].active_block_edit.is_some() || self.tabs[idx].block_test.is_some() { tasks.push(self.on_tab_close(idx)); if self.pending_block_close.is_some() || self.pending_close.is_some() { break; @@ -178,6 +178,27 @@ impl OpenCADStudio { } pub(in crate::app) fn on_tab_close(&mut self, idx: usize) -> Task { + let test_return_id = self + .tabs + .get(idx) + .and_then(|tab| tab.block_test.as_ref()) + .map(|test| { + if idx == self.active_tab { + test.source_id + } else { + self.tabs[self.active_tab].id + } + }); + if test_return_id.is_some() { + if idx == self.active_tab { + self.cancel_active_grip_edit(); + self.grip_pending = None; + self.visibility_popup = None; + self.grip_popup = None; + self.grip_hover = None; + } + self.tabs[idx].dirty = false; + } // Start tab is fixed — close requests on it are no-ops. if self.tabs.get(idx).map_or(false, |t| t.is_start) { return Task::none(); @@ -249,6 +270,16 @@ impl OpenCADStudio { // The active tab is now either a brand-new blank or a // different existing tab; in both cases the ribbon needs // to track that doc's defaults / selection. #21. + if let Some(index) = + test_return_id.and_then(|id| self.tabs.iter().position(|tab| tab.id == id)) + { + self.active_tab = index; + self.adopt_header_sysvars(index); + } + self.ribbon + .set_block_testing(self.tabs[self.active_tab].block_test.is_some()); + self.ribbon + .set_block_editing(self.tabs[self.active_tab].active_block_edit.is_some()); self.sync_ribbon_layers(); self.sync_ribbon_styles(); self.sync_ribbon_from_selection(); diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 1a33b1db2..7dea3f90b 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -22,6 +22,9 @@ use iced::{mouse, Point, Task}; impl OpenCADStudio { pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task { + if self.refuse_block_test_save(tab_idx) { + return Task::none(); + } // Every entry here is Save-As semantics (the SAVEAS command, the // recovery save-as, the native failure redirects): the user is // choosing a destination, so on the web the FS Access picker must diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 94f571b71..5405968bc 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1759,6 +1759,11 @@ impl OpenCADStudio { version: codec::DxfVersion, set_current_path: bool, ) -> Result<(), crate::io::SaveFailure> { + if self.tabs[i].block_test.is_some() { + return Err(crate::io::SaveFailure::other( + "Test Block is temporary and cannot be saved", + )); + } let previous_autosave = self.autosave_target(i); if self.tabs[i].recovery_save_as_required && self.tabs[i] @@ -2423,7 +2428,7 @@ impl OpenCADStudio { let mut skipped = 0usize; let mut pending: Vec<(crate::app::WebSaveallEntry, codec::CadDocument)> = Vec::new(); for t in 0..self.tabs.len() { - if self.tabs[t].is_start { + if self.tabs[t].is_start || self.tabs[t].block_test.is_some() { continue; } let Some(path) = self.tabs[t].current_path.clone() else { @@ -3083,6 +3088,9 @@ impl OpenCADStudio { set_current_path: bool, check_external_change: bool, ) -> Task { + if self.refuse_block_test_save(i) { + return Task::none(); + } let tab_id = self.tabs[i].id; if self .pending_native_thumbnail_save @@ -3751,6 +3759,9 @@ impl OpenCADStudio { } pub(super) fn on_save_file(&mut self) -> Task { + if self.refuse_block_test_save(self.active_tab) { + return Task::none(); + } if self.read_only { self.command_line.push_error( crate::t!("Read-only session (--read-only): saving is disabled.").as_ref(), @@ -3807,6 +3818,9 @@ impl OpenCADStudio { /// Used by plain Save (QSAVE) on an as-yet-unsaved drawing and by the /// save-before-close flow — the version picker is reserved for Save As. pub(in crate::app) fn save_with_default_format(&mut self, tab_idx: usize) -> Task { + if self.refuse_block_test_save(tab_idx) { + return Task::none(); + } self.active_tab = tab_idx; // Every caller of this entry (Save/QSAVE, the save-before-close // flow) is plain-save semantics: on the web, the tab's retained @@ -4199,7 +4213,10 @@ impl OpenCADStudio { pub(super) fn on_autosave(&mut self) -> Task { let mut tasks = Vec::new(); for i in 0..self.tabs.len() { - if !self.tabs[i].dirty || self.active_save_jobs.contains_key(&self.tabs[i].id) { + if self.tabs[i].block_test.is_some() + || !self.tabs[i].dirty + || self.active_save_jobs.contains_key(&self.tabs[i].id) + { continue; } self.prepare_snapshot_save(i); @@ -4234,7 +4251,7 @@ impl OpenCADStudio { } let mut pending = Vec::new(); for i in 0..self.tabs.len() { - if !self.tabs[i].dirty { + if self.tabs[i].block_test.is_some() || !self.tabs[i].dirty { continue; } // Same pre-serialize state sync the web Save flow performs @@ -4561,7 +4578,7 @@ impl OpenCADStudio { let mut names: Vec = Vec::new(); let mut active = 0; for (index, tab) in self.tabs.iter().enumerate() { - if tab.is_start { + if tab.is_start || tab.block_test.is_some() { continue; } if index == self.active_tab { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 14443aa96..1817eb818 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -346,7 +346,17 @@ impl OpenCADStudio { .tabs .get(self.active_tab) .is_some_and(|tab| tab.active_block_edit.is_some()); - self.ribbon.set_block_editing(block_editing); + let testing = self + .tabs + .get(self.active_tab) + .is_some_and(|tab| tab.block_test.is_some()); + if testing { + self.ribbon.set_block_editing(false); + self.ribbon.set_block_testing(true); + } else { + self.ribbon.set_block_testing(false); + self.ribbon.set_block_editing(block_editing); + } task } @@ -595,7 +605,11 @@ impl OpenCADStudio { } } #[cfg(target_arch = "wasm32")] - crate::sys::set_unsaved_changes_warning(self.tabs.iter().any(|tab| tab.dirty)); + crate::sys::set_unsaved_changes_warning( + self.tabs + .iter() + .any(|tab| tab.dirty && tab.block_test.is_none()), + ); if self.tabs[i].active_cmd.is_none() { self.block_palette.placing = None; } diff --git a/src/modules/block_editor/mod.rs b/src/modules/block_editor/mod.rs index 26f0f4379..09b61c71e 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -63,6 +63,11 @@ impl CadModule for BlockEditorModule { "Close Block Editor", include_bytes!("../../../assets/icons/mt_cancel.svg"), ), + command( + "BTESTBLOCK", + "Test Block", + include_bytes!("../../../assets/icons/edit_block.svg"), + ), ], }, RibbonGroup { diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index 4acb13587..7fb2cc5a7 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -15,7 +15,7 @@ mod report; pub mod select; pub mod units; -use crate::modules::{CadModule, RibbonGroup, RibbonItem}; +use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; pub struct DrawModule; @@ -249,6 +249,15 @@ impl CadModule for DrawModule { default: "DIST", }], }, + RibbonGroup { + title: "Close", + tools: vec![RibbonItem::LargeTool(ToolDef { + id: "BCLOSETESTBLOCK", + label: "Close Test Block", + icon: IconKind::Svg(include_bytes!("../../../assets/icons/mt_ok.svg")), + event: ModuleEvent::Command("BCLOSETESTBLOCK".into()), + })], + }, // Support group lives on the Start tab now (see view.rs: // start_page_view). Removed from the Draw ribbon to declutter. ] diff --git a/src/modules/draw/modify/block_edit.rs b/src/modules/draw/modify/block_edit.rs index 3d7c0a0d1..0e1720262 100644 --- a/src/modules/draw/modify/block_edit.rs +++ b/src/modules/draw/modify/block_edit.rs @@ -119,3 +119,6 @@ inventory::submit!(crate::command::CommandRegistration { inventory::submit!(crate::command::CommandRegistration { names: &["BSAVE", "BSAVEAS", "BCLOSE"] }); +inventory::submit!(crate::command::CommandRegistration { + names: &["BTESTBLOCK", "BCLOSETESTBLOCK"] +}); diff --git a/src/ui/ribbon/keytips.rs b/src/ui/ribbon/keytips.rs index 906646c18..a7a626eaf 100644 --- a/src/ui/ribbon/keytips.rs +++ b/src/ui/ribbon/keytips.rs @@ -434,6 +434,9 @@ const CODES: &[(&str, &str, &str)] = &[ ("block_editor", "Open and Save", "BO"), ("block_editor", "BEDIT", "BE"), ("block_editor", "BSAVE", "BS"), + ("block_editor", "BTESTBLOCK", "TB"), + ("draw", "Close", "TC"), + ("draw", "BCLOSETESTBLOCK", "CT"), ("block_editor", "ATTDEF", "AD"), ("block_editor", "AUTOCONSTRAIN", "AC"), ("block_editor", "CCONSTRAINT", "CI"), @@ -631,7 +634,7 @@ impl Ribbon { }; let scope = module.id(); let mut tips = Vec::new(); - for group in module.ribbon_groups() { + for group in self.groups_for(index) { for item in &group.tools { match item { RibbonItem::Tool(tool) @@ -709,11 +712,16 @@ impl Ribbon { fn item_tips(&self, index: usize, open: &str) -> Vec { let mut items = draw_panel::keytip_items(open); if items.is_empty() { - let groups = self.modules.get(index).map(|m| m.ribbon_groups()).unwrap_or(&[]); - for item in groups.iter().flat_map(|group| &group.tools) { - if let RibbonItem::Dropdown { id, items: list, .. } - | RibbonItem::LabeledDropdown { id, items: list, .. } - | RibbonItem::LargeDropdown { id, items: list, .. } = item + for item in self.groups_for(index).flat_map(|group| &group.tools) { + if let RibbonItem::Dropdown { + id, items: list, .. + } + | RibbonItem::LabeledDropdown { + id, items: list, .. + } + | RibbonItem::LargeDropdown { + id, items: list, .. + } = item { if *id == open { items = list diff --git a/src/ui/ribbon/locate.rs b/src/ui/ribbon/locate.rs index e4dd32c35..18c3c3d9a 100644 --- a/src/ui/ribbon/locate.rs +++ b/src/ui/ribbon/locate.rs @@ -68,15 +68,11 @@ impl Ribbon { fn entries(&self) -> impl Iterator)> { let order = std::iter::once(self.active) .chain((0..self.modules.len()).filter(move |i| *i != self.active)); - order - .filter_map(|i| self.modules.get(i).map(|m| (i, m))) - .flat_map(|(i, module)| { - module - .ribbon_groups() - .iter() - .flat_map(group_entries) - .map(move |entry| (i, entry)) - }) + order.flat_map(|i| { + self.groups_for(i) + .flat_map(group_entries) + .map(move |entry| (i, entry)) + }) } /// The button id that runs the command line `cmd`: an exact match first, @@ -111,12 +107,7 @@ impl Ribbon { /// module has no tab (its tools are on the side toolbar). pub(super) fn tab_holding_active_tool(&self) -> Option { let id = self.active_tool.as_deref()?; - let holds = |i: usize| { - self.modules[i] - .ribbon_groups() - .iter() - .any(|group| group_holds(group, id)) - }; + let holds = |i: usize| self.groups_for(i).any(|group| group_holds(group, id)); if self .modules .get(self.active) diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 86680973b..bae9d8b1a 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -126,6 +126,7 @@ pub struct Ribbon { /// A block editor session is open: the tab that was active before the /// Block Editor tab took over. block_editing: Option, + block_testing: Option, /// XDWGFADECTL as the Reference slide-out shows it (negative = off). pub xref_fade: i32, /// Open key tips (Alt). @@ -231,6 +232,7 @@ impl Ribbon { xref_ctx: false, point_cloud_ctx: None, block_editing: None, + block_testing: None, xref_fade: 50, keytips: None, } @@ -328,6 +330,40 @@ impl Ribbon { } } + pub fn set_block_testing(&mut self, testing: bool) { + if testing != self.block_testing.is_some() { + self.close_dropdown(); + let _ = self.hide_keytips(); + } + let Some(index) = self.modules.iter().position(|module| module.id() == "draw") else { + return; + }; + match (testing, self.block_testing) { + (true, None) => { + self.block_testing = Some(self.active); + self.active = index; + } + (false, Some(previous)) => { + self.block_testing = None; + if self.active == index { + self.active = previous.min(self.modules.len().saturating_sub(1)); + } + } + _ => {} + } + } + + /// Contextual panels share their normal tab with the drawing tools. + fn groups_for(&self, index: usize) -> impl Iterator { + let testing = self.block_testing.is_some(); + self.modules.get(index).into_iter().flat_map(move |module| { + module + .ribbon_groups() + .iter() + .filter(move |group| module.id() != "draw" || group.title != "Close" || testing) + }) + } + pub fn select(&mut self, index: usize) { if index < self.modules.len() { self.active = index; @@ -733,38 +769,36 @@ impl Ribbon { } else { self.active }; - let tool_area: Element<'_, Message> = - if let Some(module) = self.modules.get(effective_active) { - let groups = module.ribbon_groups(); - let style_ctx = StyleContext { - text_style_names: &self.text_style_names, - active_text_style: &self.active_text_style, - dim_style_names: &self.dim_style_names, - active_dim_style: &self.active_dim_style, - mleader_style_names: &self.mleader_style_names, - active_mleader_style: &self.active_mleader_style, - table_style_names: &self.table_style_names, - active_table_style: &self.active_table_style, - }; - - // Adaptive tool area: panels sit on one row; when they don't all - // fit they degrade from the right — a panel's large buttons first - // shrink to compact icon columns, then it collapses to a ▾ flyout - // button. See `CollapsePanels`. - let ts = self.toggle_state(show_block_palette); - let panels: Vec> = groups - .iter() - .map(|g| self.panel(g, ts, &style_ctx, &|_| Vec::new())) - .collect(); - CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H) - .report_height(self.tool_bar_h.clone()) - .report_tight(self.collapse_tight.clone()) - .mode(self.collapse_mode) - .into() - } else { - text("").into() + let tool_area: Element<'_, Message> = if self.modules.get(effective_active).is_some() { + let groups = self.groups_for(effective_active); + let style_ctx = StyleContext { + text_style_names: &self.text_style_names, + active_text_style: &self.active_text_style, + dim_style_names: &self.dim_style_names, + active_dim_style: &self.active_dim_style, + mleader_style_names: &self.mleader_style_names, + active_mleader_style: &self.active_mleader_style, + table_style_names: &self.table_style_names, + active_table_style: &self.active_table_style, }; + // Adaptive tool area: panels sit on one row; when they don't all + // fit they degrade from the right — a panel's large buttons first + // shrink to compact icon columns, then it collapses to a ▾ flyout + // button. See `CollapsePanels`. + let ts = self.toggle_state(show_block_palette); + let panels: Vec> = groups + .map(|g| self.panel(g, ts, &style_ctx, &|_| Vec::new())) + .collect(); + CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H) + .report_height(self.tool_bar_h.clone()) + .report_tight(self.collapse_tight.clone()) + .mode(self.collapse_mode) + .into() + } else { + text("").into() + }; + let tool_bar: Element<'_, Message> = container(tool_area) .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.weakest.color)), @@ -931,8 +965,7 @@ impl Ribbon { return Some(ov); } - let module = self.modules.get(self.active)?; - let groups = module.ribbon_groups(); + let groups = self.groups_for(self.active); let mut items_list: Option> = None; let mut dd_default = ""; let mut dd_id: &'static str = ""; @@ -1161,7 +1194,7 @@ impl Ribbon { /// Draw-tab dropdowns — so the list grows to fit its entries instead of /// being clipped to the ribbon-row height. (#153) fn style_combo_overlay(&self, open_id: &str, win: (f32, f32)) -> Option> { - let groups = self.modules.get(self.active)?.ribbon_groups(); + let groups = self.groups_for(self.active); // Locate the open style combo; capture its style key + manager command. let mut found: Option<(crate::modules::StyleKey, Option<&'static str>)> = None; @@ -1677,6 +1710,41 @@ mod tests { use super::widgets::is_active_tool; use super::Ribbon; + #[test] + fn block_test_close_panel_extends_draw_only_while_testing() { + let mut ribbon = Ribbon::default(); + let draw = ribbon + .modules + .iter() + .position(|module| module.id() == "draw") + .unwrap(); + assert!(!ribbon + .modules + .iter() + .any(|module| module.id() == "block_test")); + let ordinary: Vec<_> = ribbon.groups_for(draw).map(|group| group.title).collect(); + assert!(!ordinary.contains(&"Close")); + ribbon.set_block_editing(true); + ribbon.set_block_editing(false); + ribbon.set_block_testing(true); + assert_eq!(ribbon.active, draw); + let testing: Vec<_> = ribbon.groups_for(draw).map(|group| group.title).collect(); + assert_eq!(&testing[..ordinary.len()], ordinary.as_slice()); + assert_eq!(testing.last(), Some(&"Close")); + ribbon.toggle_collapsed_panel("Close"); + ribbon.set_block_testing(false); + assert!(ribbon.collapsed_open.is_none()); + assert_eq!( + ribbon + .groups_for(draw) + .map(|group| group.title) + .collect::>(), + ordinary + ); + ribbon.set_block_editing(true); + assert_eq!(ribbon.modules[ribbon.active].id(), "block_editor"); + } + #[test] fn block_palette_highlight_follows_the_app_state_passed_to_the_view() { // The Block Palette button highlight is derived from the app's