Panels: a second press of a panel's function key hides the panel

The function key shortcuts for Brushes, Color, Layers, Info, Gradient,
Stroke, Attributes, Graphic Styles, Appearance, Align, Transform,
Pathfinder, Transparency and Symbols now run window.panel with
toggle: true, as the Window menu items do. A second press hides an
active panel in the expanded dock, and the next press restores it in
its group. A chord the user assigns to one of these panels toggles the
same way. Plain window.panel calls still only show the panel.
This commit is contained in:
dotCooCoo
2026-10-31 02:52:25 +07:00
committed by bflatastic
parent d792557bbe
commit a535d7b890
5 changed files with 177 additions and 5 deletions
+2 -2
View File
@@ -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<Result<Value, String>> {
// 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) {
+118
View File
@@ -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();
}
+32 -3
View File
@@ -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));
}
}
+24
View File
@@ -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::Event>) -> egui::FullOutput {
let ctx = egui::Context::default();
+1
View File
@@ -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).