git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit64410b25786e40bbf836876e3fee8af516add679
parent8f4e5639b7
authorLucas Galante <[email protected]>
date2026-08-24 12:09
feat: Ctrl+Shift+S = Save As; Shift+letter chords actually match

Save As gets its own Action and the save_document_as chord (Ctrl+Shift+s,
rebindable via input.kdl like the rest).

Wiring it exposed that NO Shift+letter chord could ever have fired: the
matcher compared the event key exactly against the stored one, and with
Shift held xkb delivers the SHIFTED character — "S" against a stored "s".
Ctrl+Shift+Tab never noticed because Named keys aren't shift-transformed.
Character keys now compare case-insensitively. The chord test feeds the
real event shapes (upper with shift, lower without) — its first version fed
lowercase for both and passed against a matcher that could never fire live.

Live-verified in a shadow session with injected keys: Ctrl+Shift+S opens
the chooser; Ctrl+S with a loaded project saves in place and does not.

 src/app.rs      |  4 ++++
 src/main.rs     | 21 +++++++++++++++++++++
 src/shortcut.rs | 21 ++++++++++++++++-----
 3 files changed, 41 insertions(+), 5 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 5ffa1db..8bb2fe0 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2714,6 +2714,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             register("toggle_spreadsheet", "`", Action::ToggleSpreadsheet);
             register("toggle_circular_pane", "Ctrl+d", Action::ToggleCircularPane);
             register("save_document", "Ctrl+s", Action::Save);
+            register("save_document_as", "Ctrl+Shift+s", Action::SaveAs);
             register("next_context", "Ctrl+Tab", Action::NextContext);
             register("previous_context", "Ctrl+Shift+Tab", Action::PrevContext);
         }
@@ -3712,6 +3713,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 self.apply_layout();
                 self.sync_grid_settings();
             }
+            Action::SaveAs => {
+                self.save_file_chooser();
+            }
             Action::Save => {
                 let path_opt = self.loaded_project_path.clone();
                 if let Some(path) = path_opt {
diff --git a/src/main.rs b/src/main.rs
index a74a666..155f6c4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -470,6 +470,27 @@ mod tests {
         assert_eq!(v, Some("survived"), "migration recreated Guides instead of moving it");
     }
 
+    /// Ctrl+S saves in place, Ctrl+Shift+S is Save As — and the Shift must
+    /// actually discriminate: a matcher that ignores modifiers would fire
+    /// plain Save for both.
+    #[test]
+    fn test_save_as_chord() {
+        let mut m = ShortcutManager::new();
+        m.register("Ctrl+s", Action::Save).unwrap();
+        m.register("Ctrl+Shift+s", Action::SaveAs).unwrap();
+        let ctrl = crate::app::ModifiersState { ctrl: true, ..Default::default() };
+        let ctrl_shift = crate::app::ModifiersState { ctrl: true, shift: true, ..Default::default() };
+        // The REAL event shapes: xkb delivers the shifted character when
+        // Shift is held — "S", not "s". The first version of this test fed
+        // lowercase for both and passed against a matcher that could never
+        // fire in practice.
+        let lower = cce_ui::widget::Key::Character("s".into());
+        let upper = cce_ui::widget::Key::Character("S".into());
+        assert_eq!(m.match_action(&ctrl, &lower), Some(Action::Save));
+        assert_eq!(m.match_action(&ctrl_shift, &upper), Some(Action::SaveAs));
+        assert_eq!(m.match_action(&ctrl_shift, &lower), Some(Action::SaveAs));
+    }
+
     #[test]
     fn test_load_default_project() {
         let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("default_project.json");
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 4262855..55bc248 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -13,6 +13,7 @@ pub enum Action {
     ToggleCircularPane,
     DetachCircularWindow,
     Save,
+    SaveAs,
     NextContext,
     PrevContext,
 }
@@ -76,11 +77,21 @@ impl Shortcut {
     }
 
     pub fn matches(&self, mods: &ModifiersState, key: &Key) -> bool {
-        mods.control_key() == self.ctrl
-            && mods.shift_key() == self.shift
-            && mods.alt_key() == self.alt
-            && mods.super_key() == self.logo
-            && key == &self.key
+        if mods.control_key() != self.ctrl
+            || mods.shift_key() != self.shift
+            || mods.alt_key() != self.alt
+            || mods.super_key() != self.logo
+        {
+            return false;
+        }
+        // Character keys compare case-insensitively: with Shift held, xkb
+        // delivers the SHIFTED character ("S"), so an exact match against the
+        // chord's stored "s" made every Shift+letter chord unmatchable —
+        // Ctrl+Shift+Tab never noticed because Named keys aren't shifted.
+        match (key, &self.key) {
+            (Key::Character(a), Key::Character(b)) => a.eq_ignore_ascii_case(b),
+            (a, b) => a == b,
+        }
     }
 }