diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 339682c94..f31c50c40 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -40,8 +40,8 @@ pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Va /// handles its own way (a Window menu panel item that toggles, …) → `None` to leave it to the /// engine. pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { - // The Window menu's panel items toggle like its other checked items (#2361); the panel's - // other ways in (shortcuts, Show Properties Panel, a tool's double-click) only show it. + // Window menu items and the named panel function keys toggle against the checked state; + // other commands that open panels show them. if id == "window.panel" && p.get("toggle").and_then(Value::as_bool) == Some(true) && checked(app, id, p) == Some(true) { let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default(); if app.ui.open_panel.as_deref() == Some(panel) { diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 4bb77928b..f72a433cf 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -63,6 +63,11 @@ impl Harness { self.frame(vec![]); } + fn press(&mut self, key: egui::Key, modifiers: egui::Modifiers) { + self.frame(vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]); + self.frame(vec![Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }]); + } + /// Press at `from` and move to `to` in steps, holding the button. fn press_and_move(&mut self, from: Pos2, to: Pos2) { self.frame(vec![Event::PointerMoved(from), button(from, true)]); @@ -497,3 +502,116 @@ fn window_menu_panels_without_a_place_open_in_their_icon_group_or_float() { h.frame(vec![]); assert!(h.tab("openType").is_some()); } + +#[test] +fn f7_hides_and_restores_the_expanded_layers_tab() { + let mut h = Harness::new(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers"), "the first press activates the tab"); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!showing(&h.app.ui, "layers"), "a second press hides the active tab"); + assert!(h.app.ui.docking_hidden.contains_key("layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers"), "the next press restores it"); + assert_eq!(main_panels(&h.layout()), ["properties", "layers", "libraries"]); + + h.app.run("window.panel.collapse", json!({"panel": "layers", "collapsed": true})).unwrap(); + let layout = h.layout(); + let group = dock_columns(&layout).iter().flat_map(|c| &c.groups).find(|g| g.panels.contains(&"layers".to_string())).unwrap(); + assert!(group.minimized); + h.press(egui::Key::F7, egui::Modifiers::NONE); + let layout = h.layout(); + let group = dock_columns(&layout).iter().flat_map(|c| &c.groups).find(|g| g.panels.contains(&"layers".to_string())).unwrap(); + assert!(!group.minimized, "the first press expands a collapsed group"); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!showing(&h.app.ui, "layers"), "the following press hides the expanded group"); +} + +#[test] +fn f7_activates_a_background_tab_before_hiding_it() { + let mut h = Harness::new(); + h.app.run("window.panel.move", json!({"panel": "layers", "anchor": "properties"})).unwrap(); + h.app.run("window.panel.activate", json!({"panel": "properties"})).unwrap(); + assert!(!showing(&h.app.ui, "layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!h.layout().contains(&"layers".to_string())); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers")); + assert_eq!(group_of(&h.layout(), &"layers".into()), vec!["properties".to_string(), "libraries".into(), "layers".into()]); +} + +#[test] +fn panel_function_keys_toggle_icon_flyouts_and_floating_groups() { + let mut h = Harness::new(); + h.press(egui::Key::F5, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("brushes")); + h.press(egui::Key::F5, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel, None); + h.press(egui::Key::F5, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("brushes")); + + h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap(); + h.frame(vec![]); + let rect = floating_window(&h.layout(), "layers").unwrap().rect; + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(floating_window(&h.layout(), "layers").is_none()); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(floating_window(&h.layout(), "layers").unwrap().rect, rect); + h.app.run("window.panel.dock", json!({"panel": "layers"})).unwrap(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!showing(&h.app.ui, "layers"), "the expanded dock also toggles"); +} + +#[test] +fn other_function_keys_and_keyboard_guards_keep_their_routes() { + let mut h = Harness::new(); + h.app.run("window.panel", json!({"panel": "layers"})).unwrap(); + let keymap = crate::shortcut_editor::current(); + for (chord, command) in [("F8", "ui.symbolOptions"), ("F12", "file.revert")] { + let bound = crate::keymap::vc_chord(chord).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c)).unwrap(); + assert_eq!(bound.id, command); + } + h.app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + h.press(egui::Key::F8, egui::Modifiers::NONE); + assert_eq!(h.app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("symbolOptions")); + crate::dialogs::confirm(&mut h.app).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc.symbols.len(), 1); + crate::menus::invoke(&mut h.app, "view.rotate", json!({})); + assert!(h.app.ui.dialog.is_some()); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers"), "F7 does nothing while a dialog is open"); + h.app.ui.dialog = None; + + let field = egui::Id::unique("panel-shortcut-text-focus"); + h.ctx.memory_mut(|m| m.request_focus(field)); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers"), "F7 does nothing while a text field has focus"); + h.ctx.memory_mut(|m| m.surrender_focus(field)); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!showing(&h.app.ui, "layers"), "F7 toggles when the field releases focus"); + + let dir = vectorcraft_testkit::temp_dir("panel-shortcut-revert"); + let path = dir.join(format!("panel-shortcut-revert-{}.vectorcraft", std::process::id())); + let services = crate::Services { + read: Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))), + write: Some(Box::new(|p, data| std::fs::write(p, data).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut saved = VectorcraftApp::new(Session::new(), services); + saved.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + saved.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + saved.run("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + saved.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap(); + assert_eq!(saved.session.doc().unwrap().doc.node_count(), 3); + let ctx = egui::Context::default(); + let key = Event::Key { key: egui::Key::F12, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + ctx.run_ui(egui::RawInput { events: vec![key], ..Default::default() }, |ui| crate::shortcuts::handle(&mut saved, ui.ctx())) + .textures_delta + .clear(); + assert_eq!(saved.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::confirm::KIND)); + crate::dialogs::confirm(&mut saved).unwrap(); + assert_eq!(saved.session.doc().unwrap().doc.node_count(), 2, "F12 reloads the saved document"); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 9f3a70515..5724f4d41 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -381,7 +381,7 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { None if hit.params.is_null() => { let _ = app.run(&hit.id, json!({})); } - None => crate::menus::invoke(app, &hit.id, hit.params.clone()), + None => crate::menus::invoke(app, &hit.id, shortcut_params(hit)), } } } @@ -729,6 +729,36 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&H }) } +/// Params used when a shortcut invokes its command. The named panel function keys retain their +/// toggle action when their bindings are reassigned. +fn shortcut_params(c: &CommandRef) -> serde_json::Value { + let mut params = if c.params.is_null() { json!({}) } else { c.params.clone() }; + if c.id == "window.panel" + && matches!( + params.get("panel").and_then(serde_json::Value::as_str), + Some( + "brushes" + | "color" + | "layers" + | "info" + | "gradient" + | "stroke" + | "attributes" + | "graphicStyles" + | "appearance" + | "align" + | "transform" + | "pathfinder" + | "transparency" + | "symbols" + ) + ) + { + params["toggle"] = json!(true); + } + params +} + /// Runs what a shortcut is bound to: a tool, a panel (`window.panel {panel}`) or a command. fn run_hit(app: &mut VectorcraftApp, ctx: &egui::Context, c: &CommandRef) { if let Some(tool) = c.id.strip_prefix("tool:") { @@ -738,8 +768,7 @@ fn run_hit(app: &mut VectorcraftApp, ctx: &egui::Context, c: &CommandRef) { // every artboard instead of the art. crate::panels::artboards::select_all(app); } else { - let p = if c.params.is_null() { json!({}) } else { c.params.clone() }; - crate::menus::invoke(app, &c.id, p); + crate::menus::invoke(app, &c.id, shortcut_params(c)); } } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 62686d34d..703d28f3f 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1,5 +1,29 @@ use super::*; +#[test] +fn rebound_layers_chord_toggles_and_plain_panel_command_reveals() { + let mut overrides = craft_keymap::Overrides::new(); + crate::keymap::set_override(&mut overrides, "panel:layers", "Cmd+Alt+Shift+F7"); + let keymap = crate::keymap::keymap_with(&overrides); + let old = crate::keymap::vc_chord("F7").unwrap(); + assert!(crate::keymap::lookup(&keymap, &old).is_none(), "the old F7 is free"); + let rebound = crate::keymap::vc_chord("Cmd+Alt+Shift+F7").unwrap(); + let hit = crate::keymap::lookup(&keymap, &rebound).unwrap(); + assert_eq!(hit.id, "window.panel"); + assert_eq!(hit.params["panel"], "layers"); + + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("window.panel", json!({"panel": "layers"})).unwrap(); + let ctx = egui::Context::default(); + run_hit(&mut app, &ctx, &hit); + assert_eq!(crate::menus::checked(&app, "window.panel", &json!({"panel": "layers"})), Some(false)); + run_hit(&mut app, &ctx, &hit); + assert_eq!(crate::menus::checked(&app, "window.panel", &json!({"panel": "layers"})), Some(true)); + app.run("window.panel", json!({"panel": "layers"})).unwrap(); + assert_eq!(crate::menus::checked(&app, "window.panel", &json!({"panel": "layers"})), Some(true), "a plain command still reveals"); +} + /// One headless frame delivering `events` to the shortcut handler. fn frame(app: &mut VectorcraftApp, events: Vec) -> egui::FullOutput { let ctx = egui::Context::default(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cbf1e4590..4172d15fe 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -611,6 +611,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields. - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - 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. + - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols hide an active panel on a second press and restore it on the next press, using the same visibility state as the Window menu. A reassigned chord keeps that behavior. - 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). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. - 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).