M7: Cmd/Ctrl+S saves while a panel field has the keyboard (#1157)

With the keyboard in a Design panel field (X, W, a colour…), the
shortcut handler returned before looking at any key, so Cmd/Ctrl+S and
Shift+Cmd/Ctrl+S did nothing: no save, no message, the tab kept its
unsaved dot. Those two now work from a field: the field lets go of the
keyboard, which commits what was typed as clicking away does, and the
save runs the next frame.
This commit is contained in:
Gert Pellin
2026-10-22 21:46:32 +02:00
parent 9ec80bf2c2
commit 8f0982b5bd
3 changed files with 73 additions and 1 deletions
+33
View File
@@ -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::<f64>().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();
+4
View File
@@ -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,
+36 -1
View File
@@ -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<Event> = 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).