From 84de9a21c10cb5fcf11ecbabb93fc10464bc8644 Mon Sep 17 00:00:00 2001 From: Breno Alves Date: Mon, 13 Jul 2026 19:59:05 -0300 Subject: [PATCH 1/5] docs: specify native hotkey chords --- docs/spec-native-hotkey-chords.md | 109 ++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/spec-native-hotkey-chords.md diff --git a/docs/spec-native-hotkey-chords.md b/docs/spec-native-hotkey-chords.md new file mode 100644 index 0000000..5d7a7fb --- /dev/null +++ b/docs/spec-native-hotkey-chords.md @@ -0,0 +1,109 @@ +# Spec: Native hotkey chords + +## Objective + +Add native configurable hotkey combinations to PixelLab Studio so a user can +bind actions such as `Shift+2` without AutoHotkey or other external software. +The feature covers costume switching, per-layer visibility toggles, and +key-triggered animation clips because all three use the same background input +pipeline. + +Acceptance behaviour: + +- A binding contains zero or more modifiers (`Ctrl`, `Alt`, `Shift`, `Meta`) + and exactly one primary keyboard key. +- Matching is exact for modifiers: `2` and `Shift+2` are distinct bindings. +- Other held non-modifier keys are ignored, so holding `W` in a game does not + prevent `Shift+2` from firing. +- A binding fires once when it becomes active and can fire again after release. +- Existing single-key strings (`"1"`, `"F13"`, and similar) remain valid. +- Binding capture waits for a primary key instead of saving `Shift` immediately. +- Display and persistence use a stable canonical order, for example + `Ctrl+Alt+Shift+2`. +- Background/unfocused input continues to use the existing native + `BackgroundInputCapture` extension. + +## Tech stack + +- Godot 4.6 / GDScript +- Existing native `BackgroundInputCapture` GDExtension +- Existing JSON-backed settings and avatar persistence +- No new runtime dependencies + +## Commands + +From the repository root, with a Godot 4.6 console binary available as +`godot4`: + +```powershell +godot4 --headless --path . --script test/hotkey_binding_test.gd +godot4 --headless --path . --editor --quit +godot4 --headless --path . --export-release "Windows Desktop" build/PixelLabStudio.exe +``` + +The first two commands are required for the feature. The Windows export is run +when the local export templates and native libraries are available. + +## Project structure + +- `autoload/hotkey_binding.gd`: pure canonicalization and matching logic. +- `main_scenes/main.gd`: background key state, capture, edge detection, and + dispatch to costume/visibility/animation consumers. +- `test/hotkey_binding_test.gd`: headless regression tests. +- `docs/keyboard_shortcuts.md`: user-facing chord documentation. + +## Code style + +Follow existing GDScript conventions and keep the matching logic pure: + +```gdscript +var binding := HotkeyBinding.from_pressed(pressed_keys, newly_pressed_keys) +if HotkeyBinding.is_active(binding, pressed_keys): + activated_bindings.append(binding) +``` + +Use tabs for indentation, descriptive camelCase for existing `main.gd` state, +and snake_case inside the new utility to match modern Godot APIs. + +## Testing strategy + +Small headless tests cover: + +- canonical modifier ordering; +- single-key backward compatibility; +- distinction between `2` and `Shift+2`; +- left/right modifier normalization where exposed by Godot key names; +- ignoring unrelated held gameplay keys; +- malformed or modifier-only bindings not activating; +- capture choosing the newly pressed primary key. + +An integration check imports the complete Godot project headlessly. A manual +Windows check should bind `Shift+2`, minimize the app, and verify one costume +change per press while another non-modifier key is held. + +## Boundaries + +- Always: preserve existing saved bindings, use the existing native background + input extension, test the pure logic before integration, and document the UI. +- Ask first: add dependencies, change the native extension, change save-file + schemas, or broaden beyond keyboard chords. +- Never: require AutoHotkey, suppress keys sent to games, install a keyboard + driver, or commit generated exports/native build artifacts. + +## Success criteria + +- `Shift+2` can be captured and displayed natively. +- Pressing `2` alone does not fire a `Shift+2` action, and pressing `Shift+2` + does not fire an action bound to `2`. +- Holding `W` while pressing `Shift+2` still fires `Shift+2` once. +- Costume, visibility, and animation bindings share the same behaviour. +- Old single-key settings load and work unchanged. +- Regression tests pass and the project imports without script errors. + +## Open questions + +- Controller or multi-primary-key chords (for example `Q+E`) are intentionally + out of scope. +- Modifier-only bindings remain readable for backward compatibility but cannot + be newly captured, because capture must wait to distinguish `Shift` from + `Shift+2`. From 62fb1060cd8482d4569a17f9f5f39fef8d74a59c Mon Sep 17 00:00:00 2001 From: Breno Alves Date: Mon, 13 Jul 2026 20:02:15 -0300 Subject: [PATCH 2/5] test: define native hotkey chord behavior --- autoload/hotkey_binding.gd | 100 ++++++++++++++++++++++++++++++++ test/hotkey_binding_test.gd | 112 ++++++++++++++++++++++++++++++++++++ test/project.godot | 12 ++++ 3 files changed, 224 insertions(+) create mode 100644 autoload/hotkey_binding.gd create mode 100644 test/hotkey_binding_test.gd create mode 100644 test/project.godot diff --git a/autoload/hotkey_binding.gd b/autoload/hotkey_binding.gd new file mode 100644 index 0000000..d413e75 --- /dev/null +++ b/autoload/hotkey_binding.gd @@ -0,0 +1,100 @@ +class_name HotkeyBinding +extends RefCounted + +const MODIFIER_ORDER: Array[String] = ["Ctrl", "Alt", "Shift", "Meta"] + + +static func from_pressed(pressed_keys: Array, newly_pressed_keys: Array) -> String: + var primary_keys: Array[String] = [] + for key in newly_pressed_keys: + var normalized := normalize_key_name(str(key)) + if normalized != "" and not is_modifier(normalized) and not primary_keys.has(normalized): + primary_keys.append(normalized) + + if primary_keys.size() != 1: + return "" + + var parts: Array[String] = [] + var normalized_pressed := _normalized_key_set(pressed_keys) + for modifier in MODIFIER_ORDER: + if normalized_pressed.has(modifier): + parts.append(modifier) + parts.append(primary_keys[0]) + return "+".join(parts) + + +static func canonicalize(binding: String) -> String: + var raw := binding.strip_edges() + if raw == "" or raw.to_lower() == "null": + return "" + + var modifiers: Dictionary = {} + var primary_keys: Array[String] = [] + for part in raw.split("+", false): + var normalized := normalize_key_name(part) + if normalized == "": + continue + if is_modifier(normalized): + modifiers[normalized] = true + elif not primary_keys.has(normalized): + primary_keys.append(normalized) + + if primary_keys.size() != 1: + return "" + + var parts: Array[String] = [] + for modifier in MODIFIER_ORDER: + if modifiers.has(modifier): + parts.append(modifier) + parts.append(primary_keys[0]) + return "+".join(parts) + + +static func is_active(binding: String, pressed_keys: Array) -> bool: + var canonical := canonicalize(binding) + if canonical == "": + return false + + var parts := canonical.split("+", false) + var primary := parts[parts.size() - 1] + var required_modifiers: Dictionary = {} + for i in range(parts.size() - 1): + required_modifiers[parts[i]] = true + + var pressed := _normalized_key_set(pressed_keys) + if not pressed.has(primary): + return false + + for modifier in MODIFIER_ORDER: + if pressed.has(modifier) != required_modifiers.has(modifier): + return false + return true + + +static func normalize_key_name(key: String) -> String: + var trimmed := key.strip_edges() + var compact := trimmed.to_lower().replace(" ", "").replace("_", "") + match compact: + "ctrl", "control", "lctrl", "rctrl", "leftctrl", "rightctrl", "leftcontrol", "rightcontrol": + return "Ctrl" + "alt", "lalt", "ralt", "leftalt", "rightalt": + return "Alt" + "shift", "lshift", "rshift", "leftshift", "rightshift": + return "Shift" + "meta", "lmeta", "rmeta", "leftmeta", "rightmeta", "super", "win", "windows", "command", "cmd": + return "Meta" + _: + return trimmed + + +static func is_modifier(key: String) -> bool: + return MODIFIER_ORDER.has(normalize_key_name(key)) + + +static func _normalized_key_set(keys: Array) -> Dictionary: + var normalized: Dictionary = {} + for key in keys: + var name := normalize_key_name(str(key)) + if name != "": + normalized[name] = true + return normalized diff --git a/test/hotkey_binding_test.gd b/test/hotkey_binding_test.gd new file mode 100644 index 0000000..d09a5e4 --- /dev/null +++ b/test/hotkey_binding_test.gd @@ -0,0 +1,112 @@ +extends SceneTree + +const HotkeyBinding = preload("../autoload/hotkey_binding.gd") + +var _failures: Array[String] = [] + + +func _initialize() -> void: + _test_canonical_modifier_order() + _test_capture_uses_new_primary_key() + _test_capture_waits_for_primary_key() + _test_single_key_backward_compatibility() + _test_modifier_matching_is_exact() + _test_unrelated_gameplay_keys_are_ignored() + _test_modifier_aliases_are_normalized() + _test_invalid_bindings_do_not_activate() + + if _failures.is_empty(): + print("hotkey_binding_test: all tests passed") + quit(0) + return + + for failure in _failures: + push_error(failure) + quit(1) + + +func _test_canonical_modifier_order() -> void: + _assert_equal( + HotkeyBinding.from_pressed(["2", "Shift", "Alt", "Ctrl"], ["2"]), + "Ctrl+Alt+Shift+2", + "canonicalizes modifiers in a stable order" + ) + + +func _test_capture_uses_new_primary_key() -> void: + _assert_equal( + HotkeyBinding.from_pressed(["W", "Shift", "2"], ["2"]), + "Shift+2", + "captures the newly pressed primary instead of an already-held game key" + ) + + +func _test_capture_waits_for_primary_key() -> void: + _assert_equal( + HotkeyBinding.from_pressed(["Shift"], ["Shift"]), + "", + "does not finish capture when only a modifier is pressed" + ) + + +func _test_single_key_backward_compatibility() -> void: + _assert_true( + HotkeyBinding.is_active("2", ["W", "2"]), + "old single-key bindings still activate" + ) + _assert_false( + HotkeyBinding.is_active("2", ["Shift", "2"]), + "a plain binding does not activate with an extra modifier" + ) + + +func _test_modifier_matching_is_exact() -> void: + _assert_true( + HotkeyBinding.is_active("Shift+2", ["Shift", "2"]), + "a chord activates when its modifier and primary are held" + ) + _assert_false( + HotkeyBinding.is_active("Shift+2", ["2"]), + "a chord does not activate without its modifier" + ) + _assert_false( + HotkeyBinding.is_active("Shift+2", ["Ctrl", "Shift", "2"]), + "a chord does not activate with an additional modifier" + ) + + +func _test_unrelated_gameplay_keys_are_ignored() -> void: + _assert_true( + HotkeyBinding.is_active("Shift+2", ["W", "A", "Shift", "2"]), + "unrelated held non-modifier keys do not block a chord" + ) + + +func _test_modifier_aliases_are_normalized() -> void: + _assert_equal( + HotkeyBinding.canonicalize("Shift+Control+Meta+2"), + "Ctrl+Shift+Meta+2", + "normalizes modifier aliases" + ) + + +func _test_invalid_bindings_do_not_activate() -> void: + _assert_false(HotkeyBinding.is_active("", ["2"]), "empty bindings stay inactive") + _assert_false(HotkeyBinding.is_active("null", ["2"]), "deleted bindings stay inactive") + _assert_false(HotkeyBinding.is_active("Shift", ["Shift"]), "modifier-only bindings stay inactive") + _assert_false(HotkeyBinding.is_active("Q+E", ["Q", "E"]), "multi-primary chords stay inactive") + + +func _assert_equal(actual: Variant, expected: Variant, message: String) -> void: + if actual != expected: + _failures.append("%s: expected %s, got %s" % [message, expected, actual]) + + +func _assert_true(actual: bool, message: String) -> void: + if not actual: + _failures.append("%s: expected true" % message) + + +func _assert_false(actual: bool, message: String) -> void: + if actual: + _failures.append("%s: expected false" % message) diff --git a/test/project.godot b/test/project.godot new file mode 100644 index 0000000..9c6edb3 --- /dev/null +++ b/test/project.godot @@ -0,0 +1,12 @@ +[application] + +config/name="PixelLab Studio Hotkey Tests" + +[display] + +window/size/viewport_width=640 +window/size/viewport_height=360 + +[rendering] + +renderer/rendering_method="gl_compatibility" From 85bcd57726a1f05af1d903d0e3739cc818d5562c Mon Sep 17 00:00:00 2001 From: Breno Alves Date: Mon, 13 Jul 2026 20:12:33 -0300 Subject: [PATCH 3/5] feat: add native modifier hotkey chords --- autoload/hotkey_binding.gd | 22 +++++ autoload/hotkey_binding.gd.uid | 1 + docs/keyboard_shortcuts.md | 17 +++- docs/spec-native-hotkey-chords.md | 2 +- main_scenes/main.gd | 118 ++++++++++++++++++----- test/hotkey_binding_test.gd | 36 +++++++ ui_scenes/selectedSprite/spriteObject.gd | 10 +- 7 files changed, 174 insertions(+), 32 deletions(-) create mode 100644 autoload/hotkey_binding.gd.uid diff --git a/autoload/hotkey_binding.gd b/autoload/hotkey_binding.gd index d413e75..3eb2bb1 100644 --- a/autoload/hotkey_binding.gd +++ b/autoload/hotkey_binding.gd @@ -71,6 +71,28 @@ static func is_active(binding: String, pressed_keys: Array) -> bool: return true +static func newly_activated( + bindings: Array, + pressed_keys: Array, + newly_pressed_keys: Array, + previously_active: Dictionary +) -> Dictionary: + var active: Dictionary = {} + var activated: Array[String] = [] + var newly_pressed := _normalized_key_set(newly_pressed_keys) + for binding in bindings: + var canonical := canonicalize(str(binding)) + if canonical == "" or active.has(canonical): + continue + if is_active(canonical, pressed_keys): + active[canonical] = true + var parts := canonical.split("+", false) + var primary := parts[parts.size() - 1] + if newly_pressed.has(primary) and not previously_active.has(canonical): + activated.append(canonical) + return {"active": active, "activated": activated} + + static func normalize_key_name(key: String) -> String: var trimmed := key.strip_edges() var compact := trimmed.to_lower().replace(" ", "").replace("_", "") diff --git a/autoload/hotkey_binding.gd.uid b/autoload/hotkey_binding.gd.uid new file mode 100644 index 0000000..b6f359d --- /dev/null +++ b/autoload/hotkey_binding.gd.uid @@ -0,0 +1 @@ +uid://0ai0xppw6nqn diff --git a/docs/keyboard_shortcuts.md b/docs/keyboard_shortcuts.md index 67593b2..cf55df1 100644 --- a/docs/keyboard_shortcuts.md +++ b/docs/keyboard_shortcuts.md @@ -98,6 +98,14 @@ between the 10 costume slots. Each slot can be re-bound from **Settings → Costume hotkeys**, and individual costume hotkeys can be disabled if you want to free up a key. +Configurable keyboard bindings support native modifier chords. Hold any +combination of **Ctrl**, **Alt**, **Shift**, or **Meta/Windows**, then press one +primary key; for example, hold Shift and press 2 to save `Shift+2`. Modifier +matching is exact, so `2` and `Shift+2` can be assigned to different actions. +Other non-modifier keys already being held (such as W while playing a game) do +not block the chord. The same chord support applies to per-sprite visibility +toggles and key-triggered animation clips. + | Default key | Costume | |---|---| | `1` | Costume 1 | @@ -159,7 +167,8 @@ section. Each entry binds an action name (e.g., `undo`, `screenshot`, entries. To remap globally, edit the relevant entry's `physical_keycode` in `project.godot` and rebuild the app. -Costume keys and per-sprite visibility toggles aren't in the input map; -they're stored in `Saving.settings["costumeKeys"]` and each sprite's -`toggle` property, respectively, and are user-editable at runtime via -the settings menu and the right sidebar. +Costume keys, per-sprite visibility toggles, and animation trigger keys aren't +in the input map. They are stored as canonical binding strings in +`Saving.settings["costumeKeys"]`, each sprite's `toggle` property, and the +animation clip's `key` field, respectively. Existing single-key strings remain +compatible. diff --git a/docs/spec-native-hotkey-chords.md b/docs/spec-native-hotkey-chords.md index 5d7a7fb..88b5f66 100644 --- a/docs/spec-native-hotkey-chords.md +++ b/docs/spec-native-hotkey-chords.md @@ -36,7 +36,7 @@ From the repository root, with a Godot 4.6 console binary available as `godot4`: ```powershell -godot4 --headless --path . --script test/hotkey_binding_test.gd +godot4 --headless --path test --script hotkey_binding_test.gd godot4 --headless --path . --editor --quit godot4 --headless --path . --export-release "Windows Desktop" build/PixelLabStudio.exe ``` diff --git a/main_scenes/main.gd b/main_scenes/main.gd index 450e407..e2e4257 100644 --- a/main_scenes/main.gd +++ b/main_scenes/main.gd @@ -1,5 +1,7 @@ extends Node2D +const HotkeyBindingUtil = preload("res://autoload/hotkey_binding.gd") + var editMode = true #Node Reference @@ -114,6 +116,9 @@ signal pressedKey var costumeKeys = ["1","2","3","4","5","6","7","8","9","0"] signal spriteVisToggles(keysPressed:Array) signal fatfuckingballs +var _backgroundKeysDown: Array[String] = [] +var _activeHotkeyBindings: Dictionary = {} +var _capturedToggleBindingThisEvent := "" func _ready(): Global.main = self @@ -2802,54 +2807,76 @@ func _on_settings_buttons_pressed(): func _on_background_input_capture_bg_key_pressed(node, keys_pressed): - if Global._z_input_active: - return - var keyStrings = [] - - for i in keys_pressed: - if keys_pressed[i]: - keyStrings.append(OS.get_keycode_string(i) if !OS.get_keycode_string(i).strip_edges().is_empty() else "Keycode" + str(i)) + var keyStrings := _background_key_strings(keys_pressed) + var newlyPressed := _newly_pressed_background_keys(keyStrings) + _backgroundKeysDown = keyStrings.duplicate() - if fileSystemOpen: + if Global._z_input_active or fileSystemOpen: + _activeHotkeyBindings.clear() return if keyStrings.size() <= 0: + _activeHotkeyBindings.clear() emit_signal("emptiedCapture") return + if _capturedToggleBindingThisEvent != "": + _activeHotkeyBindings[_capturedToggleBindingThisEvent] = true + _capturedToggleBindingThisEvent = "" + return + if Global.awaitingToggleBind: + return + + var transition := HotkeyBindingUtil.newly_activated( + _configured_hotkey_bindings(), keyStrings, newlyPressed, _activeHotkeyBindings + ) + _activeHotkeyBindings = transition["active"] + + var capturedBinding := HotkeyBindingUtil.from_pressed(keyStrings, newlyPressed) + # Animation tab "Bind key": capture the next key into the target clip instead # of triggering anything. if Global.awaitingAnimKeyBind and Global.animKeyBindClip != null: - Global.animKeyBindClip["key"] = keyStrings[0] + if capturedBinding == "": + return + Global.animKeyBindClip["key"] = capturedBinding Global.awaitingAnimKeyBind = false Global.animKeyBindClip = null + _activeHotkeyBindings[capturedBinding] = true return if settingsMenu.awaitingCostumeInput >= 0: - - if keyStrings[0] == "Keycode1": + if capturedBinding == "": + return + if capturedBinding == "Keycode1": if !settingsMenu.hasMouse: emit_signal("pressedKey") return var currentButton = costumeKeys[settingsMenu.awaitingCostumeInput] - costumeKeys[settingsMenu.awaitingCostumeInput] = keyStrings[0] + costumeKeys[settingsMenu.awaitingCostumeInput] = capturedBinding Saving.settings["costumeKeys"] = costumeKeys - Global.pushUpdate("Changed costume " + str(settingsMenu.awaitingCostumeInput+1) + " hotkey from \"" + currentButton + "\" to \"" + keyStrings[0] + "\"") + Global.pushUpdate("Changed costume " + str(settingsMenu.awaitingCostumeInput+1) + " hotkey from \"" + currentButton + "\" to \"" + capturedBinding + "\"") + _activeHotkeyBindings[capturedBinding] = true emit_signal("pressedKey") + return - for key in keyStrings: - var i = costumeKeys.find(key) + var activatedBindings: Array = transition["activated"] + for binding in activatedBindings: + var i := _costume_index_for_binding(binding) if i >= 0: changeCostume(i+1) + if not Global.awaitingToggleBind and not activatedBindings.is_empty(): + spriteVisToggles.emit(activatedBindings) + # Animation key triggers — fire every layer's key-bound clips. Skipped while # binding a costume key or typing into a text field. - if settingsMenu.awaitingCostumeInput < 0 and not Global._is_any_field_focused(): - for key in keyStrings: + if not Global._is_any_field_focused(): + for binding in activatedBindings: for s in get_tree().get_nodes_in_group("saved"): if s.type == "sprite": - s.triggerAnimationKey(key) + s.triggerAnimationKey(binding) @@ -2858,17 +2885,58 @@ func bgInputSprite(node, keys_pressed): return if fileSystemOpen: return - var keyStrings = [] - - for i in keys_pressed: - if keys_pressed[i]: - keyStrings.append(OS.get_keycode_string(i) if !OS.get_keycode_string(i).strip_edges().is_empty() else "Keycode" + str(i)) + var keyStrings := _background_key_strings(keys_pressed) if keyStrings.size() <= 0: emit_signal("fatfuckingballs") return - - spriteVisToggles.emit(keyStrings) + if not Global.awaitingToggleBind: + return + + var capturedBinding := HotkeyBindingUtil.from_pressed( + keyStrings, _newly_pressed_background_keys(keyStrings) + ) + if capturedBinding == "": + return + _capturedToggleBindingThisEvent = capturedBinding + spriteVisToggles.emit([capturedBinding]) + + +func _background_key_strings(keys_pressed) -> Array[String]: + var keyStrings: Array[String] = [] + for keycode in keys_pressed: + if keys_pressed[keycode]: + var keyString := OS.get_keycode_string(keycode) + keyStrings.append(keyString if not keyString.strip_edges().is_empty() else "Keycode" + str(keycode)) + return keyStrings + + +func _newly_pressed_background_keys(keyStrings: Array[String]) -> Array[String]: + var newlyPressed: Array[String] = [] + for keyString in keyStrings: + if not _backgroundKeysDown.has(keyString): + newlyPressed.append(keyString) + return newlyPressed + + +func _configured_hotkey_bindings() -> Array: + var bindings: Array = costumeKeys.duplicate() + for sprite in get_tree().get_nodes_in_group("saved"): + if sprite.type != "sprite": + continue + bindings.append(sprite.toggle) + for clip in sprite.animClips: + if str(clip.get("trigger", "")) == "key": + bindings.append(str(clip.get("key", ""))) + return bindings + + +func _costume_index_for_binding(binding: String) -> int: + for i in range(costumeKeys.size()): + if HotkeyBindingUtil.canonicalize(str(costumeKeys[i])) == binding: + return i + return -1 + func _on_clear_avatar_pressed(): UndoManager.save_state() diff --git a/test/hotkey_binding_test.gd b/test/hotkey_binding_test.gd index d09a5e4..8c37d5e 100644 --- a/test/hotkey_binding_test.gd +++ b/test/hotkey_binding_test.gd @@ -12,6 +12,8 @@ func _initialize() -> void: _test_single_key_backward_compatibility() _test_modifier_matching_is_exact() _test_unrelated_gameplay_keys_are_ignored() + _test_activation_is_edge_triggered() + _test_releasing_modifier_does_not_activate_plain_binding() _test_modifier_aliases_are_normalized() _test_invalid_bindings_do_not_activate() @@ -82,6 +84,40 @@ func _test_unrelated_gameplay_keys_are_ignored() -> void: ) +func _test_activation_is_edge_triggered() -> void: + var first := HotkeyBinding.newly_activated( + ["2", "Shift+2"], ["W", "Shift", "2"], ["2"], {} + ) + _assert_equal(first["activated"], ["Shift+2"], "activates a chord on its leading edge") + + var repeated := HotkeyBinding.newly_activated( + ["2", "Shift+2"], ["W", "Shift", "2"], [], first["active"] + ) + _assert_equal(repeated["activated"], [], "does not repeat while the chord remains held") + + var released := HotkeyBinding.newly_activated( + ["2", "Shift+2"], ["W"], [], repeated["active"] + ) + var pressed_again := HotkeyBinding.newly_activated( + ["2", "Shift+2"], ["W", "Shift", "2"], ["Shift", "2"], released["active"] + ) + _assert_equal(pressed_again["activated"], ["Shift+2"], "activates again after release") + + +func _test_releasing_modifier_does_not_activate_plain_binding() -> void: + var chord := HotkeyBinding.newly_activated( + ["2", "Shift+2"], ["Shift", "2"], ["2"], {} + ) + var shift_released := HotkeyBinding.newly_activated( + ["2", "Shift+2"], ["2"], [], chord["active"] + ) + _assert_equal( + shift_released["activated"], + [], + "releasing a modifier while the primary is held does not trigger another binding" + ) + + func _test_modifier_aliases_are_normalized() -> void: _assert_equal( HotkeyBinding.canonicalize("Shift+Control+Meta+2"), diff --git a/ui_scenes/selectedSprite/spriteObject.gd b/ui_scenes/selectedSprite/spriteObject.gd index c0e61e6..d0c7833 100644 --- a/ui_scenes/selectedSprite/spriteObject.gd +++ b/ui_scenes/selectedSprite/spriteObject.gd @@ -1,5 +1,7 @@ extends Node2D +const HotkeyBindingUtil = preload("res://autoload/hotkey_binding.gd") + # talkBlink() looks up whether a (showOnTalk + 3*blinkVal + 10*speaking + 20*blink) # combination should be visible. The values mirror the original literal # [0,10,20,30,1,21,12,32,3,13,4,15,26,36,27,38].has(int(value)) — moving them @@ -1559,8 +1561,12 @@ func getAllDescendants() -> Array: func visToggle(keys): if Global.awaitingToggleBind: return - if keys.has(toggle): - $WobbleOrigin/DragOrigin.visible = !$WobbleOrigin/DragOrigin.visible + var canonicalToggle := HotkeyBindingUtil.canonicalize(toggle) + if canonicalToggle == "": return + for key in keys: + if HotkeyBindingUtil.canonicalize(str(key)) == canonicalToggle: + $WobbleOrigin/DragOrigin.visible = !$WobbleOrigin/DragOrigin.visible + return func makeVis(): $WobbleOrigin/DragOrigin.visible = true From 25b17befda04399a2f0ffe0b6f8267c4c48f644f Mon Sep 17 00:00:00 2001 From: Breno Alves Date: Mon, 13 Jul 2026 20:29:43 -0300 Subject: [PATCH 4/5] fix: ignore duplicate root NDI plugin --- godot-ndi/.gdignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 godot-ndi/.gdignore diff --git a/godot-ndi/.gdignore b/godot-ndi/.gdignore new file mode 100644 index 0000000..fd8746c --- /dev/null +++ b/godot-ndi/.gdignore @@ -0,0 +1 @@ +# Legacy duplicate of addons/godot-ndi. Keep it out of Godot's resource scan. From 801417c87d1bd4fe5c06d9156c735358eb1bdec0 Mon Sep 17 00:00:00 2001 From: Breno Alves Date: Mon, 13 Jul 2026 22:08:33 -0300 Subject: [PATCH 5/5] test: guard native hotkey runtime packaging --- test/hotkey_binding_test.gd | 12 ++++++++++++ tools/native_extension_smoke_test.gd | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tools/native_extension_smoke_test.gd diff --git a/test/hotkey_binding_test.gd b/test/hotkey_binding_test.gd index 8c37d5e..4a62dd2 100644 --- a/test/hotkey_binding_test.gd +++ b/test/hotkey_binding_test.gd @@ -11,6 +11,7 @@ func _initialize() -> void: _test_capture_waits_for_primary_key() _test_single_key_backward_compatibility() _test_modifier_matching_is_exact() + _test_ctrl_shift_number_chord() _test_unrelated_gameplay_keys_are_ignored() _test_activation_is_edge_triggered() _test_releasing_modifier_does_not_activate_plain_binding() @@ -77,6 +78,17 @@ func _test_modifier_matching_is_exact() -> void: ) +func _test_ctrl_shift_number_chord() -> void: + var transition := HotkeyBinding.newly_activated( + ["Ctrl+Shift+1"], ["Ctrl", "Shift", "1"], ["1"], {} + ) + _assert_equal( + transition["activated"], + ["Ctrl+Shift+1"], + "activates the requested Ctrl+Shift+1 costume chord" + ) + + func _test_unrelated_gameplay_keys_are_ignored() -> void: _assert_true( HotkeyBinding.is_active("Shift+2", ["W", "A", "Shift", "2"]), diff --git a/tools/native_extension_smoke_test.gd b/tools/native_extension_smoke_test.gd new file mode 100644 index 0000000..292738b --- /dev/null +++ b/tools/native_extension_smoke_test.gd @@ -0,0 +1,25 @@ +extends MainLoop + +const REQUIRED_NATIVE_CLASSES: Array[String] = [ + "BackgroundInputCapture", + "PSDNative", +] + + +func _initialize() -> void: + var missing: Array[String] = [] + for native_class_name in REQUIRED_NATIVE_CLASSES: + if not ClassDB.class_exists(native_class_name): + missing.append(native_class_name) + + if missing.is_empty(): + print("native_extension_smoke_test: all native classes loaded") + return + + var message := "Missing native classes: %s" % ", ".join(missing) + push_error(message) + assert(missing.is_empty(), message) + + +func _process(_delta: float) -> bool: + return true