diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 84dc1f3c0..2dc514170 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "appearance.addFill", "Add New Fill", ["Window", "Appearance"], - None, + Some("Cmd+/"), "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill)", has_doc, |s, p| add_item(s, p, true) @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "appearance.addStroke", "Add New Stroke", ["Window", "Appearance"], - None, + Some("Cmd+Alt+/"), "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row (a copy of its top stroke, else the default stroke)", has_doc, |s, p| add_item(s, p, false) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 6007ff914..e3e0f94c9 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1184,6 +1184,31 @@ fn cmd_shift_x_resets_horizontal_scale_for_agents() { assert_eq!(runs[0]["style"]["h_scale"], json!(100.0), "{runs}"); } +/// `press_key` runs Cmd+/ and Cmd+Alt+/ for agents: a new fill and then a new stroke go on top of +/// the selected rectangle's appearance stack. +#[test] +fn cmd_slash_and_cmd_alt_slash_add_a_fill_and_a_stroke_for_agents() { + let mut s = server(); + let r = call(&mut s, 1, "run_command", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 100, "height": 100}})); + let id = serde_json::from_str::(&text_of(&r)).unwrap()["id"].as_u64().unwrap(); + let r = call(&mut s, 2, "run_command", json!({"command": "select.set", "params": {"ids": [id]}})); + assert_eq!(r["isError"], false, "{r}"); + let kinds = |s: &mut Server, n: u64| { + let r = call(s, n, "run_command", json!({"command": "effect.list", "params": {}})); + let items = serde_json::from_str::(&text_of(&r)).unwrap()["applied"][0]["items"].clone(); + items.as_array().unwrap().iter().map(|it| it["kind"].as_str().unwrap().to_string()).collect::>() + }; + assert_eq!(kinds(&mut s, 3), ["fill", "stroke"]); + let r = call(&mut s, 4, "press_key", json!({"key": "/", "mods": {"cmd": true}})); + assert_eq!(r["isError"], false, "{r}"); + assert_eq!(serde_json::from_str::(&text_of(&r)).unwrap()["command"], "appearance.addFill", "{r}"); + assert_eq!(kinds(&mut s, 5), ["fill", "stroke", "fill"]); + let r = call(&mut s, 6, "press_key", json!({"key": "/", "mods": {"cmd": true, "alt": true}})); + assert_eq!(r["isError"], false, "{r}"); + assert_eq!(serde_json::from_str::(&text_of(&r)).unwrap()["command"], "appearance.addStroke", "{r}"); + assert_eq!(kinds(&mut s, 7), ["fill", "stroke", "fill", "stroke"]); +} + /// The drawing tools snap to Smart Guides over MCP as with the mouse (#506): a Rectangle drawn /// from near another object's corner starts on it, and a Pen anchor placed beside that object's /// centre lines up with it. diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index 99d6839d1..7d990108a 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -102,6 +102,106 @@ fn stroke_panel_shows_the_active_rows_weight() { assert!(!frame_texts(&mut app, stroke::show).contains(&six)); } +/// Cmd+/ is Add New Fill and Cmd+Alt+/ is Add New Stroke by default. On Windows and Linux the keys +/// are Ctrl+/ and Ctrl+Alt+/, and on macOS they are Command+/ and Option+Command+/. No other +/// command, tool or panel has either key by default. +#[test] +fn cmd_slash_is_add_new_fill_and_cmd_alt_slash_add_new_stroke() { + use crate::shortcut_editor::{command_for_key, default_command_shortcut}; + let mac = cfg!(target_os = "macos"); + let (cmd, alt) = (egui::Modifiers::COMMAND, egui::Modifiers::ALT); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + for (id, key, label, mods, shown) in [ + ("appearance.addFill", "Cmd+/", "Add New Fill", cmd, if mac { "⌘/" } else { "Ctrl+/" }), + ("appearance.addStroke", "Cmd+Alt+/", "Add New Stroke", cmd | alt, if mac { "⌥⌘/" } else { "Ctrl+Alt+/" }), + ] { + assert_eq!(default_command_shortcut(id), Some(key)); + assert_eq!(command_for_key(key), Some(id)); + let chord = crate::shortcuts::parse(key).expect("parses"); + assert_eq!((chord.modifiers, chord.logical_key), (mods, egui::Key::Slash)); + assert_eq!(crate::menus::pretty_shortcut(key), shown); + let listed = app.run("shortcuts.list", json!({ "query": id })).unwrap(); + let rows: Vec<_> = listed.as_array().expect("rows").iter().map(|r| (r["label"].clone(), r["group"].clone(), r["default"].clone())).collect(); + assert_eq!(rows, [(json!(label), json!("Window › Appearance"), json!(key))], "Keyboard Shortcuts lists {id}"); + } + assert_eq!(app.run("shortcuts.conflicts", json!({})).unwrap(), json!([]), "no other default has either key"); +} + +/// Cmd+/ and Cmd+Alt+/, pressed through the shortcut handler, add a fill and a stroke to the +/// selected objects as the Appearance panel's Add New Fill and Add New Stroke buttons do, on a path +/// and on type. With nothing selected the buttons are off, and the keys leave the objects as they are. +#[test] +fn cmd_slash_adds_a_fill_and_cmd_alt_slash_a_stroke_as_the_panel_buttons_do() { + fn press(app: &mut VectorcraftApp, modifiers: egui::Modifiers) { + let ctx = egui::Context::default(); + let ev = egui::Event::Key { key: egui::Key::Slash, physical_key: None, pressed: true, repeat: false, modifiers }; + let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx())); + out.textures_delta.clear(); + } + /// The Appearance panel's button named `label`: the center of its rect and whether it is on. + fn button(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str) -> (egui::Pos2, bool) { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| appearance::show(app, ui)); + out.textures_delta.clear(); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let (_, n) = nodes + .iter() + .find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button) + .unwrap_or_else(|| panic!("no button named {label:?}")); + let b = n.bounds().expect("bounds"); + (egui::pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5), !n.is_disabled()) + } + /// A rectangle and a line of type, with the rectangle (`type_too`: both) selected. + fn two_objects(type_too: bool) -> (VectorcraftApp, Vec) { + let mut app = app_with_rect(); + let rect = first_selected(&app).expect("the rectangle").id.0; + let text = run(&mut app, "text.create", json!({"x": 10, "y": 150, "text": "Hi"}))["id"].clone(); + let ids = if type_too { vec![json!(rect), text.clone()] } else { vec![json!(rect)] }; + run(&mut app, "select.set", json!({ "ids": ids })); + (app, vec![json!(rect), text]) + } + let stacks = |app: &VectorcraftApp, ids: &[Value]| -> Vec { + let d = &app.session.active().expect("a document").doc; + ids.iter().map(|id| (*d.node(vectorcraft_doc::NodeId(id.as_u64().expect("an id"))).expect("the object").appearance).clone()).collect() + }; + let cmd = egui::Modifiers::COMMAND; + for (type_too, mods, label, fill) in [ + (false, cmd, "Add New Fill", true), + (false, cmd | egui::Modifiers::ALT, "Add New Stroke", false), + (true, cmd, "Add New Fill", true), + (true, cmd | egui::Modifiers::ALT, "Add New Stroke", false), + ] { + let (mut keyed, ids) = two_objects(type_too); + let before = stacks(&keyed, &ids); + press(&mut keyed, mods); + let (mut clicked, _) = two_objects(type_too); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let (at, on) = button(&ctx, &mut clicked, label); + assert!(on, "{label} is on with a selection"); + click(&ctx, &mut clicked, at, appearance::show); + let after = stacks(&keyed, &ids); + assert_eq!(after, stacks(&clicked, &ids), "{label}: the key adds what the button adds"); + let selected = if type_too { 2 } else { 1 }; + for (i, (a, b)) in after.iter().zip(&before).enumerate() { + let added = if i < selected { 1 } else { 0 }; + assert_eq!(a.items.len(), b.items.len() + added, "{label}, object {i}: {a:?}"); + if added == 1 { + assert_eq!(a.items.last().map(|it| it.is_fill()), Some(fill), "{label}: on top of the stack"); + } + } + } + // With nothing selected, both buttons are off and neither key changes the objects. + let (mut app, ids) = two_objects(true); + run(&mut app, "select.none", json!({})); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + assert!(!button(&ctx, &mut app, "Add New Fill").1 && !button(&ctx, &mut app, "Add New Stroke").1); + let before = stacks(&app, &ids); + press(&mut app, cmd); + press(&mut app, cmd | egui::Modifiers::ALT); + assert_eq!(stacks(&app, &ids), before); +} + #[test] fn transparency_panel_reads_the_active_item() { let mut app = app_with_rect(); diff --git a/docs/mcp.md b/docs/mcp.md index d1228d055..39763fbd5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -415,7 +415,8 @@ mode. To press the key during a drag, end one With nothing being dragged, `e` chooses the tool bound to E. Appearance stacks: an object can carry several fills and strokes (`appearance.addFill`, `appearance.addStroke`), indexed -in paint order (0 is painted first, the bottom row of the Appearance panel). `paint.setFill`, `paint.setStroke`, +in paint order (0 is painted first, the bottom row of the Appearance panel). Cmd+/ runs `appearance.addFill` and +Cmd+Alt+/ runs `appearance.addStroke` (`press_key {key: "/", mods: {cmd: true, alt: true}}`). `paint.setFill`, `paint.setStroke`, `stroke.set`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and `transparency.set` take `item` to edit one of them; `run_command appearance.setActiveItem {"index": n}` makes that row the target of later calls that omit `item` (as clicking the row in the Appearance panel does) until the selection changes; the proxy of diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a8c9be92..08fcc37a4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -512,6 +512,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). + - Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for "Add new fill" and "Add new stroke". Keyboard Shortcuts lists both under Window › Appearance. - Cmd/Ctrl+Alt+Shift+. and , (`type.size.increaseByFive`, `type.size.decreaseByFive`) step the font size of the selected type, or of the text the Type tool has selected, by five times the Size/Leading increment (Preferences › Type); Cmd/Ctrl+Shift+. and , still step it by one increment. - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and its kerning to Auto (0 in the Character panel), in one undo step; at a caret it resets the kerning of the character before it. - Cmd/Ctrl+Shift+X (`type.resetHorizontalScale`) sets the horizontal scale of the selected type, or of the text the Type tool has selected, to 100% as the Character panel shows it (the object's own stretch included), in one undo step; at a caret it leaves the text as it is. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d366c1021..4605c9cde 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -189,6 +189,9 @@ tools, never checked side by side) isn't captured row by row. ## Keyboard shortcuts +Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the +selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do. + ~110 distinct modified shortcuts on menu commands plus single-key tool shortcuts for the default toolbar (`crates/tools/src/catalog.rs`), a shortcut editor with chords, and saved sets. Not yet diffed item by item against Illustrator's published default shortcut list; estimated ~70% of it. Remaining: the panel shortcuts,