fix(block): open nested editor tabs

Closes #538
This commit is contained in:
Hakan Seven 2026-07-28 19:13:27 +03:00
commit d77eb8f419
8 changed files with 296 additions and 79 deletions

View file

@ -335,11 +335,7 @@ impl OpenCADStudio {
"BEDIT" => {
use crate::modules::draw::modify::block_edit::BlockEditPickCommand;
if self.tabs[i].block_edit.is_some() {
self.command_line.push_error(
"BEDIT: a block editor is already open. Save or discard it first.",
);
} else if self.tabs[i].refedit_session.is_some() {
if self.tabs[i].refedit_session.is_some() {
self.command_line
.push_error("BEDIT: finish the active REFEDIT (REFCLOSE) first.");
} else {
@ -399,6 +395,16 @@ impl OpenCADStudio {
.push_error("BEDIT: cannot edit an external reference (xref).");
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(
insert.block_name.clone(),
)));
}
// Snapshot the block's block-local entities so Discard can restore
// them (skip structural Block/BlockEnd/AttDef, mirroring REFEDIT).
@ -425,23 +431,42 @@ impl OpenCADStudio {
self.push_undo_snapshot(i, "BEDIT");
let return_layout = self.tabs[i].scene.current_layout.clone();
// Capture the camera before the editor reframes it, so leaving
// the block editor returns the view exactly where it was (#425).
let return_camera = self.tabs[i].scene.camera.borrow().clone();
let current_camera = self.tabs[i].scene.camera.borrow().clone();
let (return_layout, return_block, return_camera) =
if let Some(parent_index) = self.tabs[i].active_block_edit {
let parent = &mut self.tabs[i].block_edits[parent_index];
parent.editor_camera = current_camera.clone();
(
parent.return_layout.clone(),
Some(parent.block_name.clone()),
parent.return_camera.clone(),
)
} else {
self.tabs[i].scene.sync_camera_to_document();
(
self.tabs[i].scene.current_layout.clone(),
None,
current_camera.clone(),
)
};
// A block editor renders in model style; switch to Model first so
// the paper-space code paths stay off, then scope to the block.
if return_layout != "Model" {
if self.tabs[i].scene.current_layout != "Model" {
self.tabs[i].scene.set_current_layout("Model".to_string());
}
self.tabs[i].scene.block_edit_block = Some(br_handle);
self.tabs[i].block_edit = Some(BlockEditSession {
self.tabs[i].block_edits.push(BlockEditSession {
block_name: insert.block_name.clone(),
br_handle,
return_layout,
return_block,
snapshot,
return_camera,
editor_camera: current_camera,
});
self.tabs[i].active_block_edit = Some(self.tabs[i].block_edits.len() - 1);
self.tabs[i].scene.deselect_all();
// The first click of a double-click selects the model-space
@ -461,6 +486,11 @@ impl OpenCADStudio {
// it already scopes to the edited block. Without this the view
// stays wherever model/paper space was. (#261)
self.tabs[i].scene.fit_all();
let editor_camera = self.tabs[i].scene.camera.borrow().clone();
if let Some(session) = self.tabs[i].active_block_edit_session_mut() {
session.editor_camera = editor_camera;
}
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;
@ -471,27 +501,28 @@ impl OpenCADStudio {
}
"BEDIT_SAVE" => {
let session = match self.tabs[i].block_edit.take() {
Some(s) => s,
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("BEDIT_SAVE: no block editor is open.");
return Some(Task::none());
}
Some(_) => {
self.command_line
.push_error("BEDIT_SAVE: invalid block editor state.");
return Some(Task::none());
}
};
// Edits are live on the block record — just leave the block space.
self.tabs[i].scene.block_edit_block = None;
self.tabs[i].scene.deselect_all();
self.tabs[i].active_grip = None;
self.grip_hover = None;
self.grip_popup = None;
self.visibility_popup = None;
self.tabs[i].scene.set_current_layout(session.return_layout.clone());
// Return the view to where it was when BEDIT began (#425).
*self.tabs[i].scene.camera.borrow_mut() = session.return_camera.clone();
self.tabs[i].scene.camera_generation += 1;
self.tabs[i].scene.rebuild_derived_caches();
self.refresh_properties();
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(&format!(
"BEDIT: Block \"{}\" saved. All references updated.",
@ -500,13 +531,20 @@ impl OpenCADStudio {
}
"BEDIT_DISCARD" => {
let session = match self.tabs[i].block_edit.take() {
Some(s) => s,
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("BEDIT_DISCARD: no block editor is open.");
return Some(Task::none());
}
Some(_) => {
self.command_line
.push_error("BEDIT_DISCARD: invalid block editor state.");
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
@ -531,27 +569,25 @@ impl OpenCADStudio {
{
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 {
entity.common_mut().handle = acadrust::Handle::NULL;
entity.common_mut().owner_handle = session.br_handle;
let _ = self.tabs[i].scene.document.add_entity(entity);
}
self.tabs[i].scene.block_edit_block = None;
self.tabs[i].scene.deselect_all();
self.tabs[i].active_grip = None;
self.grip_hover = None;
self.grip_popup = None;
self.visibility_popup = None;
self.tabs[i].scene.set_current_layout(session.return_layout.clone());
// Return the view to where it was when BEDIT began (#425).
*self.tabs[i].scene.camera.borrow_mut() = session.return_camera.clone();
self.tabs[i].scene.camera_generation += 1;
self.tabs[i].scene.rebuild_derived_caches();
self.refresh_properties();
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(&format!(
"BEDIT: Block \"{}\" edit discarded.",
session.block_name
block_name
));
}
@ -1150,6 +1186,60 @@ impl OpenCADStudio {
Some(self.finish_dispatch(cmd))
}
fn restore_after_block_edit_close(
&mut self,
i: usize,
return_block: Option<&str>,
return_layout: &str,
return_camera: &crate::scene::view::camera::Camera,
) {
self.tabs[i].scene.block_edit_block = None;
self.tabs[i].scene.deselect_all();
self.tabs[i].active_grip = None;
self.grip_hover = None;
self.grip_popup = None;
self.visibility_popup = None;
let parent_index = return_block.and_then(|parent_name| {
self.tabs[i]
.block_edits
.iter()
.position(|candidate| candidate.block_name == parent_name)
});
if let Some(parent_index) = parent_index {
let (br_handle, editor_camera) = {
let parent = &self.tabs[i].block_edits[parent_index];
(parent.br_handle, parent.editor_camera.clone())
};
self.tabs[i].scene.set_current_layout("Model".to_string());
self.tabs[i].scene.block_edit_block = Some(br_handle);
self.tabs[i].active_block_edit = Some(parent_index);
*self.tabs[i].scene.camera.borrow_mut() = editor_camera;
} else {
let return_layout = if self.tabs[i]
.scene
.layout_names()
.iter()
.any(|name| name == return_layout)
{
return_layout.to_string()
} else {
"Model".to_string()
};
self.tabs[i].active_block_edit = None;
self.tabs[i].scene.set_current_layout(return_layout);
*self.tabs[i].scene.camera.borrow_mut() = return_camera.clone();
}
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();
self.tabs[i].refresh_active_ucs();
self.refresh_properties();
self.adopt_view_display(i);
self.sync_dyn_fields();
}
/// ADDSELECTED — start the draw command that creates the same kind of
/// object as the currently-selected one, adopting its general properties
/// (layer, colour, linetype, lineweight, linetype scale) as the current

View file

@ -231,8 +231,11 @@ pub(super) struct DocumentTab {
pub(super) paper_bg_color: Option<[f32; 4]>,
/// Active REFEDIT session, if any.
pub(super) refedit_session: Option<RefEditSession>,
/// Active BEDIT block-editor space session, if any (issue #261).
pub(super) block_edit: Option<BlockEditSession>,
/// Open BEDIT block tabs. Definitions are edited live; each tab owns its
/// entry snapshot and camera so nested blocks can remain open independently.
pub(super) block_edits: Vec<BlockEditSession>,
/// Index of the block tab currently shown, or `None` for Model/Paper space.
pub(super) active_block_edit: Option<usize>,
/// Currently active MLeader style name.
pub(super) active_mleader_style: String,
/// Last camera_generation value written back to the document.
@ -258,6 +261,16 @@ pub(super) struct DocumentTab {
}
impl DocumentTab {
pub(super) fn active_block_edit_session(&self) -> Option<&BlockEditSession> {
self.active_block_edit
.and_then(|index| self.block_edits.get(index))
}
pub(super) fn active_block_edit_session_mut(&mut self) -> Option<&mut BlockEditSession> {
self.active_block_edit
.and_then(|index| self.block_edits.get_mut(index))
}
/// The active WCS↔UCS converter for this tab — identity when no UCS is set.
/// Every consumer that needs UCS-relative coordinates goes through this.
pub(super) fn ucs_xform(&self) -> super::helpers::UcsXform {
@ -478,7 +491,8 @@ impl DocumentTab {
bg_color: None,
paper_bg_color: None,
refedit_session: None,
block_edit: None,
block_edits: Vec::new(),
active_block_edit: None,
active_mleader_style: "Standard".to_string(),
last_synced_camera_gen: 0,
#[cfg(not(target_arch = "wasm32"))]

View file

@ -1957,6 +1957,8 @@ pub enum Message {
PspaceCommand,
/// Switch to a named layout ("Model" or paper space layout name).
LayoutSwitch(String),
/// Switch to an already-open BEDIT block tab.
BlockEditSwitch(String),
/// Move a paper layout before/after another paper layout.
LayoutReorder {
from: String,

View file

@ -3358,6 +3358,11 @@ impl OpenCADStudio {
self.on_layout_switch(name)
}
Message::BlockEditSwitch(name) => {
self.layout_list_open = false;
self.on_block_edit_switch(name)
}
Message::LayoutReorder { from, to, after } => {
let i = self.active_tab;
if self.tabs[i].is_start {

View file

@ -370,7 +370,12 @@ impl OpenCADStudio {
let gen = self.tabs[i].scene.camera_generation;
if gen != self.tabs[i].last_synced_camera_gen {
self.tabs[i].last_synced_camera_gen = gen;
if self.tabs[i].scene.sync_camera_to_document() {
if self.tabs[i].active_block_edit.is_some() {
let camera = self.tabs[i].scene.camera.borrow().clone();
if let Some(session) = self.tabs[i].active_block_edit_session_mut() {
session.editor_camera = camera;
}
} else if self.tabs[i].scene.sync_camera_to_document() {
self.tabs[i].dirty = true;
}
}
@ -3134,17 +3139,14 @@ impl OpenCADStudio {
self.tabs[i].scene.document.get_entity(handle),
Some(AcadEntityType::Insert(ins)) if !ins.attributes.is_empty()
);
if insert_has_attrs {
if insert_has_attrs && self.tabs[i].active_block_edit.is_none() {
return Task::done(Message::AttrEditorOpen(handle));
}
let is_insert = matches!(
self.tabs[i].scene.document.get_entity(handle),
Some(AcadEntityType::Insert(_))
);
if is_insert
&& self.tabs[i].refedit_session.is_none()
&& self.tabs[i].block_edit.is_none()
{
if is_insert && self.tabs[i].refedit_session.is_none() {
return Task::done(Message::Command(format!(
"BEDIT_BEGIN:{}",
handle.value()
@ -3679,6 +3681,59 @@ impl OpenCADStudio {
self.on_layout_switch_inner(name, false)
}
pub(crate) fn on_block_edit_switch(&mut self, name: String) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].is_start {
return Task::none();
}
let Some(target_index) = self.tabs[i]
.block_edits
.iter()
.position(|session| session.block_name == name)
else {
self.command_line
.push_error(&format!("BEDIT: block tab \"{name}\" is not open."));
return Task::none();
};
if self.tabs[i].active_block_edit == Some(target_index) {
return Task::none();
}
let cancel_task = self.cancel_active_command_for_space_change();
let current_camera = self.tabs[i].scene.camera.borrow().clone();
if let Some(active_index) = self.tabs[i].active_block_edit {
if let Some(session) = self.tabs[i].block_edits.get_mut(active_index) {
session.editor_camera = current_camera;
}
} else {
self.tabs[i].scene.sync_camera_to_document();
}
let (br_handle, editor_camera) = {
let session = &self.tabs[i].block_edits[target_index];
(session.br_handle, session.editor_camera.clone())
};
self.layout_rename_state = None;
self.tabs[i].scene.active_viewport = None;
self.tabs[i].scene.set_current_layout("Model".to_string());
self.tabs[i].scene.block_edit_block = Some(br_handle);
self.tabs[i].active_block_edit = Some(target_index);
*self.tabs[i].scene.camera.borrow_mut() = editor_camera;
self.tabs[i].scene.camera_generation += 1;
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
self.tabs[i].scene.deselect_all();
self.tabs[i].active_grip = None;
self.grip_hover = None;
self.grip_popup = None;
self.visibility_popup = None;
self.tabs[i].refresh_active_ucs();
self.tabs[i].scene.bump_geometry_no_blocks();
self.refresh_properties();
self.adopt_view_display(i);
self.sync_dyn_fields();
cancel_task
}
/// MVIEW's "Insert view > New" flow deliberately visits Model space to
/// define a view and then returns to its paper layout. This is the only
/// layout transition allowed to preserve an active command.
@ -3700,19 +3755,16 @@ impl OpenCADStudio {
.push_info("Open or create a drawing to switch layouts.");
return Task::none();
}
// A BEDIT block editor locks the active space; finish it with
// Save Block or Discard before switching spaces. (#261)
if self.tabs[i].block_edit.is_some() {
self.command_line.push_info(
"Finish the block editor (Save Block or Discard) before switching spaces.",
);
return Task::none();
}
let perf = crate::perf::enabled();
let perf_total = Instant::now();
let perf_from = self.tabs[i].scene.current_layout.clone();
let context_changed =
self.tabs[i].scene.current_layout != name || self.tabs[i].scene.active_viewport.is_some();
let leaving_block_edit = self.tabs[i].active_block_edit.is_some();
let perf_from = self.tabs[i]
.active_block_edit_session()
.map(|session| session.block_name.clone())
.unwrap_or_else(|| self.tabs[i].scene.current_layout.clone());
let context_changed = leaving_block_edit
|| self.tabs[i].scene.current_layout != name
|| self.tabs[i].scene.active_viewport.is_some();
let preserve_active_command = preserve_active_command
&& self.tabs[i]
.active_cmd
@ -3730,6 +3782,24 @@ impl OpenCADStudio {
} else {
Task::none()
};
if let Some(active_index) = self.tabs[i].active_block_edit.take() {
let camera = self.tabs[i].scene.camera.borrow().clone();
let (return_layout, return_camera) = {
let session = &mut self.tabs[i].block_edits[active_index];
session.editor_camera = camera;
(session.return_layout.clone(), session.return_camera.clone())
};
let return_layout = if self.tabs[i].scene.layout_names().contains(&return_layout) {
return_layout
} else {
"Model".to_string()
};
self.tabs[i].scene.block_edit_block = None;
self.tabs[i].scene.set_current_layout(return_layout);
*self.tabs[i].scene.camera.borrow_mut() = return_camera;
self.tabs[i].scene.camera_generation += 1;
self.tabs[i].scene.bump_geometry_no_blocks();
}
let going_to_paper = name != "Model";
// Persist the camera of the layout we're leaving BEFORE switching
// so returning to it restores where the user left off (the
@ -3849,10 +3919,8 @@ impl OpenCADStudio {
// BEDIT block tab: renaming it renames the BLOCK itself —
// its record, marker and every INSERT reference. (#261)
let is_block_tab = self.tabs[i]
.block_edit
.as_ref()
.map(|be| be.block_name == orig)
.unwrap_or(false);
.active_block_edit_session()
.is_some_and(|session| session.block_name == orig);
if is_block_tab {
if self.tabs[i]
.scene
@ -3866,8 +3934,15 @@ impl OpenCADStudio {
} else {
self.push_undo_snapshot(i, "BLOCK RENAME");
if self.tabs[i].scene.rename_block(&orig, &new_name) {
if let Some(be) = self.tabs[i].block_edit.as_mut() {
be.block_name = new_name.clone();
if let Some(session) =
self.tabs[i].active_block_edit_session_mut()
{
session.block_name = new_name.clone();
}
for session in &mut self.tabs[i].block_edits {
if session.return_block.as_deref() == Some(orig.as_str()) {
session.return_block = Some(new_name.clone());
}
}
self.tabs[i].dirty = true;
self.command_line

View file

@ -1084,7 +1084,7 @@ impl OpenCADStudio {
}
// BEDIT block editor: right-edge Save Block / Discard toolbar (#261).
if tab.block_edit.is_some() && !tab.is_start {
if tab.active_block_edit.is_some() && !tab.is_start {
if let Some(tb) = crate::ui::side_toolbar::view(
&crate::modules::draw::modify::block_edit::block_edit_tools(),
) {
@ -1375,10 +1375,14 @@ impl OpenCADStudio {
let coords_mode = tab.scene.document.header.coords_mode;
let picking = tab.active_cmd.is_some();
let layout_names = tab.scene.layout_names();
let mut displayed_layouts = layout_names.clone();
if let Some(be) = &tab.block_edit {
displayed_layouts.push(be.block_name.clone());
}
let block_tabs = tab
.block_edits
.iter()
.map(|session| session.block_name.clone())
.collect();
let active_block = tab
.active_block_edit_session()
.map(|session| session.block_name.clone());
let status_menu_data = crate::ui::statusbar::StatusMenuData {
layout_names: layout_names.clone(),
polar_custom_input: &self.polar_custom_input,
@ -1401,12 +1405,11 @@ impl OpenCADStudio {
self.polar_increment_deg,
self.dyn_input,
self.snapper.otrack_enabled,
displayed_layouts,
layout_names.clone(),
block_tabs,
layout_names.into_iter().skip(1).collect(),
tab.block_edit
.as_ref()
.map(|be| be.block_name.clone())
.unwrap_or_else(|| tab.scene.current_layout.clone()),
tab.scene.current_layout.clone(),
active_block,
tab.is_start,
self.layout_rename_state.as_ref(),
tab.scene.first_viewport_scale(),

View file

@ -42,7 +42,7 @@ pub fn block_edit_tools() -> Vec<ToolDef> {
// ── Session state (held in DocumentTab) ───────────────────────────────────
/// Active BEDIT block-editor session. Lives in `DocumentTab::block_edit`.
/// One open BEDIT block-editor tab. Lives in `DocumentTab::block_edits`.
#[derive(Clone)]
pub struct BlockEditSession {
/// Name of the block being edited (also the space-tab label).
@ -52,12 +52,18 @@ pub struct BlockEditSession {
/// Space to return to when the block editor closes (the layout that was
/// active when BEDIT began).
pub return_layout: String,
/// Parent block tab to return to for a nested BEDIT session. `None` returns
/// to `return_layout`.
pub return_block: Option<String>,
/// The block's block-local entities captured on entry, so Discard can
/// restore the definition (structural Block/BlockEnd/AttDef excluded).
pub snapshot: Vec<EntityType>,
/// 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,
/// Camera owned by this block tab. Updated whenever another space tab is
/// activated, then restored when the user returns to this block.
pub editor_camera: crate::scene::view::camera::Camera,
}
// ── BEDIT pick command ─────────────────────────────────────────────────────

View file

@ -60,8 +60,10 @@ impl StatusBar {
dyn_input: bool,
otrack: bool,
layouts: Vec<String>,
block_tabs: Vec<String>,
reorderable_layouts: Vec<String>,
current_layout: String,
active_block: Option<String>,
// Start/welcome view has no drawing to own layouts.
is_start: bool,
// If `Some((original, edit_value))`, the named tab shows a text input.
@ -413,10 +415,11 @@ impl StatusBar {
if show_layout_tabs {
let reorderable_layouts: Arc<[String]> = reorderable_layouts.into();
for name in layouts {
let is_active = name == current_layout;
let is_active = active_block.is_none() && name == current_layout;
let renaming = rename_state
.filter(|(orig, _)| *orig == name)
.map(|(_, edit)| edit.as_str());
let switch_msg = Message::LayoutSwitch(name.clone());
left.push(
space_tab(
name,
@ -424,6 +427,24 @@ impl StatusBar {
renaming,
!is_start,
reorderable_layouts.clone(),
switch_msg,
"SB_LAYOUT_TAB",
)
.into(),
);
}
for name in block_tabs {
let is_active = active_block.as_deref() == Some(name.as_str());
let switch_msg = Message::BlockEditSwitch(name.clone());
left.push(
space_tab(
name,
is_active,
None,
!is_start,
Arc::from(Vec::<String>::new()),
switch_msg,
"SB_BLOCK_TAB",
)
.into(),
);
@ -760,6 +781,8 @@ fn space_tab<'a>(
rename_edit: Option<&'a str>,
enabled: bool,
reorderable_layouts: Arc<[String]>,
switch_msg: Message,
report_key_prefix: &'static str,
) -> Element<'a, Message> {
let tab_style = move |theme: &Theme| {
let palette = theme.extended_palette();
@ -791,7 +814,7 @@ fn space_tab<'a>(
.style(tab_style)
.padding([4, 10]);
crate::ui::wrap_bar::PosReport::owned(
format!("SB_LAYOUT_TAB:{label}"),
format!("{report_key_prefix}:{label}"),
tip(
display.into(),
"Open or create a drawing to switch layouts.",
@ -827,9 +850,8 @@ fn space_tab<'a>(
.style(tab_style)
.padding([4, 10]);
let switch_msg = Message::LayoutSwitch(label.clone());
let has_context_menu = reorderable_layouts.contains(&label);
let report_key = format!("SB_LAYOUT_TAB:{label}");
let report_key = format!("{report_key_prefix}:{label}");
let tab = mouse_area(display).on_press(switch_msg);
let tab: Element<'a, Message> = if has_context_menu {
crate::ui::wrap_bar::ReorderTab::layout(