Merge pull request #4474 from el-j/feat/isolation-bar-navigation-4468

Isolation mode: breadcrumbs are clickable to navigate hierarchy levels (#4468)
This commit is contained in:
BFlat
2026-10-29 20:21:07 +07:00
committed by GitHub
4 changed files with 202 additions and 28 deletions
+33 -17
View File
@@ -269,22 +269,34 @@ pub(super) fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
.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<Value> {
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<Value> {
// 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()
}
+44
View File
@@ -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.
+54 -11
View File
@@ -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<String> =
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<vectorcraft_doc::NodeId>,
is_layer: bool,
name: String,
}
let mut crumbs: Vec<Crumb> = 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,16 @@ 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();
// Up one level: the isolated object's parent, or out of isolation mode from a layer.
nav_cmd = Some(match ancestry.iter().rev().nth(1).filter(|p| st.doc.node(**p).is_some_and(|n| !n.is_layer())) {
Some(parent) => ("object.isolate", json!({ "id": parent.0 })),
None => ("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();
}
+71
View File
@@ -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 > <Group> > <Group> > <Path>)
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 ("<Group>") 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