mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
Isolation mode: breadcrumbs are clickable to navigate hierarchy levels (#4468)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user