Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions autoload/hotkey_binding.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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 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("_", "")
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
1 change: 1 addition & 0 deletions autoload/hotkey_binding.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://0ai0xppw6nqn
17 changes: 13 additions & 4 deletions docs/keyboard_shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
109 changes: 109 additions & 0 deletions docs/spec-native-hotkey-chords.md
Original file line number Diff line number Diff line change
@@ -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 test --script 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`.
1 change: 1 addition & 0 deletions godot-ndi/.gdignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Legacy duplicate of addons/godot-ndi. Keep it out of Godot's resource scan.
Loading