diff --git a/src/app/automation.rs b/src/app/automation.rs index 45d8bac5c..dfd666579 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_edit_history.is_some() { + return err("Close the block editor before replacing the drawing."); + } if let Some(template) = req["template"].as_str() { let purged = match self.apply_template(template) { Ok(purged) => purged, @@ -723,6 +726,9 @@ impl OpenCADStudio { } #[cfg(not(target_arch = "wasm32"))] "open" => { + if self.tabs[self.active_tab].block_edit_history.is_some() { + return err("Close the block editor before replacing the drawing."); + } let Some(path) = req["path"].as_str() else { return err("open: missing \"path\""); }; diff --git a/src/app/block_editor.rs b/src/app/block_editor.rs new file mode 100644 index 000000000..e797ed515 --- /dev/null +++ b/src/app/block_editor.rs @@ -0,0 +1,393 @@ +use super::document::{ + DeltaSnapshot, HistorySnapshot, HistoryState, ParametricConstraintsEntryDelta, + StructureSnapshot, +}; +use super::{Message, ModalKind, OpenCADStudio}; +use codec::{CadDocument, EntityType}; +use iced::Task; +use rustc_hash::{FxHashMap, FxHashSet}; + +/// A single save checkpoint. Entity payloads remain shared through the +/// document's Arc storage; only modified entities detach during editing. +pub(super) struct BlockEditCheckpoint { + document: CadDocument, + constraints: Vec, + parameters: crate::scene::named_parameters::ParameterTable, + dirty: bool, + active_layer: String, +} + +pub(super) struct BlockEditHistory { + pub main: HistoryState, + checkpoint: BlockEditCheckpoint, + return_isolation: crate::scene::ObjectIsolationState, +} + +#[derive(Clone, Debug)] +pub(super) enum BlockEditContinuation { + Close, + Switch(String), + Layout(String), + Tab(u64), + Quit, +} + +fn same_constraints( + before: &crate::scene::parametric_constraints::ParametricConstraintSet, + after: &crate::scene::parametric_constraints::ParametricConstraintSet, +) -> bool { + before.constraints == after.constraints + && before.local_parameters == after.local_parameters + && before.retained_standard_groups == after.retained_standard_groups + && before.dimensions == after.dimensions +} + +fn normalize_allocation(document: &mut CadDocument, next: u64) { + if document.next_handle() < next { + document.header.handle_seed = next - 1; + document.allocate_handle(); + } + document.header.handle_seed = next; +} + +impl OpenCADStudio { + pub(super) fn reconcile_block_edit_membership(&mut self, i: usize) { + let Some(session) = self.tabs[i].active_block_edit_session() else { + return; + }; + let name = session.block_name.clone(); + let document = &mut self.tabs[i].scene.document; + let Some(record) = document.block_records.get(&name) else { + return; + }; + let members: Vec<_> = record + .entity_handles + .iter() + .copied() + .filter(|handle| document.get_entity(*handle).is_some()) + .collect(); + if members != record.entity_handles { + document + .block_records + .get_mut(&name) + .unwrap() + .entity_handles = members; + } + } + + fn block_edit_checkpoint(&self, i: usize) -> BlockEditCheckpoint { + let tab = &self.tabs[i]; + BlockEditCheckpoint { + document: tab.scene.document.clone(), + constraints: tab.scene.parametric_constraints.clone(), + parameters: tab.scene.named_parameters.clone(), + dirty: tab.dirty, + active_layer: tab.active_layer.clone(), + } + } + + pub(super) fn begin_block_edit_history(&mut self, i: usize) { + self.finish_pending_history(i); + let checkpoint = self.block_edit_checkpoint(i); + let main = std::mem::take(&mut self.tabs[i].history); + let return_isolation = self.tabs[i].scene.object_isolation.clone(); + self.tabs[i].block_edit_history = Some(BlockEditHistory { + main, + checkpoint, + return_isolation, + }); + } + + fn block_edit_delta(&mut self, i: usize) -> Option { + self.reconcile_block_edit_membership(i); + self.finish_pending_history(i); + let current_structure = self.tabs[i].scene.document.snapshot_structure(); + let tab = &self.tabs[i]; + let history = tab.block_edit_history.as_ref()?; + let checkpoint = &history.checkpoint; + let session = tab.active_block_edit_session()?; + let before: FxHashMap<_, _> = checkpoint + .document + .entities() + .map(|e| { + let h = e.common().handle; + (h, checkpoint.document.get_entity_arc(h).unwrap()) + }) + .collect(); + let after: FxHashMap<_, _> = tab + .scene + .document + .entities() + .map(|e| { + let h = e.common().handle; + (h, tab.scene.document.get_entity_arc(h).unwrap()) + }) + .collect(); + let mut handles: FxHashSet<_> = before.keys().copied().collect(); + handles.extend(after.keys().copied()); + let mut entities: Vec<_> = handles + .into_iter() + .filter_map(|h| { + let a = before.get(&h); + let b = after.get(&h); + (a != b).then(|| (h, a.cloned(), b.cloned())) + }) + .collect(); + entities.sort_by_key(|(handle, _, _)| handle.value()); + let mut before_structure = checkpoint.document.clone(); + before_structure = before_structure.snapshot_structure(); + let mut after_structure = current_structure; + // The editor temporarily uses Model rendering. Drawing history must + // retain the layout to which the editor returns. + let model = session.return_layout == "Model"; + before_structure.header.show_model_space = model; + after_structure.header.show_model_space = model; + if !model { + crate::io::set_saved_active_layout(&mut before_structure, &session.return_layout); + crate::io::set_saved_active_layout(&mut after_structure, &session.return_layout); + } + let next = before_structure + .next_handle() + .max(after_structure.next_handle()) + .max(before_structure.header.handle_seed) + .max(after_structure.header.handle_seed); + // Reserving handles is not a content edit. Undoing every edit should + // return a session to clean without reusing allocated handles. + normalize_allocation(&mut before_structure, next); + normalize_allocation(&mut after_structure, next); + let structure = (before_structure != after_structure) + .then_some(StructureSnapshot::Full(before_structure)); + let mut scopes: Vec<_> = checkpoint.constraints.iter().map(|c| c.scope).collect(); + for set in &tab.scene.parametric_constraints { + if !scopes.contains(&set.scope) { + scopes.push(set.scope); + } + } + let parametric_constraints: Vec<_> = scopes + .into_iter() + .filter_map(|scope| { + let empty = + || crate::scene::parametric_constraints::ParametricConstraintSet::new(scope); + let a = checkpoint + .constraints + .iter() + .find(|c| c.scope == scope) + .cloned() + .unwrap_or_else(empty); + let b = tab + .scene + .parametric_constraints + .iter() + .find(|c| c.scope == scope) + .cloned() + .unwrap_or_else(empty); + (!same_constraints(&a, &b)).then_some(ParametricConstraintsEntryDelta { + scope, + before: a, + after: b, + }) + }) + .collect(); + let named_parameters = (checkpoint.parameters != tab.scene.named_parameters).then(|| { + ( + checkpoint.parameters.clone(), + tab.scene.named_parameters.clone(), + ) + }); + let active_layer = (checkpoint.active_layer != tab.active_layer) + .then(|| (checkpoint.active_layer.clone(), tab.active_layer.clone())); + if entities.is_empty() + && structure.is_none() + && parametric_constraints.is_empty() + && named_parameters.is_none() + && active_layer.is_none() + { + return None; + } + Some(DeltaSnapshot { + entities, + current_layout_before: session.return_layout.clone(), + current_layout_after: session.return_layout.clone(), + selected_before: Vec::new(), + selected_after: Vec::new(), + dirty_before: checkpoint.dirty, + dirty_after: true, + active_layer, + structure, + parametric_constraints, + named_parameters, + label: format!("Edit block {}", session.block_name), + }) + } + + pub(super) fn block_edit_dirty(&mut self, i: usize) -> bool { + self.block_edit_delta(i).is_some() + } + + 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 + .push_error(crate::t!("No block editor is open.").as_ref()); + return false; + }; + let name = session.block_name.clone(); + if let Err(error) = crate::scene::block_service::validate_block_name(&name) { + // Existing anonymous definitions are editable; name validation + // applies to named definitions rather than generated variants. + if !name.starts_with('*') { + self.command_line.push_error(&error.to_string()); + return false; + } + } + let nested: Vec<_> = self.tabs[i] + .scene + .document + .entities_in_block(&name) + .filter_map(|entity| match entity { + EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }) + .collect(); + for child in nested { + if crate::scene::block_service::would_create_cycle( + &self.tabs[i].scene.document, + &name, + &child, + ) { + self.command_line.push_error( + &crate::scene::block_service::circular_dependency_error(&name, &child), + ); + return false; + } + } + if let Some(delta) = self.block_edit_delta(i) { + 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].dirty = true; + history.checkpoint = self.block_edit_checkpoint(i); + self.tabs[i].block_edit_history = Some(history); + self.tabs[i].scene.bump_geometry(); + } + self.command_line + .push_output(crate::tf!("Block {} saved.", name).as_ref()); + true + } + + pub(super) fn close_block_edit(&mut self, i: usize, discard: bool) { + self.finish_pending_history(i); + let Some(session) = self.tabs[i].block_edits.pop() else { + return; + }; + if let Some(history) = self.tabs[i].block_edit_history.take() { + if discard { + self.tabs[i].scene.document = history.checkpoint.document; + self.tabs[i].scene.parametric_constraints = history.checkpoint.constraints; + self.tabs[i].scene.named_parameters = history.checkpoint.parameters; + self.tabs[i].active_layer = history.checkpoint.active_layer; + self.tabs[i].dirty = history.checkpoint.dirty; + } + self.tabs[i].history = history.main; + self.tabs[i].scene.object_isolation = history.return_isolation; + } + self.tabs[i].active_block_edit = None; + self.restore_after_block_edit_close( + i, + None, + &session.return_layout, + &session.return_camera, + ); + self.cancel_attr_editor(); + } + + pub(super) fn request_block_edit_close( + &mut self, + i: usize, + continuation: BlockEditContinuation, + ) -> Task { + if self.tabs[i].active_block_edit_session().is_some() { + if self.block_edit_dirty(i) { + self.active_tab = i; + self.pending_block_close = Some((self.tabs[i].id, continuation)); + self.active_modal = Some(ModalKind::BlockEditChanges); + return Task::none(); + } + self.close_block_edit(i, true); + } + self.continue_after_block_edit(continuation, i) + } + + fn continue_after_block_edit( + &mut self, + continuation: BlockEditContinuation, + i: usize, + ) -> Task { + match continuation { + BlockEditContinuation::Close => Task::none(), + BlockEditContinuation::Switch(name) => { + self.active_tab = i; + self.dispatch_command(&format!("BEDIT_OPEN:{name}")) + } + BlockEditContinuation::Layout(name) => { + self.active_tab = i; + self.on_layout_switch(name) + } + BlockEditContinuation::Tab(id) => self + .tabs + .iter() + .position(|tab| tab.id == id) + .map(|index| { + let close = self.on_tab_close(index); + if self.pending_close.is_none() && self.pending_block_close.is_none() { + Task::batch([close, self.continue_tab_close_queue()]) + } else { + close + } + }) + .unwrap_or_else(Task::none), + BlockEditContinuation::Quit => self.request_quit_with_block_edits(), + } + } + + pub(super) fn resolve_block_edit_close(&mut self, save: bool) -> Task { + let Some((id, continuation)) = self.pending_block_close.clone() else { + return Task::none(); + }; + let Some(i) = self.tabs.iter().position(|tab| tab.id == id) else { + self.cancel_block_edit_close(); + return Task::none(); + }; + if save && !self.save_block_edit(i) { + return Task::none(); + } + self.pending_block_close = None; + self.active_modal = None; + self.close_block_edit(i, !save); + self.continue_after_block_edit(continuation, i) + } + + pub(super) fn cancel_block_edit_close(&mut self) { + self.pending_block_close = None; + self.pending_tab_closes.clear(); + if self.active_modal == Some(ModalKind::BlockEditChanges) { + self.active_modal = None; + } + } + + pub(super) fn request_quit_with_block_edits(&mut self) -> Task { + if let Some(i) = self + .tabs + .iter() + .position(|tab| tab.active_block_edit_session().is_some()) + { + return self.request_block_edit_close(i, BlockEditContinuation::Quit); + } + if self.tabs.iter().any(|tab| tab.dirty) { + self.pending_close = Some(super::PendingClose::Quit); + self.open_unsaved_dialog_window() + } else { + self.exit_app() + } + } +} diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs new file mode 100644 index 000000000..2c07c4172 --- /dev/null +++ b/src/app/block_editor_tests.rs @@ -0,0 +1,367 @@ +use super::{Message, ModalKind, OpenCADStudio}; +use codec::entities::{Insert, Line}; +use codec::types::Vector3; +use codec::{EntityType, Handle}; + +fn fixture() -> (OpenCADStudio, 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( + "EDIT_ME", + Vector3::ZERO, + vec![EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::UNIT_X, + ))], + ); + let reference = app.tabs[i] + .scene + .document + .add_entity(EntityType::Insert(Insert::new("EDIT_ME", Vector3::ZERO))) + .unwrap(); + app.tabs[i].dirty = false; + (app, reference) +} + +fn begin(app: &mut OpenCADStudio, reference: Handle) { + let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", reference.value())); + assert!(app.tabs[app.active_tab].active_block_edit.is_some()); +} + +fn circle(app: &mut OpenCADStudio, x: f64) { + let result = app.automation_op(&format!(r#"{{"op":"run","cmd":"CIRCLE {x},0 2"}}"#)); + assert_eq!(result["ok"], true, "{result}"); + app.finish_pending_history(app.active_tab); +} + +fn members(app: &OpenCADStudio) -> Vec { + app.tabs[app.active_tab] + .scene + .document + .block_records + .get("EDIT_ME") + .unwrap() + .entity_handles + .clone() +} + +fn main_depth(app: &OpenCADStudio) -> usize { + let tab = &app.tabs[app.active_tab]; + tab.block_edit_history + .as_ref() + .map(|h| h.main.undo_stack.len()) + .unwrap_or(tab.history.undo_stack.len()) +} + +#[test] +fn session_undo_preserves_membership_and_cannot_reach_drawing_history() { + let (mut app, reference) = fixture(); + circle(&mut app, 50.0); + let depth = main_depth(&app); + begin(&mut app, reference); + assert_eq!(main_depth(&app), depth); + circle(&mut app, 1.0); + assert_eq!(members(&app).len(), 2); + app.undo_steps(1); + assert_eq!(members(&app).len(), 1); + assert!(!app.block_edit_dirty(app.active_tab)); + app.undo_steps(100); + assert_eq!(main_depth(&app), depth); + assert!(app.tabs[app.active_tab].active_block_edit.is_some()); + app.redo_steps(1); + assert_eq!(members(&app).len(), 2); + assert!(members(&app).iter().all(|h| app.tabs[app.active_tab] + .scene + .document + .get_entity(*h) + .is_some())); +} + +#[test] +fn repeated_saves_stay_open_and_commit_one_drawing_step_each() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + circle(&mut app, 1.0); + circle(&mut app, 2.0); + let _ = app.dispatch_command("BSAVE"); + assert!(app.tabs[app.active_tab].active_block_edit.is_some()); + assert_eq!(main_depth(&app), 1); + assert!(!app.block_edit_dirty(app.active_tab)); + let _ = app.dispatch_command("BSAVE"); + assert_eq!(main_depth(&app), 1, "a clean save is not a new edit"); + circle(&mut app, 3.0); + let _ = app.dispatch_command("BSAVE"); + assert_eq!(main_depth(&app), 2); + circle(&mut app, 4.0); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert_eq!(members(&app).len(), 4, "discard restores the last save"); + assert_eq!(main_depth(&app), 2); + assert_eq!( + app.tabs[app.active_tab] + .history + .undo_stack + .last() + .unwrap() + .label(), + "Edit block EDIT_ME" + ); + app.undo_steps(1); + assert_eq!(members(&app).len(), 3); + app.undo_steps(1); + assert_eq!(members(&app).len(), 1); + app.redo_steps(2); + assert_eq!(members(&app).len(), 4); +} + +#[test] +fn discard_preserves_drawing_redo_and_never_leaks_local_edits() { + let (mut app, reference) = fixture(); + circle(&mut app, 50.0); + app.undo_steps(1); + let main_redo = app.tabs[app.active_tab].history.redo_stack.len(); + begin(&mut app, reference); + circle(&mut app, 1.0); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert_eq!(members(&app).len(), 1); + assert_eq!(app.tabs[app.active_tab].history.redo_stack.len(), main_redo); + app.redo_steps(1); + assert_eq!(members(&app).len(), 1); +} + +#[test] +fn dirty_close_cancel_discard_and_save_follow_the_checkpoint() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + circle(&mut app, 1.0); + let _ = app.dispatch_command("BCLOSE"); + assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges)); + let _ = app.update(Message::BlockEditChangesCancel); + assert!(app.tabs[app.active_tab].active_block_edit.is_some()); + assert_eq!(members(&app).len(), 2); + let _ = app.dispatch_command("BCLOSE"); + let _ = app.update(Message::BlockEditChangesDiscard); + assert!(app.tabs[app.active_tab].active_block_edit.is_none()); + assert_eq!(members(&app).len(), 1); + assert_eq!(main_depth(&app), 0); + begin(&mut app, reference); + circle(&mut app, 2.0); + let _ = app.dispatch_command("BCLOSE"); + let _ = app.update(Message::BlockEditChangesSave); + assert!(app.tabs[app.active_tab].active_block_edit.is_none()); + assert_eq!(members(&app).len(), 2); + assert_eq!(main_depth(&app), 1); +} + +#[test] +fn clean_close_does_not_prompt_or_dirty_the_drawing() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + assert!(!app.tabs[app.active_tab].dirty); + let _ = app.dispatch_command("BCLOSE"); + assert!(app.tabs[app.active_tab].active_block_edit.is_none()); + assert!(app.active_modal.is_none()); + assert!(!app.tabs[app.active_tab].dirty); + assert_eq!(main_depth(&app), 0); +} + +#[test] +fn dirty_switch_can_cancel_and_never_nests_sessions() { + let (mut app, reference) = fixture(); + let i = app.active_tab; + app.tabs[i] + .scene + .define_block_raw("OTHER", Vector3::ZERO, vec![]); + let other = app.tabs[i] + .scene + .document + .add_entity(EntityType::Insert(Insert::new("OTHER", Vector3::ZERO))) + .unwrap(); + begin(&mut app, reference); + circle(&mut app, 1.0); + let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", other.value())); + assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges)); + let _ = app.update(Message::BlockEditChangesCancel); + assert_eq!( + app.tabs[i].active_block_edit_session().unwrap().block_name, + "EDIT_ME" + ); + let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", other.value())); + let _ = app.update(Message::BlockEditChangesSave); + assert_eq!(app.tabs[i].block_edits.len(), 1); + assert_eq!( + app.tabs[i].active_block_edit_session().unwrap().block_name, + "OTHER" + ); + assert_eq!(main_depth(&app), 1); +} + +#[test] +fn tab_close_prompts_for_block_edits_before_drawing_changes() { + let (mut app, reference) = fixture(); + let id = app.tabs[app.active_tab].id; + begin(&mut app, reference); + circle(&mut app, 1.0); + let _ = app.update(Message::TabClose(id)); + assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges)); + let _ = app.update(Message::BlockEditChangesCancel); + assert!(app.tabs.iter().any(|t| t.id == id)); + let _ = app.update(Message::TabClose(id)); + let _ = app.update(Message::BlockEditChangesDiscard); + assert!(!app.tabs.iter().any(|t| t.id == id)); +} + +#[test] +fn save_refuses_a_cycle_introduced_outside_the_insert_command() { + let (mut app, reference) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.define_block_raw( + "OTHER", + Vector3::ZERO, + vec![EntityType::Insert(Insert::new("EDIT_ME", Vector3::ZERO))], + ); + begin(&mut app, reference); + app.tabs[i] + .scene + .add_entity(EntityType::Insert(Insert::new("OTHER", Vector3::ZERO))); + assert!(!app.save_block_edit(i)); + assert_eq!(main_depth(&app), 0); + assert!(app.tabs[i].active_block_edit.is_some()); +} + +#[test] +fn drawing_history_retains_the_paper_layout_after_save_and_undo() { + let (mut app, reference) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.add_layout("Sheet").unwrap(); + app.tabs[i].scene.set_current_layout("Sheet".into()); + begin(&mut app, reference); + circle(&mut app, 1.0); + let _ = app.dispatch_command("BEDIT_SAVE"); + assert_eq!(app.tabs[i].scene.current_layout, "Sheet"); + assert!(!app.tabs[i].scene.document.header.show_model_space); + app.undo_steps(1); + assert_eq!(app.tabs[i].scene.current_layout, "Sheet"); + assert!(!app.tabs[i].scene.document.header.show_model_space); + assert_eq!( + crate::io::saved_active_layout(&app.tabs[i].scene.document).as_deref(), + Some("Sheet") + ); + assert_eq!(members(&app).len(), 1); + app.redo_steps(1); + assert!(!app.tabs[i].scene.document.header.show_model_space); + assert_eq!(members(&app).len(), 2); +} + +#[test] +fn local_parameters_are_saved_and_discarded_with_the_definition() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let scope = app.tabs[i].current_parametric_scope(); + app.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .local_parameters + .set("Width", "7") + .unwrap(); + assert!(app.save_block_edit(i)); + app.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .local_parameters + .set("Width", "9") + .unwrap(); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert_eq!( + app.tabs[i] + .scene + .parametric_constraint_set(scope) + .unwrap() + .local_parameters + .resolve("Width") + .unwrap(), + 7.0 + ); + app.undo_steps(1); + assert!(!app.tabs[i] + .scene + .parametric_constraint_set(scope) + .unwrap() + .local_parameters + .contains("Width")); + app.redo_steps(1); + assert_eq!( + app.tabs[i] + .scene + .parametric_constraint_set(scope) + .unwrap() + .local_parameters + .resolve("Width") + .unwrap(), + 7.0 + ); +} + +#[test] +fn quit_prompts_for_block_edits_before_unsaved_drawing_changes() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + circle(&mut app, 1.0); + let _ = app.request_quit_with_block_edits(); + assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges)); + let _ = app.update(Message::BlockEditChangesCancel); + assert!(app.pending_close.is_none()); + assert!(app.tabs[app.active_tab].active_block_edit.is_some()); + let _ = app.request_quit_with_block_edits(); + let _ = app.update(Message::BlockEditChangesSave); + assert!(app.tabs[app.active_tab].active_block_edit.is_none()); + assert_eq!(app.active_modal, Some(ModalKind::Unsaved)); + assert!(matches!(app.pending_close, Some(super::PendingClose::Quit))); +} + +#[test] +fn document_replacement_is_refused_until_the_editor_is_closed() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], false); + assert!(app.tabs[app.active_tab].active_block_edit.is_some()); +} + +#[test] +fn erasing_the_last_member_saves_an_empty_definition_and_undo_restores_it() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let line = members(&app)[0]; + app.push_undo_snapshot(i, "Erase block member"); + app.tabs[i].scene.erase_entities(&[line]); + app.finish_pending_history(i); + assert!(members(&app).is_empty()); + app.undo_steps(1); + assert_eq!(members(&app), vec![line]); + app.redo_steps(1); + assert!(members(&app).is_empty()); + let _ = app.dispatch_command("BEDIT_SAVE"); + assert!(members(&app).is_empty()); + app.undo_steps(1); + assert_eq!(members(&app), vec![line]); +} + +#[test] +fn close_all_resumes_after_discarding_a_dirty_block() { + let (mut app, reference) = fixture(); + let first = app.tabs[app.active_tab].id; + app.tabs.push(super::document::DocumentTab::new_drawing(2)); + let second = app.tabs.last().unwrap().id; + begin(&mut app, reference); + circle(&mut app, 1.0); + let _ = app.update(Message::DocTabCloseAll); + assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges)); + let _ = app.update(Message::BlockEditChangesDiscard); + assert!(!app + .tabs + .iter() + .any(|tab| tab.id == first || tab.id == second)); + assert!(app.pending_tab_closes.is_empty()); +} diff --git a/src/app/command_driver/tests.rs b/src/app/command_driver/tests.rs index da8faf9f5..502b646ad 100644 --- a/src/app/command_driver/tests.rs +++ b/src/app/command_driver/tests.rs @@ -1284,12 +1284,9 @@ mod bedit_characterization_tests { assert!(app.tabs[tab].block_edits.is_empty()); } - /// Documenting: the REFEDIT guard lives only in the `BEDIT` picker arm. - /// `BEDIT_BEGIN:` (used by the palette "Edit" action) has no - /// REFEDIT check and opens a session while REFEDIT is active. Expected to - /// flip in Phase 1 once the guard moves into the begin path. + /// Every entry point must refuse while REFEDIT is active. #[test] - fn documenting_bedit_begin_bypasses_refedit_guard() { + fn locked_bedit_begin_refuses_active_refedit() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); let tab = app.active_tab; @@ -1297,8 +1294,8 @@ mod bedit_characterization_tests { assert!(app.tabs[tab].refedit_session.is_some()); let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", insert.value())); assert!( - app.tabs[tab].active_block_edit.is_some(), - "documenting: BEDIT_BEGIN opens a session despite an active REFEDIT" + app.tabs[tab].active_block_edit.is_none(), + "begin must refuse while REFEDIT is active" ); } @@ -1323,12 +1320,9 @@ mod bedit_characterization_tests { // ---- documenting: today's behavior, expected to flip in Phase 1 ---- - /// Documenting: modify an existing member, discard, then undo once. The - /// undo target is the BEDIT_BEGIN entry. Records whatever the stack does - /// with the stale before-image (ISS-02). The assertion pins the current - /// result; Phase 1 must justify any change. + /// Discard removes local before-images before drawing undo resumes. #[test] - fn documenting_modify_discard_then_undo_probes_stale_before_image() { + fn locked_modify_discard_then_undo_does_not_restore_local_edits() { let mut app = OpenCADStudio::new_for_test(); let (insert, members) = setup_block_and_insert(&mut app); let line = members[0]; @@ -1367,10 +1361,9 @@ mod bedit_characterization_tests { ); } - /// Documenting: undo during a session crosses the entry boundary. A - /// single undo after an in-session edit may revert the BEDIT_BEGIN step. + /// Local undo restores both the entity store and block membership. #[test] - fn documenting_undo_inside_session_crosses_entry_boundary() { + fn locked_session_undo_restores_membership_without_crossing_entry() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); begin_bedit(&mut app, insert); @@ -1381,38 +1374,33 @@ mod bedit_characterization_tests { assert_eq!(block_members(&app, "BLK").len(), 2); app.undo_steps(1); - // Documenting: undo removes the circle from the entity store but leaves - // its handle in the block record, so the definition holds a dangling - // member. Expected to flip in Phase 1 (members should match the store). let members = block_members(&app, "BLK"); assert_eq!( members.len(), - 2, - "documenting: block record keeps the undone handle" + 1, + "block membership must match the undone entity store" ); let dangling = members .iter() .filter(|h| app.tabs[tab].scene.document.get_entity(**h).is_none()) .count(); - assert_eq!(dangling, 1, "documenting: one member handle has no entity"); + assert_eq!(dangling, 0, "every member must have an entity"); assert!( app.tabs[tab].active_block_edit.is_some(), "documenting: undo does not close the session" ); } - /// Documenting: undoing the BEDIT_BEGIN step itself, while the session is - /// still open, leaves the editor open. Records whether the undo reaches - /// across the entry boundary. + /// Opening starts an empty local history; undo cannot cross entry. #[test] - fn documenting_undo_across_entry_leaves_session_open() { + fn locked_session_entry_has_no_undo_step() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); let tab = app.active_tab; begin_bedit(&mut app, insert); app.finish_pending_history(tab); let depth_after_begin = undo_depth(&app); - assert!(depth_after_begin >= 1, "BEDIT_BEGIN recorded an undo step"); + assert_eq!(depth_after_begin, 0, "opening must not record an undo step"); app.undo_steps(1); assert!( @@ -1421,10 +1409,9 @@ mod bedit_characterization_tests { ); } - /// Documenting: BEDIT on a block while another block is open nests a - /// second session. Records the resulting stack. + /// Switching a clean block replaces the single session. #[test] - fn documenting_nested_bedit_opens_second_session() { + fn locked_clean_switch_replaces_the_session() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); let tab = app.active_tab; @@ -1448,15 +1435,14 @@ mod bedit_characterization_tests { begin_bedit(&mut app, other_insert); assert_eq!( app.tabs[tab].block_edits.len(), - 2, - "documenting: a second BEDIT pushes a nested session" + 1, + "a drawing has only one block-editor session" ); } - /// Documenting: BEDIT_BEGIN pushes an undo entry, and BEDIT_SAVE and - /// BEDIT_DISCARD push none. Phase 1 may change this. + /// No-change Save and Discard leave the drawing history untouched. #[test] - fn documenting_save_and_discard_push_no_undo_entry() { + fn locked_clean_save_and_discard_leave_history_unchanged() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); begin_bedit(&mut app, insert); diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 2d1ed0a4e..5bd028c16 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -511,30 +511,40 @@ impl OpenCADStudio { } } - cmd if cmd.starts_with("BEDIT_BEGIN:") => { + cmd if cmd.starts_with("BEDIT_BEGIN:") || cmd.starts_with("BEDIT_OPEN:") => { + if self.tabs[i].refedit_session.is_some() { + self.command_line.push_error( + crate::t!("BEDIT: finish the active REFEDIT (REFCLOSE) first.").as_ref(), + ); + return Some(Task::none()); + } use crate::modules::draw::modify::block_edit::BlockEditSession; use codec::Handle; - let handle_u64: u64 = cmd["BEDIT_BEGIN:".len()..].parse().unwrap_or(0); - let insert_handle = Handle::new(handle_u64); - if self.reject_locked_edit(i, insert_handle) { - return Some(Task::none()); - } - - let block_name = match self.tabs[i].scene.document.get_entity(insert_handle) { - Some(codec::EntityType::Insert(ins)) => ins.block_name.clone(), - _ => { - self.command_line.push_error( - crate::t!("BEDIT: selected object is not a block reference.").as_ref(), - ); + let block_name = if let Some(name) = cmd.strip_prefix("BEDIT_OPEN:") { + name.to_string() + } else { + let handle_u64: u64 = cmd["BEDIT_BEGIN:".len()..].parse().unwrap_or(0); + let insert_handle = Handle::new(handle_u64); + if self.reject_locked_edit(i, insert_handle) { return Some(Task::none()); } + match self.tabs[i].scene.document.get_entity(insert_handle) { + Some(codec::EntityType::Insert(ins)) => ins.block_name.clone(), + _ => { + self.command_line.push_error( + crate::t!("BEDIT: selected object is not a block reference.") + .as_ref(), + ); + return Some(Task::none()); + } + } }; // Resolve the block record; reject external references (xrefs). let (br_handle, is_xref) = match self.tabs[i].scene.document.block_records.get(&block_name) { - Some(br) => (br.handle, br.flags.is_xref), + Some(br) => (br.handle, br.flags.is_xref || br.flags.is_xref_overlay), None => { self.command_line.push_error( crate::tf!("BEDIT: block \"{}\" not found.", block_name).as_ref(), @@ -548,52 +558,16 @@ impl OpenCADStudio { ); return Some(Task::none()); } - if self.tabs[i] - .block_edits - .iter() - .any(|session| session.br_handle == br_handle) - { - self.tabs[i].active_cmd = None; - return Some(Task::done(Message::BlockEditSwitch(block_name.clone()))); + if let Some(session) = self.tabs[i].active_block_edit_session() { + if session.br_handle == br_handle { + return Some(Task::none()); + } + return Some(self.request_block_edit_close( + i, + crate::app::block_editor::BlockEditContinuation::Switch(block_name), + )); } - // Snapshot the complete block definition so Discard can restore - // structural markers, ATTDEFs and geometry with exact handles. - let snapshot: Vec<_> = { - let br = self.tabs[i] - .scene - .document - .block_records - .get(&block_name) - .unwrap(); - br.entity_handles - .iter() - .filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned()) - .collect() - }; - let dependent_snapshot: Vec<_> = self.tabs[i] - .scene - .block_definition_dependent_handles(br_handle) - .into_iter() - .filter_map(|handle| self.tabs[i].scene.document.get_entity_arc(handle)) - .collect(); - let reference_attributes: Vec<_> = self.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| match entity { - codec::EntityType::Insert(reference) - if reference.block_name.eq_ignore_ascii_case(&block_name) - && !reference.attributes.is_empty() => - { - Some((reference.common.handle, reference.attributes.clone())) - } - _ => None, - }) - .collect(); - - self.push_undo_snapshot(i, "BEDIT"); - // Capture the camera before the editor reframes it, so leaving // the block editor returns the view exactly where it was (#425). let current_camera = self.tabs[i].scene.camera.borrow().clone(); @@ -627,9 +601,6 @@ impl OpenCADStudio { br_handle, return_layout, return_block, - snapshot, - dependent_snapshot, - reference_attributes, return_camera, editor_camera: current_camera, editor_ucs: None, @@ -664,125 +635,37 @@ impl OpenCADStudio { self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; self.refresh_properties(); self.tabs[i].active_cmd = None; - self.tabs[i].dirty = true; - self.command_line.push_info( - crate::tf!( - "BEDIT: Editing block \"{}\". Use Save Block or Discard to finish.", - block_name - ) - .as_ref(), - ); + self.begin_block_edit_history(i); + self.command_line + .push_info(crate::tf!("Block Editor: editing {}", block_name).as_ref()); } + "BSAVE" => { + self.save_block_edit(i); + } + "BCLOSE" => { + if self.tabs[i].active_block_edit.is_none() { + self.command_line + .push_error(crate::t!("No block editor is open.").as_ref()); + } else { + return Some(self.request_block_edit_close( + i, + crate::app::block_editor::BlockEditContinuation::Close, + )); + } + } "BEDIT_SAVE" => { - let session = match self.tabs[i].active_block_edit.take() { - Some(index) if index < self.tabs[i].block_edits.len() => { - self.tabs[i].block_edits.remove(index) - } - None => { - self.command_line - .push_error(crate::t!("BEDIT_SAVE: no block editor is open.").as_ref()); - return Some(Task::none()); - } - Some(_) => { - self.command_line.push_error( - crate::t!("BEDIT_SAVE: invalid block editor state.").as_ref(), - ); - return Some(Task::none()); - } - }; - // Edits are live on the block record — just leave the block space. - self.restore_after_block_edit_close( - i, - session.return_block.as_deref(), - &session.return_layout, - &session.return_camera, - ); - self.tabs[i].dirty = true; - self.command_line.push_output( - crate::tf!( - "BEDIT: Block \"{}\" saved. All references updated.", - session.block_name - ) - .as_ref(), - ); + if self.save_block_edit(i) { + self.close_block_edit(i, false); + } } - "BEDIT_DISCARD" => { - let session = match self.tabs[i].active_block_edit.take() { - Some(index) if index < self.tabs[i].block_edits.len() => { - self.tabs[i].block_edits.remove(index) - } - None => { - self.command_line.push_error( - crate::t!("BEDIT_DISCARD: no block editor is open.").as_ref(), - ); - return Some(Task::none()); - } - Some(_) => { - self.command_line.push_error( - crate::t!("BEDIT_DISCARD: invalid block editor state.").as_ref(), - ); - return Some(Task::none()); - } - }; - // Restore the block definition to its on-entry snapshot: remove the - // block's current entities, then re-add the snapshot ones (mirrors - // the REFCLOSE_SAVE write-back). - let old_handles: Vec<_> = match self.tabs[i] - .scene - .document - .block_records - .get(&session.block_name) - { - Some(br) => br.entity_handles.clone(), - None => vec![], - }; - for h in &old_handles { - self.tabs[i].scene.document.remove_entity(*h); + if self.tabs[i].active_block_edit.is_none() { + self.command_line + .push_error(crate::t!("No block editor is open.").as_ref()); + } else { + self.close_block_edit(i, true); } - if let Some(br) = self.tabs[i] - .scene - .document - .block_records - .get_mut(&session.block_name) - { - br.entity_handles.clear(); - } - let block_name = session.block_name.clone(); - let return_block = session.return_block.clone(); - let return_layout = session.return_layout.clone(); - let return_camera = session.return_camera.clone(); - for mut entity in session.snapshot { - // Preserve the original handle so ATTDEF references and - // other handle-based relationships survive Discard. - entity.common_mut().owner_handle = session.br_handle; - let _ = self.tabs[i].scene.document.add_entity(entity); - } - for (handle, attributes) in session.reference_attributes { - if let Some(codec::EntityType::Insert(reference)) = - self.tabs[i].scene.document.get_entity_mut(handle) - { - reference.attributes = attributes; - } - } - for entity in session.dependent_snapshot { - let handle = entity.common().handle; - let _ = self.tabs[i] - .scene - .document - .replace_entity_arc(handle, entity); - } - self.restore_after_block_edit_close( - i, - return_block.as_deref(), - &return_layout, - &return_camera, - ); - self.tabs[i].dirty = true; - self.command_line.push_output( - crate::tf!("BEDIT: Block \"{}\" edit discarded.", block_name).as_ref(), - ); } cmd if cmd.starts_with("REFEDIT_BEGIN:") => { @@ -1462,7 +1345,7 @@ impl OpenCADStudio { Some(self.finish_dispatch(cmd)) } - fn restore_after_block_edit_close( + pub(in crate::app) fn restore_after_block_edit_close( &mut self, i: usize, return_block: Option<&str>, @@ -1510,6 +1393,10 @@ impl OpenCADStudio { self.tabs[i].scene.camera_generation += 1; self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; self.tabs[i].scene.rebuild_derived_caches(); + let layers = self.tabs[i].scene.document.layers.clone(); + let viewports = self.tabs[i].scene.viewport_list(); + self.tabs[i].layers.sync_with_viewports(&layers, viewports); + self.sync_ribbon_layers(); self.tabs[i].refresh_active_ucs(); self.refresh_properties(); self.adopt_view_display(i); diff --git a/src/app/document.rs b/src/app/document.rs index bf0b130b7..841609a4e 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -205,6 +205,8 @@ pub(super) struct DocumentTab { /// Index of the field that TAB has focused (the one keystrokes edit). pub(super) dyn_active: usize, pub(super) history: HistoryState, + /// Drawing history is suspended while `history` belongs to BEDIT. + pub(super) block_edit_history: 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 @@ -653,6 +655,7 @@ impl DocumentTab { dyn_ref_screen: None, dyn_active: 0, history: HistoryState::default(), + block_edit_history: None, active_layer: "0".to_string(), props_asym_scale: std::collections::HashSet::new(), graph: Default::default(), diff --git a/src/app/history.rs b/src/app/history.rs index 01b919f14..389c2b3ba 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -152,7 +152,7 @@ impl OpenCADStudio { defer_history_drop(discarded); } - fn push_undo_entry(&mut self, i: usize, snapshot: HistorySnapshot) { + pub(super) fn push_undo_entry(&mut self, i: usize, snapshot: HistorySnapshot) { self.tabs[i].history.undo_stack.push(snapshot); self.tabs[i].edit_revision = self.tabs[i].edit_revision.wrapping_add(1); self.clear_redo_history(i); @@ -283,6 +283,7 @@ impl OpenCADStudio { return; }; self.tabs[i].scene.document.end_entity_change_recording(); + self.reconcile_block_edit_membership(i); let structure_after = self.tabs[i].scene.document.snapshot_structure(); let before_images = pending.recorder.take_before_images(); let added_handles: Vec = before_images @@ -292,11 +293,25 @@ impl OpenCADStudio { .then_some(*handle) }) .collect(); + let block_members = self.tabs[i] + .active_block_edit_session() + .and_then(|session| { + pending + .structure_before + .block_records + .get(&session.block_name) + .map(|record| (session.block_name.clone(), record.entity_handles.clone())) + }); codec::CadDocument::align_added_entity_structure( &mut pending.structure_before, &structure_after, &added_handles, ); + if let Some((name, members)) = block_members { + if let Some(record) = pending.structure_before.block_records.get_mut(&name) { + record.entity_handles = members; + } + } let structure_changed = pending.structure_before != structure_after; let entities: Vec<_> = before_images .into_iter() @@ -402,8 +417,8 @@ impl OpenCADStudio { ) -> Option { self.finish_pending_history(i); let label = label.into(); - let structure_before = - (!delta_safe).then(|| self.tabs[i].scene.document.snapshot_structure()); + let structure_before = (!delta_safe || self.tabs[i].active_block_edit.is_some()) + .then(|| self.tabs[i].scene.document.snapshot_structure()); self.tabs[i].scene.begin_undo_recording(); Some(PendingDelta { label, @@ -740,6 +755,7 @@ impl OpenCADStudio { let Some(rec) = self.tabs[i].scene.take_undo_recording() else { return; }; + self.reconcile_block_edit_membership(i); // The predicate should have kept the command entity-only; if a primitive // still poisoned the recording we've already mutated and the pre-command // full state is gone, so the entity delta is the best we can do (its @@ -813,6 +829,16 @@ impl OpenCADStudio { let mut structure = pending.structure_before.map(StructureSnapshot::Full); if let Some(before_structure) = structure.as_mut() { if let StructureSnapshot::Full(before_structure) = before_structure { + let block_members = self.tabs[i] + .active_block_edit_session() + .and_then(|session| { + before_structure + .block_records + .get(&session.block_name) + .map(|record| { + (session.block_name.clone(), record.entity_handles.clone()) + }) + }); let after_structure = self.tabs[i].scene.document.snapshot_structure(); let added_handles: Vec = entities .iter() @@ -825,6 +851,11 @@ impl OpenCADStudio { &after_structure, &added_handles, ); + if let Some((name, members)) = block_members { + if let Some(record) = before_structure.block_records.get_mut(&name) { + record.entity_handles = members; + } + } if *before_structure == after_structure { structure = None; } diff --git a/src/app/mod.rs b/src/app/mod.rs index 96bddd1d9..d28b02525 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,6 +8,9 @@ pub(crate) mod config; #[cfg(not(target_arch = "wasm32"))] pub use automation::{export_headless, serve}; mod annotation_data; +mod block_editor; +#[cfg(test)] +mod block_editor_tests; mod command_driver; pub(crate) use command_driver::{copy_to_clipboard_kernel, paste_entities_kernel, PasteSpace}; pub(crate) mod commands; @@ -1520,6 +1523,7 @@ pub(super) struct OpenCADStudio { // ── Unsaved-changes dialog ──────────────────────────────────────────── /// Set when the user tries to close a tab or quit while there are unsaved changes. pending_close: Option, + pending_block_close: Option<(u64, block_editor::BlockEditContinuation)>, /// Stable document ids waiting to be closed by "Close All" or /// "Close All Other Drawings". Dirty drawings pause this queue at the /// existing unsaved-changes dialog and resume after Save or Discard. @@ -2158,6 +2162,7 @@ pub enum ModalKind { MLeaderStyle, DimStyle, Unsaved, + BlockEditChanges, SaveDialog, Recovery, /// Fonts referenced by the drawing are missing on this machine. @@ -2784,6 +2789,9 @@ pub enum Message { UnsavedDialogDiscard, /// User clicked "Cancel" in the unsaved-changes dialog. UnsavedDialogCancel, + BlockEditChangesSave, + BlockEditChangesDiscard, + BlockEditChangesCancel, // ── AEC / unsupported-object drop warning (lossy Save-As) ────────────── /// Save as the source DWG version so the unsupported objects survive. AecDropSameVersion, @@ -4844,6 +4852,7 @@ impl OpenCADStudio { queued_interaction_indices: std::collections::VecDeque::new(), pending_close: None, pending_tab_closes: std::collections::VecDeque::new(), + pending_block_close: None, #[cfg(not(target_arch = "wasm32"))] active_save_jobs: std::collections::HashMap::new(), #[cfg(not(target_arch = "wasm32"))] diff --git a/src/app/update/block_ops_tests.rs b/src/app/update/block_ops_tests.rs index bfae7258e..05a3c25a7 100644 --- a/src/app/update/block_ops_tests.rs +++ b/src/app/update/block_ops_tests.rs @@ -887,11 +887,9 @@ fn locked_palette_remove_refuses_a_block_with_an_open_bedit_session() { ); } -/// Nested BEDIT sessions all count: the outer and the inner record are both -/// open, so both are refused — the whole session stack is handed to the -/// service, not just the active tab. +/// Switching replaces the open session; the active definition stays protected. #[test] -fn locked_palette_remove_refuses_every_block_of_a_nested_bedit_session() { +fn locked_palette_remove_refuses_the_open_block_after_switching() { let mut app = app(); let inner = "BO_NEST_INNER"; define_block(&mut app, inner); @@ -915,10 +913,10 @@ fn locked_palette_remove_refuses_every_block_of_a_nested_bedit_session() { begin_bedit(&mut app, inner_reference); assert_eq!( app.tabs[tab(&app)].block_edits.len(), - 2, - "the nested session did not open" + 1, + "switching must keep one session" ); - for name in [outer, inner] { + for name in [inner] { palette_remove(&mut app, name); assert!(record_exists(&app, name), "{name} went while open"); let expected = command_line_error(&format!( diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 7e2c909dd..a7a95e16a 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -151,7 +151,7 @@ impl OpenCADStudio { /// Close queued drawings until a dirty tab requires confirmation. Queue /// entries use stable document ids because removing an earlier tab changes /// every later vector index. - pub(super) fn continue_tab_close_queue(&mut self) -> Task { + pub(in crate::app) fn continue_tab_close_queue(&mut self) -> Task { let mut tasks = Vec::new(); while let Some(tab_id) = self.pending_tab_closes.pop_front() { let Some(idx) = self.tabs.iter().position(|tab| tab.id == tab_id) else { @@ -160,6 +160,13 @@ impl OpenCADStudio { if self.tabs[idx].is_start { continue; } + if self.tabs[idx].active_block_edit.is_some() { + tasks.push(self.on_tab_close(idx)); + if self.pending_block_close.is_some() || self.pending_close.is_some() { + break; + } + continue; + } if self.tabs[idx].dirty { self.pending_close = Some(crate::app::PendingClose::Tab(tab_id)); tasks.push(self.open_unsaved_dialog_window()); @@ -170,11 +177,21 @@ impl OpenCADStudio { Task::batch(tasks) } - pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { + pub(in crate::app) fn on_tab_close(&mut self, idx: usize) -> Task { // 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(); } + if self + .tabs + .get(idx) + .is_some_and(|tab| tab.active_block_edit.is_some()) + { + return self.request_block_edit_close( + idx, + crate::app::block_editor::BlockEditContinuation::Tab(self.tabs[idx].id), + ); + } // Closing a tab shifts indices / the active tab. The attribute // editor holds a document-local handle into one tab, so drop it // now rather than risk it applying to a different tab's document. @@ -4344,7 +4361,7 @@ impl OpenCADStudio { /// Close the attribute editor without applying, if it is open. Used when /// the tab it belongs to closes or the active tab switches — its handle is /// document-local and must not outlive its tab. - pub(super) fn cancel_attr_editor(&mut self) { + pub(in crate::app) fn cancel_attr_editor(&mut self) { if self.active_modal == Some(crate::app::ModalKind::AttributeEditor) { self.active_modal = None; self.reset_modal_geometry(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index e2f64f20e..421b2076a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -215,6 +215,7 @@ impl OpenCADStudio { match self.active_modal { // Dismissing these via ✕ is the cancel/decline path. Some(Unsaved) => self.pending_close = None, + Some(BlockEditChanges) => self.cancel_block_edit_close(), Some(AssocPrompt) => self.mark_assoc_prompted(), // Cancel: leave the layer (and its objects) untouched. Some(LayerDeleteWarning) => self.layer_delete_pending = None, @@ -3895,11 +3896,7 @@ impl OpenCADStudio { Message::WindowCloseRequested(id) => { if self.main_window == Some(id) { - if self.tabs.iter().any(|t| t.dirty) { - self.pending_close = Some(super::PendingClose::Quit); - return self.open_unsaved_dialog_window(); - } - return self.exit_app(); + return self.request_quit_with_block_edits(); } Task::none() } @@ -10434,6 +10431,12 @@ impl OpenCADStudio { Message::UnsavedDialogDiscard => self.on_unsaved_dialog_discard(), Message::UnsavedDialogSave => self.on_unsaved_dialog_save(), + Message::BlockEditChangesSave => self.resolve_block_edit_close(true), + Message::BlockEditChangesDiscard => self.resolve_block_edit_close(false), + Message::BlockEditChangesCancel => { + self.cancel_block_edit_close(); + Task::none() + } Message::AecDropSameVersion => self.on_aec_drop_same_version(), Message::AecDropProceed => self.on_aec_drop_proceed(), diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 25b414075..6b00f7b67 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -6446,6 +6446,12 @@ properties={:.1}ms picked={}", } pub(crate) fn on_layout_switch(&mut self, name: String) -> Task { + if self.tabs[self.active_tab].active_block_edit.is_some() { + return self.request_block_edit_close( + self.active_tab, + crate::app::block_editor::BlockEditContinuation::Layout(name), + ); + } self.on_layout_switch_inner(name, false) } @@ -6508,6 +6514,12 @@ properties={:.1}ms picked={}", /// define a view and then returns to its paper layout. This is the only /// layout transition allowed to preserve an active command. pub(crate) fn on_layout_switch_preserving_command(&mut self, name: String) -> Task { + if self.tabs[self.active_tab].active_block_edit.is_some() { + return self.request_block_edit_close( + self.active_tab, + crate::app::block_editor::BlockEditContinuation::Layout(name), + ); + } self.on_layout_switch_inner(name, true) } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index c0cad3593..bb3cb3ec3 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -76,6 +76,7 @@ impl OpenCADStudio { Some(K::ExternalChange) => crate::tr!("modal", "drawing-changed"), 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::PointStyle) => crate::tr!("modal", "point-style"), Some(K::AttributeEditor) => crate::tr!("modal", "attribute-editor"), Some(K::SaveDialog) => crate::tr!("modal", "save-drawing-as"), @@ -1742,7 +1743,19 @@ impl OpenCADStudio { .unwrap_or_default(), None => String::new(), }; - automatic_flow(ex, |flow| unsaved_changes_dialog_window(&tab_name, flow)) + automatic_flow(ex, |flow| { + unsaved_changes_dialog_window(&tab_name, flow, false) + }) + } + super::super::ModalKind::BlockEditChanges => { + let name = self + .pending_block_close + .as_ref() + .and_then(|(id, _)| self.tabs.iter().find(|tab| tab.id == *id)) + .and_then(|tab| tab.active_block_edit_session()) + .map(|session| session.block_name.clone()) + .unwrap_or_default(); + automatic_flow(ex, |flow| unsaved_changes_dialog_window(&name, flow, true)) } super::super::ModalKind::PointStyle => sized_flow(ex, 360, 470, |flow| { crate::ui::style::point_style::view_window( @@ -2060,23 +2073,53 @@ fn save_as_dialog_window<'a>( fn unsaved_changes_dialog_window( name: &str, sizing: crate::ui::modal::ModalSizing, + block_edit: bool, ) -> Element<'static, Message> { container( column![ - text(name.to_owned()).size(14), + text(if block_edit { + crate::tf!("Save changes to {}?", name).into_owned() + } else { + name.to_owned() + }) + .size(14), iced::widget::Space::new().height(4), - text(crate::tr!("modal", "unsaved-save-prompt")) - .size(12) - .style(dialog_muted_text_style), + text(if block_edit { + String::new() + } else { + crate::tr!("modal", "unsaved-save-prompt") + }) + .size(12) + .style(dialog_muted_text_style), iced::widget::Space::new().height(20), row![ - dialog_button(t!("Save"), Message::UnsavedDialogSave, button::primary), + dialog_button( + t!("Save"), + if block_edit { + Message::BlockEditChangesSave + } else { + Message::UnsavedDialogSave + }, + button::primary + ), iced::widget::Space::new().width(8), - dialog_button(t!("Discard"), Message::UnsavedDialogDiscard, button::danger), + dialog_button( + t!("Discard"), + if block_edit { + Message::BlockEditChangesDiscard + } else { + Message::UnsavedDialogDiscard + }, + button::danger + ), iced::widget::Space::new().width(8), dialog_button( t!("Cancel"), - Message::UnsavedDialogCancel, + if block_edit { + Message::BlockEditChangesCancel + } else { + Message::UnsavedDialogCancel + }, button::secondary ), ], diff --git a/src/modules/block_editor/mod.rs b/src/modules/block_editor/mod.rs index 705507002..7c3f4d8cf 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -37,13 +37,13 @@ impl CadModule for BlockEditorModule { include_bytes!("../../../assets/icons/edit_block.svg"), ), command( - "BEDIT_SAVE", + "BSAVE", "Save Block", include_bytes!("../../../assets/icons/mt_ok.svg"), ), command( - "BEDIT_DISCARD", - "Discard Block Edit", + "BCLOSE", + "Close Block Editor", include_bytes!("../../../assets/icons/mt_cancel.svg"), ), ], diff --git a/src/modules/draw/modify/block_edit.rs b/src/modules/draw/modify/block_edit.rs index c2a7ed109..fcf41a47b 100644 --- a/src/modules/draw/modify/block_edit.rs +++ b/src/modules/draw/modify/block_edit.rs @@ -15,7 +15,7 @@ // block record in place, so there is no copy-back and no transform — the space // render filter (`current_layout_block_handle`) already draws only the block. -use codec::{EntityType, Handle}; +use codec::Handle; use glam::DVec3; use crate::command::{CadCommand, CmdResult}; @@ -26,16 +26,16 @@ use crate::modules::{IconKind, ModuleEvent, ToolDef}; pub fn block_edit_tools() -> Vec { vec![ ToolDef { - id: "BEDIT_SAVE", + id: "BSAVE", label: "Save Block", icon: IconKind::Svg(include_bytes!("../../../../assets/icons/mt_ok.svg")), - event: ModuleEvent::Command("BEDIT_SAVE".to_string()), + event: ModuleEvent::Command("BSAVE".to_string()), }, ToolDef { - id: "BEDIT_DISCARD", - label: "Discard Block Edit", + id: "BCLOSE", + label: "Close Block Editor", icon: IconKind::Svg(include_bytes!("../../../../assets/icons/mt_cancel.svg")), - event: ModuleEvent::Command("BEDIT_DISCARD".to_string()), + event: ModuleEvent::Command("BCLOSE".to_string()), }, ] } @@ -55,17 +55,6 @@ pub struct BlockEditSession { /// Parent block tab to return to for a nested BEDIT session. `None` returns /// to `return_layout`. pub return_block: Option, - /// The complete block definition captured on entry, so Discard restores - /// structural markers and content with their original handles. - pub snapshot: Vec, - /// Baked anonymous dimension-block entities transformed alongside - /// dimensions in this definition. They remain in place, so Discard can - /// restore their shared before-images directly. - pub dependent_snapshot: Vec>, - /// Inline ATTRIB values/positions on every existing INSERT of this block. - /// Rebasing the origin moves them with the definition; Discard restores - /// their exact on-entry state. - pub reference_attributes: Vec<(Handle, Vec)>, /// Camera state of the space that was active when BEDIT began, restored /// on Save/Discard so the view returns exactly where it was (#425). pub return_camera: crate::scene::view::camera::Camera, @@ -121,3 +110,6 @@ inventory::submit!(crate::command::CommandRegistration { inventory::submit!(crate::command::CommandRegistration { names: &["BEDIT_DISCARD"] }); +inventory::submit!(crate::command::CommandRegistration { + names: &["BSAVE", "BCLOSE"] +});