From 6e2e0740b01a286a473c1fd3330c3fdd99404c81 Mon Sep 17 00:00:00 2001 From: el-j Date: Thu, 29 Oct 2026 13:34:50 +0100 Subject: [PATCH] Isolation mode: breadcrumbs are clickable to navigate hierarchy levels (#4468) --- crates/engine/src/cmd/object/arrange.rs | 50 +++++++++++------ crates/engine/src/tests/persp_text.rs | 44 +++++++++++++++ crates/ui-egui/src/chrome/tabs.rs | 70 ++++++++++++++++++++---- crates/ui-egui/src/chrome/tests.rs | 71 +++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index 2be0c06a8..325024bcb 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -269,22 +269,34 @@ pub(super) fn isolate(s: &mut Session, p: &Value) -> Result { .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) .ok_or_else(|| bad("object.isolate", "missing id"))?; let guides_locked = s.menu.guides_locked; + let (isolation, prev) = { + let st = s.doc_mut()?; + // Groups, layers and single objects (a path, a compound path, an image…); type isn't + // isolated: a double-click on it edits its text. + if matches!(st.doc.node(id).ok_or(EngineError::NoNode(id))?.kind, NodeKind::Text(_)) { + return Err(bad("object.isolate", "type objects can't be isolated")); + } + let isolation = + st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); + // A hidden or locked object, one in a hidden or locked group or layer, and a guide path while + // Lock Guides is on aren't isolated either: a click with the Selection tool doesn't select them. + // A Shaper Group isolates its source group, so that group is the one checked. + if !st.doc.is_editable(isolation) { + return Err(bad("object.isolate", "the object is hidden or locked")); + } + if guides_locked && crate::is_guide_path(&st.doc, isolation) { + return Err(EngineError::Other("guides are locked".into())); + } + (isolation, st.isolation) + }; + if let Some(prev) = prev + && prev != isolation + { + crate::cmd::settle_interaction(s)?; + let st = s.doc_mut()?; + crate::cmd::distortcmds::finish_edit_text(st, prev); + } let st = s.doc_mut()?; - // Groups, layers and single objects (a path, a compound path, an image…); type isn't - // isolated: a double-click on it edits its text. - if matches!(st.doc.node(id).ok_or(EngineError::NoNode(id))?.kind, NodeKind::Text(_)) { - return Err(bad("object.isolate", "type objects can't be isolated")); - } - let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); - // A hidden or locked object, one in a hidden or locked group or layer, and a guide path while - // Lock Guides is on aren't isolated either: a click with the Selection tool doesn't select them. - // A Shaper Group isolates its source group, so that group is the one checked. - if !st.doc.is_editable(isolation) { - return Err(bad("object.isolate", "the object is hidden or locked")); - } - if guides_locked && crate::is_guide_path(&st.doc, isolation) { - return Err(EngineError::Other("guides are locked".into())); - } st.isolation = Some(isolation); st.selection.clear(); // New art on a locked artboard, now deselected, is protected. @@ -312,8 +324,11 @@ pub(super) fn exit_isolation(s: &mut Session, p: &Value) -> Result { return ok(); } let menu = s.menu.clone(); - let st = s.doc_mut()?; - if let Some(i) = st.isolation.take() { + let prev = s.active().and_then(|d| d.isolation); + if let Some(i) = prev { + crate::cmd::settle_interaction(s)?; + let st = s.doc_mut()?; + st.isolation = None; crate::cmd::distortcmds::finish_edit_text(st, i); let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i); st.selection.set([target]); @@ -322,6 +337,7 @@ pub(super) fn exit_isolation(s: &mut Session, p: &Value) -> Result { // The art on a locked artboard can't be selected. st.protect_locked_artboards(&[], &Default::default(), None); } + let st = s.doc_mut()?; st.revision += 1; ok() } diff --git a/crates/engine/src/tests/persp_text.rs b/crates/engine/src/tests/persp_text.rs index 0a32e8138..9de16c451 100644 --- a/crates/engine/src/tests/persp_text.rs +++ b/crates/engine/src/tests/persp_text.rs @@ -158,6 +158,50 @@ fn edit_text_is_off_while_an_opacity_mask_is_edited() { assert_eq!(s.doc().unwrap().isolation, Some(id)); } +#[test] +fn isolating_ancestor_after_undo_clears_editing_from_redo_history() { + let (mut s, rect) = persp_session(); + let id = persp_text(&mut s); + s.execute("select.set", &json!({"ids": [id.0, rect.0]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + + // Start editing text in perspective and make an edit so undo/redo has entries + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("perspective.editText", &json!({})).unwrap(); + s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 0, "insert": "Hello "})).unwrap(); + + // Undo the text edit so redo stack holds an entry while doc is not actively editing + s.execute("edit.undo", &json!({})).unwrap(); + + // Isolating the parent group must clear editing flag in redo entries too + s.execute("object.isolate", &json!({"id": g})).unwrap(); + for e in &s.doc().unwrap().history.redo { + if let Some(p) = e.doc.node(id).and_then(|n| n.extras().perspective.as_deref()) { + assert!(!p.editing, "redo entry must not retain editing=true"); + } + } +} + +#[test] +fn isolate_settles_pending_typing_interaction() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let (mut s, rect) = persp_session(); + let v = crate::ViewInfo::default(); + s.select_tool("type", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, 200.0, 200.0), v).unwrap(); + } + let id = s.doc().unwrap().selection.objects[0]; + s.tool_text("abc", v).unwrap(); + assert!(s.in_interaction()); + + s.execute("object.isolate", &json!({"id": rect.0})).unwrap(); + assert!(!s.in_interaction(), "isolating another object settles typing interaction"); + let n = node(&s, id); + let NodeKind::Text(ref t) = n.kind else { panic!("not text") }; + assert_eq!(t.plain_text(), "abc"); +} + /// Edit Text ending while a background save writes the file leaves the document saved once the /// file is written: the flat view is never saved. Saved while editing and edited after: undoing /// the edit once Edit Text has ended is back at what the file holds. diff --git a/crates/ui-egui/src/chrome/tabs.rs b/crates/ui-egui/src/chrome/tabs.rs index a53062be8..43d0eff62 100644 --- a/crates/ui-egui/src/chrome/tabs.rs +++ b/crates/ui-egui/src/chrome/tabs.rs @@ -136,12 +136,20 @@ pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) { if let Some(st) = app.session.active() && let Some(iso) = st.isolation { - let mut crumbs: Vec = - st.doc.ancestry(iso).unwrap_or_default().iter().filter_map(|id| st.doc.node(*id)).map(|n| n.display_name()).collect(); + let ancestry = st.doc.ancestry(iso).unwrap_or_default(); + struct Crumb { + id: Option, + is_layer: bool, + name: String, + } + let mut crumbs: Vec = ancestry + .iter() + .filter_map(|id| st.doc.node(*id).map(|n| Crumb { id: Some(*id), is_layer: n.is_layer(), name: n.display_name() })) + .collect(); // Pattern editing mode: the pattern's name, and Save a Copy / Done / Cancel at the right. let pattern_edit = st.doc.pattern_edit.as_ref().map(|e| e.pattern.clone()); if let Some(pn) = &pattern_edit { - crumbs.push(pn.clone()); + crumbs.push(Crumb { id: None, is_layer: false, name: pn.clone() }); } let bar = egui::Rect::from_min_size(area.min, vec2(area.width(), 24.0)); ui.interact(bar, ui.scope_id().with("iso-bar"), Sense::click_and_drag()); @@ -149,13 +157,41 @@ pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) { let back = egui::Rect::from_min_size(bar.min + vec2(6.0, 3.0), vec2(18.0, 18.0)); let bresp = ui.interact(back, ui.scope_id().with("iso-back"), Sense::click()); icons::paint(ui, "chevron-left", back, if bresp.hovered() { t.text } else { t.icon }); - ui.painter().text( - bar.left_center() + vec2(30.0, 0.0), - egui::Align2::LEFT_CENTER, - crumbs.join(" › "), - egui::FontId::proportional(12.0), - t.text, - ); + + let mut x = bar.left() + 30.0; + let y_center = bar.center().y; + let font_id = egui::FontId::proportional(12.0); + let mut nav_cmd = None; + let total = crumbs.len(); + for (i, crumb) in crumbs.iter().enumerate() { + let is_leaf = i + 1 == total; + let galley = ui.painter().layout_no_wrap(crumb.name.clone(), font_id.clone(), t.text); + let w = galley.size().x; + let text_pos = egui::pos2(x, y_center - galley.size().y * 0.5); + if !is_leaf { + let crumb_rect = egui::Rect::from_min_max(egui::pos2(x - 3.0, bar.top() + 3.0), egui::pos2(x + w + 3.0, bar.bottom() - 3.0)); + let resp = ui.interact(crumb_rect, ui.scope_id().with(("iso-crumb", i)), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(crumb_rect, 3.0, t.hover); + } + if resp.clicked() { + if crumb.is_layer { + nav_cmd = Some(("object.exitIsolation", json!({}))); + } else if let Some(target) = crumb.id { + nav_cmd = Some(("object.isolate", json!({ "id": target.0 }))); + } + } + ui.painter().galley(text_pos, galley, if resp.hovered() { t.text } else { t.text_dim }); + x += w + 6.0; + let sep = ui.painter().layout_no_wrap("›".to_string(), font_id.clone(), t.text_dim); + let sw = sep.size().x; + ui.painter().galley(egui::pos2(x, y_center - sep.size().y * 0.5), sep, t.text_dim); + x += sw + 6.0; + } else { + ui.painter().galley(text_pos, galley, t.text); + x += w; + } + } let mut pattern_cmd = None; if pattern_edit.is_some() { let mut x = bar.right() - 6.0; @@ -176,9 +212,21 @@ pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) { if bresp.clicked() { pattern_cmd = pattern_cmd.or(pattern_edit.as_ref().map(|_| "object.pattern.done")); if pattern_cmd.is_none() { - app.run("object.exitIsolation", json!({})).ok(); + if ancestry.len() > 1 { + let parent_id = ancestry[ancestry.len() - 2]; + if st.doc.node(parent_id).is_some_and(|n| n.is_layer()) { + nav_cmd = Some(("object.exitIsolation", json!({}))); + } else { + nav_cmd = Some(("object.isolate", json!({ "id": parent_id.0 }))); + } + } else { + nav_cmd = Some(("object.exitIsolation", json!({}))); + } } } + if let Some((cmd, params)) = nav_cmd { + app.run(cmd, params).ok(); + } if let Some(cmd) = pattern_cmd { app.run(cmd, json!({})).ok(); } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 362854a5f..6d8611190 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -902,6 +902,77 @@ fn the_isolation_bar_does_not_take_layout_height_from_the_canvas() { assert_eq!(top(&mut app), before, "isolation mode leaves the canvas where it was"); } +/// #4468: In nested isolation, clicking the back button steps up one level in the hierarchy, +/// and clicking ancestor breadcrumbs navigates directly to that isolation level. +#[test] +fn isolation_bar_navigation_and_back_button_steps_up() { + use vectorcraft_doc::NodeId; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let rect = |app: &mut crate::VectorcraftApp, x: f64| { + app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (rect(&mut app, 10.0), rect(&mut app, 50.0)); + // Group 1 contains a and b + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let g1 = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + let c = rect(&mut app, 90.0); + // Group 2 contains g1 and c + app.run("select.set", json!({"ids": [g1, c]})).unwrap(); + let g2 = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + + // Isolate path a (deepest level: Layer 1 > > > ) + app.run("object.isolate", json!({"id": a})).unwrap(); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(a))); + + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + egui_extras::install_image_loaders(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0)); + + let click_at = |app: &mut crate::VectorcraftApp, pos: egui::Pos2| { + let button = egui::PointerButton::Primary; + let mut run_frame = |events| { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| { + super::isolation_bar(app, ui, screen); + }); + out.textures_delta.clear(); + }; + run_frame(vec![egui::Event::PointerMoved(pos)]); + run_frame(vec![egui::Event::PointerButton { pos, button, pressed: true, modifiers: Default::default() }]); + run_frame(vec![egui::Event::PointerButton { pos, button, pressed: false, modifiers: Default::default() }]); + }; + + // Clicking back from `a` steps up to parent group `g1` + click_at(&mut app, egui::pos2(15.0, 12.0)); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(g1))); + + // Clicking back from `g1` steps up to grandparent group `g2` + click_at(&mut app, egui::pos2(15.0, 12.0)); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(g2))); + + // Clicking back from `g2` (parent is layer) exits isolation mode completely + click_at(&mut app, egui::pos2(15.0, 12.0)); + assert_eq!(app.session.active().unwrap().isolation, None); + + // Deep isolate again to test clicking ancestor breadcrumb directly + app.run("object.isolate", json!({"id": a})).unwrap(); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(a))); + + // First crumb is Layer 1 (at x ~ 30..80). Clicking it exits isolation directly. + click_at(&mut app, egui::pos2(45.0, 12.0)); + assert_eq!(app.session.active().unwrap().isolation, None); + + // Deep isolate again to test clicking an intermediate ancestor group breadcrumb directly + app.run("object.isolate", json!({"id": a})).unwrap(); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(a))); + + // Second crumb is g2 ("") at x ~ 95..140. + // Clicking g2 isolates g2 directly. + click_at(&mut app, egui::pos2(115.0, 12.0)); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(g2))); +} + /// The Control bar's Isolate Selected Object isolates a path inside a group that Direct /// Selection selected, and Exit Isolation Mode leaves with the path selected. Type, symbol /// instances, blends, two objects and an envelope's contents get no isolate button, and pattern