diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index fea0be8e..75f064f6 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -370,6 +370,39 @@ fn arithmetic_commits_tab_moves_on_and_escape_restores() { assert_eq!(geom(&u, a).0, 78.0); } +#[test] +fn the_save_shortcut_saves_from_a_field_with_what_was_typed() { + // Cmd/Ctrl+S with the keyboard in a panel field saved nothing (#1157). It commits the field + // first (as clicking away does), then saves; the field lets go of the keyboard. + let cmd = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + let (mut u, a, _) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-save-from-field-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.figcraft"); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + for (shortcut, mods) in [("Cmd/Ctrl+S", cmd), ("Shift+Cmd/Ctrl+S on a file with a path", cmd.plus(Modifiers::SHIFT))] { + let (p, _) = field(&mut u, "X"); + click(&mut u, p); + assert!(crate::a11y::field_focused(&u.ctx), "{shortcut}: the field has the keyboard"); + let x = if mods.shift { "66" } else { "77" }; + u.frame(vec![Event::Text(x.into())]); + key(&mut u, Key::S, mods); + u.frame(vec![]); + assert_eq!(geom(&u, a).0, x.parse::().unwrap(), "{shortcut}: what was typed is committed"); + assert!(!crate::a11y::field_focused(&u.ctx), "{shortcut}: the field let go"); + if mods.shift { + // Save as asks where (a dialog this harness doesn't answer): nothing is written yet. + continue; + } + assert!(!u.app.session.ds().unwrap().is_dirty(), "{shortcut}: saved"); + let mut back = Session::new(); + back.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + let b = back.doc().unwrap().world_bounds(a).unwrap(); + assert_eq!(b.x0, 77.0, "{shortcut}: the file on disk has the typed value"); + } + std::fs::remove_dir_all(&dir).ok(); +} + #[test] fn mixed_values_take_edits_relative_to_each_layer() { let (mut u, a, b) = setup(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..3eaadf95 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -631,6 +631,9 @@ pub struct UiState { pub paste_event: bool, /// The window got the focus back since the last V release. pub refocused: bool, + /// A save shortcut pressed while a panel field had the keyboard: the field lets go (which + /// commits what was typed) and the command runs the next frame (`shortcuts::handle`). + pub after_field: Option<&'static str>, /// The modifiers held, as of the last modifier change in the raw events: a paste event doesn't /// carry them. pub held: egui::Modifiers, @@ -748,6 +751,7 @@ impl Default for UiState { v_pressed: false, paste_event: false, refocused: false, + after_field: None, held: egui::Modifiers::NONE, next_autosave: 0.0, auto_save_at: None, diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c86cfdf1..6ce75cbb 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -97,8 +97,16 @@ fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str { /// `swallowed`: what this frame's Cmd/Ctrl+V chords the shell swallowed ask for ([`swallowed_paste`]). pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowed) { note_platform(ctx); - // Text fields in panels own the keyboard (also the key that ends their editing). + // A save asked for from a field last frame: the field has committed by now. + if let Some(id) = app.ui.after_field.take() { + run_binding(app, id); + } + // Text fields in panels own the keyboard (also the key that ends their editing), except for + // the save shortcuts. if typing || crate::a11y::field_focused(ctx) || app.ui.palette.is_some() { + if app.ui.palette.is_none() { + save_from_field(app, ctx); + } return; } let events: Vec = ctx.input(|i| i.events.clone()); @@ -137,6 +145,33 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowe } } +/// Commands whose shortcut works while a panel field has the keyboard: they don't edit text, and +/// a file that can't be saved from a field looks like a file that can't be saved (#1157). +const FROM_A_FIELD: [&str; 2] = ["file.save", "file.saveAs"]; + +/// Cmd/Ctrl+S (or Shift+Cmd/Ctrl+S) pressed in a panel field: the field lets go of the keyboard, +/// which commits what was typed as clicking away does, and the save runs next frame, after that +/// commit. +fn save_from_field(app: &mut FigApp, ctx: &Context) { + let id = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + Event::Key { key, physical_key, pressed: true, repeat: false, modifiers } if modifiers.command => { + lookup(keymap::Context::Canvas, *key, *physical_key, *modifiers).map(|b| b.id).filter(|id| FROM_A_FIELD.contains(id)) + } + _ => None, + }) + }); + if let Some(id) = id { + ctx.memory_mut(|m| { + if let Some(f) = m.focused() { + m.surrender_focus(f); + } + }); + app.ui.after_field = Some(id); + ctx.request_repaint(); + } +} + /// The command Cmd/Ctrl+V runs with `m` held too: `edit.paste`, `edit.pasteOver` (Shift), /// `edit.pasteProperties` (Alt), or `None` for a chord nothing is bound to. The keymap decides, /// whether the key press arrived (browser) or only a paste event and the release (desktop).