feat(block-editor): add Test Block preview

Preview the current unsaved block in a disposable drawing with independent history. Keep normal Draw tools available, append Close Test Block, and return to the original editor without saving. Guard persistence paths and handle refresh and parent-close lifecycle by stable document identity.
This commit is contained in:
Karim Jerbi committed 2026-10-14 14:44:58 +01:00
1 parent 0a29be07f7
commit 9632979d09
22 files changed
+801 -65

No files matched your search

+15
View File
@@ -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<Value, Value> {
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"}),
+9 -1
View File
@@ -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<Message> {
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 {
+163
View File
@@ -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<Message> {
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<Message> {
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);
}
}
}
}
+346
View File
@@ -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());
}
+1 -1
View File
@@ -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() {
+2
View File
@@ -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);
}
+27
View File
@@ -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);
}
+6
View File
@@ -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<Task<Message>, 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
+12
View File
@@ -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(
+4 -1
View File
@@ -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",
+2
View File
@@ -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<super::block_editor::BlockEditHistory>,
pub(super) block_test: Option<super::block_test::BlockTestSession>,
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(),
+3
View File
@@ -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;
+32 -1
View File
@@ -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<Message> {
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();
+3
View File
@@ -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<Message> {
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
+21 -4
View File
@@ -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<Message> {
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<Message> {
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<Message> {
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<Message> {
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<String> = 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 {
+16 -2
View File
@@ -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;
}
+5
View File
@@ -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 {
+10 -1
View File
@@ -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.
]
+3
View File
@@ -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"]
});
+14 -6
View File
@@ -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<Tip> {
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
+6 -15
View File
@@ -68,15 +68,11 @@ impl Ribbon {
fn entries(&self) -> impl Iterator<Item = (usize, Entry<'_>)> {
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<usize> {
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)
+101 -33
View File
@@ -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<usize>,
block_testing: Option<usize>,
/// 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<Item = &RibbonGroup> {
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<Panel<'_>> = 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<Panel<'_>> = 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<Vec<(&'static str, &'static str, IconKind)>> = 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<Element<'_, Message>> {
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::<Vec<_>>(),
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