diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 339682c94..8eff1fc12 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -40,14 +40,17 @@ 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) { app.ui.open_panel = None; return Some(Ok(json!({"open": null}))); } + if p.get("toggleGroup").and_then(Value::as_bool) == Some(true) { + return Some(crate::panel_docking::hide_group(app, panel)); + } return Some(app.run("window.panel.close", json!({"panel": panel}))); } if let Some(result) = crate::panel_docking::command(app, id, p) { diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index dfedad31d..02a95cfd5 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -337,6 +337,9 @@ fn show_panel(app: &mut VectorcraftApp, params: &Value) -> Result if !layout.contains(&panel.to_string()) && !app.ui.docking_hidden.contains_key(panel) && crate::panels::library_panel::parse(panel).is_some() { return crate::panels::library_panel::float(app, panel); } + if app.ui.docking_hidden.contains_key(panel) { + return restore_hidden_group(app, panel); + } let value = shared_command(app, "window.panel.activate", &json!({"panel": panel}))?; let layout = layout_of(&app.ui); Ok(match floating_window(&layout, panel) { @@ -345,6 +348,37 @@ fn show_panel(app: &mut VectorcraftApp, params: &Value) -> Result }) } +/// Hides the group holding an active panel and records each tab's return location. +pub(crate) fn hide_group(app: &mut VectorcraftApp, panel: &str) -> Result { + let mut txn = Transaction::begin(app)?; + let panel = panel.to_string(); + let closed = txn.apply(Action::CloseGroup { panel })?; + let group = closed.clone(); + let result = txn.commit(app, closed, None); + if !group.is_empty() { + app.ui.docking_hidden_groups.push(group); + } + Ok(result) +} + +/// Reopens the tabs hidden together with `panel` and brings it to the front. +fn restore_hidden_group(app: &mut VectorcraftApp, panel: &str) -> Result { + let mut txn = Transaction::begin(app)?; + let members = + app.ui.docking_hidden_groups.iter().find(|group| group.iter().any(|id| id == panel)).cloned().unwrap_or_else(|| vec![panel.to_string()]); + for member in members { + if !txn.workspace.hidden.iter().any(|(id, _)| id == &member) { + continue; + } + let fallback = first_dock_panel(&txn.workspace.layout, &member); + txn.workspace.set_visible(member, true, fallback, model_permissions).map_err(|e| e.to_string())?; + } + txn.apply(Action::Activate { panel: panel.to_string() })?; + let result = txn.commit(app, Vec::new(), Some(panel.to_string())); + app.ui.docking_hidden_groups.retain(|group| !group.iter().any(|id| id == panel)); + Ok(result) +} + /// Show `panel` as `window.panel` does, but leave it be when it already shows ([`showing`]): a /// command that opens a panel to work in (Edit › Color Settings…) never puts it away. pub(crate) fn reveal_panel(app: &mut VectorcraftApp, panel: &str) -> Result { @@ -927,6 +961,10 @@ impl Transaction { } } let reveal = reveal.filter(|panel| layout.contains(panel)); + for group in &mut app.ui.docking_hidden_groups { + group.retain(|panel| hidden.contains_key(panel) && !layout.contains(panel)); + } + app.ui.docking_hidden_groups.retain(|group| !group.is_empty()); let flyout = reveal.as_ref().filter(|panel| in_iconic_column(&layout, panel)).cloned(); match (app.ui.docking.is_none() && hidden.is_empty()).then(|| default_flags(&app.ui, &layout)).flatten() { Some((tab, collapsed, width)) => { diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 4bb77928b..2bae524dd 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,201 @@ 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(); + let before = group_of(&h.layout(), &"layers".into()); + 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!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string()))); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers")); + assert_eq!(group_of(&h.layout(), &"layers".into()), before); +} + +#[test] +fn f7_hides_and_restores_the_whole_expanded_group() { + let mut h = Harness::new(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string()))); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]); + assert!(showing(&h.app.ui, "layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + h.app.run("window.panel", json!({"panel": "properties", "toggle": true})).unwrap(); + assert_eq!(group_of(&h.layout(), &"properties".into()), ["properties", "layers", "libraries"]); + assert!(showing(&h.app.ui, "properties")); +} + +#[test] +fn f7_restores_only_tabs_hidden_with_the_group() { + let mut h = Harness::new(); + h.app.run("window.panel.close", json!({"panel": "libraries"})).unwrap(); + assert!(h.app.ui.docking_hidden.contains_key("libraries")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(["properties", "layers"].iter().all(|panel| h.app.ui.docking_hidden.contains_key(*panel))); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers"]); + assert!(!h.layout().contains(&"libraries".to_string())); + assert!(showing(&h.app.ui, "layers")); +} + +#[test] +fn independently_reopened_tab_is_not_restored_by_the_group_key() { + let mut h = Harness::new(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(["properties", "layers", "libraries"].iter().all(|panel| h.app.ui.docking_hidden.contains_key(*panel))); + assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string()))); + h.app.run("window.panel.activate", json!({"panel": "libraries"})).unwrap(); + assert!(h.layout().contains(&"libraries".to_string())); + assert!(!h.app.ui.docking_hidden.contains_key("libraries")); + assert!(["properties", "layers"].iter().all(|panel| h.app.ui.docking_hidden.contains_key(*panel))); + h.app.run("window.panel.close", json!({"panel": "libraries"})).unwrap(); + assert!(h.app.ui.docking_hidden.contains_key("libraries")); + assert!(!h.layout().contains(&"libraries".to_string())); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers"]); + assert!(!h.layout().contains(&"libraries".to_string())); + assert!(showing(&h.app.ui, "layers")); +} + +#[test] +fn f7_hides_and_restores_a_floating_tab_group() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap(); + h.app.run("window.panel.activate", json!({"panel": "layers"})).unwrap(); + h.frame(vec![]); + let before = h.layout(); + let window = floating_window(&before, "layers").unwrap(); + let rect = window.rect; + assert_eq!(group_of(&before, &"layers".into()), ["properties", "layers", "libraries"]); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(floating_window(&h.layout(), "layers").is_none()); + assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string()))); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(floating_window(&h.layout(), "layers").unwrap().rect, rect); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]); + assert!(showing(&h.app.ui, "layers")); +} + +#[test] +fn f7_toggles_a_multi_tab_icon_flyout() { + let mut h = Harness::new(); + h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap(); + h.frame(vec![]); + assert!(in_iconic_column(&h.layout(), "layers")); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel, None); + assert!(in_iconic_column(&h.layout(), "layers")); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]); +} + +#[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/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 409020e3b..8fba3604d 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -381,6 +381,13 @@ fn rename(ui: &mut UiState, from: &str, to: &str) -> Result<(), String> { } ui.docking = docking; ui.docking_hidden = hidden; + for group in &mut ui.docking_hidden_groups { + for panel in group { + if panel == from { + *panel = to.to_string(); + } + } + } if ui.open_panel.as_deref() == Some(from) { ui.open_panel = Some(to.into()); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 9f3a70515..00249145d 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,37 @@ 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["toggleGroup"] = 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 +769,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..a8f9b08c1 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1,5 +1,67 @@ use super::*; +#[test] +fn named_panel_default_chords_toggle_their_panels() { + let keymap = crate::keymap::keymap_with(&craft_keymap::Overrides::new()); + for (panel, chord) in [ + ("brushes", "F5"), + ("color", "F6"), + ("layers", "F7"), + ("graphicStyles", "Shift+F5"), + ("appearance", "Shift+F6"), + ("align", "Shift+F7"), + ("transform", "Shift+F8"), + ("info", "Cmd+F8"), + ("gradient", "Cmd+F9"), + ("pathfinder", "Cmd+Shift+F9"), + ("stroke", "Cmd+F10"), + ("transparency", "Cmd+Shift+F10"), + ("attributes", "Cmd+F11"), + ("symbols", "Cmd+Shift+F11"), + ] { + let chord = crate::keymap::vc_chord(chord).unwrap(); + let hit = crate::keymap::lookup(&keymap, &chord).unwrap(); + assert_eq!(hit.id, "window.panel"); + assert_eq!(hit.params["panel"], panel); + assert_eq!(shortcut_params(&hit)["toggle"], true); + assert_eq!(shortcut_params(&hit)["toggleGroup"], true); + } +} + +#[test] +fn other_panel_shortcuts_keep_plain_panel_params() { + for panel in ["character", "swatches"] { + let hit = CommandRef { id: "window.panel".into(), params: json!({"panel": panel}) }; + let params = shortcut_params(&hit); + assert!(params.get("toggle").is_none(), "{panel} gained toggle"); + assert!(params.get("toggleGroup").is_none(), "{panel} gained toggleGroup"); + } +} + +#[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/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 72a8be32f..c85f6e668 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -382,6 +382,9 @@ pub struct UiState { pub docking: Option>, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub docking_hidden: std::collections::BTreeMap>, + /// Tabs closed together by panel shortcuts, kept with their workspace return locations. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub docking_hidden_groups: Vec>, /// Panels dropped on the icon column, in older files (read only, like [`Self::floating_panels`]). #[serde(default, skip_serializing)] pub docking_icons: std::collections::BTreeSet, @@ -838,6 +841,7 @@ impl Default for UiState { floating_panels: vec![], docking: None, docking_hidden: Default::default(), + docking_hidden_groups: Vec::new(), docking_icons: Default::default(), docking_collapsed: Default::default(), docking_generation: 0, diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index a67bc690c..a35356378 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -43,6 +43,9 @@ pub struct Workspace { pub docking: Option>, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub docking_hidden: std::collections::BTreeMap>, + /// Tabs closed together by a panel shortcut. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub docking_hidden_groups: Vec>, /// Panels dropped on the icon column, in older workspaces (read only). #[serde(default, skip_serializing)] pub docking_icons: std::collections::BTreeSet, @@ -184,6 +187,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { floating_panels: Vec::new(), docking: ui.docking.clone(), docking_hidden: ui.docking_hidden.clone(), + docking_hidden_groups: ui.docking_hidden_groups.clone(), docking_icons: Default::default(), docking_collapsed: Default::default(), toolbar_pos: ui.toolbar_pos, @@ -209,6 +213,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { ui.docking_generation = ui.docking_generation.wrapping_add(1); ui.docking = w.docking.clone().filter(crate::panel_docking::valid); ui.docking_hidden = w.docking_hidden.clone(); + ui.docking_hidden_groups = w.docking_hidden_groups.clone(); ui.toolbar_pos = w.toolbar_pos; ui.dock_width = w.dock_width; crate::state::sanitize_toolbar_pos(&mut ui.toolbar_pos); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cbf1e4590..55ad521f9 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 activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. - 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). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f56514e57..68f5227a1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -248,7 +248,7 @@ selected objects, as the Appearance panel's Add New Fill and Add New Stroke butt ~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. Cmd/Ctrl+Alt+L (`ui.newLayer`) opens Layer Options for a new layer, as an Alt-click on the Layers panel's New Layer button does, and Cmd/Ctrl+L still adds a layer without the dialog. F8 (`ui.symbolOptions`) opens Symbol Options for a new symbol made from the selection, as the Symbols panel's New Symbol button does. Not yet diffed item by item -against Illustrator's published default shortcut list; estimated ~70% of it. Remaining: the panel shortcuts, +against Illustrator's published default shortcut list; estimated ~70% of it. Remaining: other panel shortcuts, the type shortcuts (tracking/kerning/leading/baseline steps with Alt/Shift), and selection shortcuts in the Layers panel. The shortcut editor also lists the commands that are in no menu but run without parameters (Edit › Duplicate, panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1539). It lists each button of the