diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 842ee1bc1..774de7540 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -1074,6 +1074,63 @@ fn the_control_bar_isolates_the_selected_object_and_exits_isolation_mode() { assert_eq!(isolation_button(&mut app, &ctx, vec![]), None, "the isolation bar ends pattern editing"); } +/// In symbol editing mode the Control bar's isolation button is Exit Symbol Editing Mode and +/// leaves the mode. With a group isolated inside the symbol's art it is Exit Isolation Mode and +/// goes back to the symbol's art. +#[test] +fn the_control_bar_exits_symbol_editing_mode() { + use vectorcraft_doc::NodeId; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + app.run("symbol.new", json!({"name": "Box"})).unwrap(); + app.run("symbol.edit", json!({"name": "Box"})).unwrap(); + let layer = app.session.active().unwrap().doc.symbol_edit.as_ref().expect("symbol editing mode").layer; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + // The isolation button's label (the one exit label the bar shows), and a click on it. + let label = |app: &mut crate::VectorcraftApp| { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 300.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui)); + out.textures_delta.clear(); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let exits: Vec = nodes + .iter() + .filter_map(|(_, n)| n.label().map(|l| l.trim().to_string())) + .filter(|l| l == "Exit Symbol Editing Mode" || l == "Exit Isolation Mode") + .collect(); + assert!(exits.len() <= 1, "{exits:?}"); + exits.into_iter().next() + }; + let click = |app: &mut crate::VectorcraftApp| { + let r = isolation_button(app, &ctx, vec![]).expect("the bar shows the isolation button"); + let press = + |pressed| egui::Event::PointerButton { pos: r.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for e in [egui::Event::PointerMoved(r.center()), press(true), press(false)] { + isolation_button(app, &ctx, vec![e]); + } + }; + assert_eq!(label(&mut app).as_deref(), Some("Exit Symbol Editing Mode")); + // Two rectangles drawn into the symbol's art, grouped and isolated. + let a = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("shape.rectangle", json!({"x": 130, "y": 100, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let g = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("object.isolate", json!({"id": g})).unwrap(); + assert_eq!(label(&mut app).as_deref(), Some("Exit Isolation Mode")); + click(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.isolation, st.doc.symbol_edit.is_some()), (Some(layer), true), "back to the symbol's art"); + assert_eq!(label(&mut app).as_deref(), Some("Exit Symbol Editing Mode")); + click(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.isolation, st.doc.symbol_edit.is_some()), (None::, false), "symbol editing mode ends"); + // The symbol keeps the art drawn while editing. + let symbol = st.doc.symbols.iter().find(|s| s.name == "Box").expect("the symbol"); + assert!(symbol.art.children().is_some_and(|c| c.len() >= 2), "the edit reached the symbol"); +} + /// #4819: with Direct Selection, a selected blend shows the Control bar's and the Properties /// panel's Convert buttons, as a path selected whole does; with the Selection tool it doesn't. #[test] diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index 291257e97..1b2ff3559 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -1,5 +1,5 @@ //! Symbols panel: the document's symbols as rendered thumbnails or a list. Click selects a symbol -//! (it becomes the Symbol Sprayer's current symbol); double-click places an instance. Symbol +//! (it becomes the Symbol Sprayer's current symbol); double-click opens it in symbol editing mode. Symbol //! libraries open in library panels ([`SymbolLibraries`]). use std::cell::RefCell; @@ -979,6 +979,106 @@ mod tests { assert_eq!(editing_art(&app), Some([585.0, 35.0, 615.0, 65.0])); } + /// The symbol in symbol editing mode, if any. + fn editing(app: &VectorcraftApp) -> Option { + app.session.active()?.doc.symbol_edit.as_ref().map(|e| e.symbol.clone()) + } + + /// The first instance of symbol `name` in the active document. + fn instance_of(app: &VectorcraftApp, name: &str) -> Option { + let mut found = None; + for l in &app.session.active()?.doc.layers { + l.walk(&mut |n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind + && symbol == name + && found.is_none() + { + found = Some(n.id); + } + }); + } + found + } + + fn double_click(at: Pos2) -> Vec { + vec![egui::Event::PointerMoved(at), button(at, true), button(at, false), button(at, true), button(at, false)] + } + + /// A double-click on a symbol in the panel, in Thumbnail View and in List View, opens it in + /// symbol editing mode and places no instance; a single click only makes it current. + #[test] + fn double_clicking_a_symbol_in_the_panel_edits_it_in_either_view() { + let mut app = leaf_and_dot(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // Thumbnail View: the first tile is Leaf's. + let tile = pos2(26.0, 26.0); + frame(&ctx, &mut app, vec![]); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile), button(tile, true), button(tile, false)]); + assert_eq!((editing(&app), current(&mut app)), (None, Some("Leaf".into())), "a click makes it current"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![]); + frame(&ctx, &mut app, double_click(tile)); + assert_eq!(editing(&app).as_deref(), Some("Leaf")); + assert_eq!(art(&app, "Leaf").0, 1, "no instance is placed"); + app.run("symbol.exitEditing", json!({})).unwrap(); + // List View: the row named Dot. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, "sym-list", true); + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 400.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui)); + out.textures_delta.clear(); + let labels = crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()); + let row = labels.iter().find(|(t, _)| t.trim() == "Dot").map(|(_, r)| r.center()).expect("a row named Dot"); + frame(&ctx, &mut app, double_click(row)); + assert_eq!(editing(&app).as_deref(), Some("Dot")); + assert_eq!(art(&app, "Dot").0, 0, "no instance is placed"); + } + + /// The panel menu's Edit Symbol edits the selected instance's symbol, and with no instance + /// selected the symbol current in the panel. + #[test] + fn the_panel_menus_edit_symbol_edits_the_instances_symbol_or_the_panels() { + let mut app = leaf_and_dot(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + click_menu(&ctx, &mut app, "Edit Symbol"); + assert_eq!(editing(&app).as_deref(), Some("Dot"), "no instance selected: the panel's symbol"); + app.run("symbol.exitEditing", json!({})).unwrap(); + let leaf = instance_of(&app, "Leaf").expect("a Leaf instance"); + app.run("select.set", json!({"ids": [leaf.0]})).unwrap(); + click_menu(&ctx, &mut app, "Edit Symbol"); + assert_eq!(editing(&app).as_deref(), Some("Leaf"), "the selected instance's symbol"); + assert_eq!(instance_of(&app, "Leaf"), Some(leaf), "the instance keeps its link"); + } + + /// With instances of one symbol selected, the Control bar's Edit Symbol opens that symbol in + /// symbol editing mode and keeps the instance linked. + #[test] + fn the_control_bars_edit_symbol_edits_the_selected_instances_symbol() { + let mut app = leaf_and_dot(); + let leaf = instance_of(&app, "Leaf").expect("a Leaf instance"); + app.run("select.set", json!({"ids": [leaf.0]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let bar = |app: &mut VectorcraftApp, events: Vec| { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + ui.horizontal(|ui| control_bar(app, ui)); + }); + out.textures_delta.clear(); + out + }; + let out = bar(&mut app, vec![]); + let at = clickable(&out).into_iter().find(|(l, _)| l == "Edit Symbol").expect("an Edit Symbol button").1; + bar(&mut app, vec![egui::Event::PointerMoved(at), button(at, true), button(at, false)]); + assert_eq!(editing(&app).as_deref(), Some("Leaf")); + assert_eq!(instance_of(&app, "Leaf"), Some(leaf), "the instance keeps its link"); + } + fn dialog_kind(app: &VectorcraftApp) -> Option<&str> { app.ui.dialog.as_ref().map(|d| d.kind.as_str()) } diff --git a/docs/mcp.md b/docs/mcp.md index 760ce7071..1846d5bc2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1572,7 +1572,7 @@ already walks Image Trace. Short agent entry points: of `symbol.list` carry `nineSlice`: the guides or `null`; `type` is Symbol Type, `static` by default for the command and `dynamic` by default in the Symbol Options dialog), `symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}`, `symbol.replace`, - `symbol.edit {id?, name?, x?, y?, artboard?, viewCenter?}` (symbol editing mode: the symbol's art on a temporary + `symbol.edit {id?, ids?, name?, x?, y?, artboard?, viewCenter?}` (symbol editing mode: the symbol's art on a temporary isolated layer, where the instance `id` shows it or centered on (x, y), by default on the center of artboard `artboard`, the active one the app passes, or in a document without artboards on `viewCenter`, the view's center the app passes; every instance follows each edit and stays linked), `symbol.exitEditing`