From d84464dd823d5e114bbb146aca9be1a43fca91dc Mon Sep 17 00:00:00 2001 From: Andrew Hodgson <92012118+DrakonWolfDev@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:36:11 +0100 Subject: [PATCH] Add configurable stratagem direction keys --- AGENTS.md | 4 ++- README.md | 3 +- key_mapping.py | 34 ++++++++++++++++++++++ main.py | 70 ++++++++++++++++++++++++++++++++++++++++++--- test_key_mapping.py | 36 +++++++++++++++++++++++ test_stratagems.py | 18 ++++++++---- 6 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 key_mapping.py create mode 100644 test_key_mapping.py diff --git a/AGENTS.md b/AGENTS.md index 3adf7a6..1d3d510 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,6 +135,7 @@ The plugin has a settings page accessible via **Settings → Plugins → HELLDIV |---------|-------------|---------| | **Key Delay** | Delay between key presses (0.01-0.20 seconds). Increase if stratagems fail. | 0.03s | | **Modifier Key** | Key to open stratagem menu when not in Hero mode. Options: Left/Right Ctrl, Alt, Shift | Left Ctrl | +| **Direction Keys** | Keys used for directional inputs. Options: Arrow keys or WASD. | Arrow keys | | **Hold Modifier Key** | If ON, holds modifier during entire sequence. If OFF, presses/releases modifier then types sequence. | ON | | **Show Labels** | If ON, displays text labels on stratagem buttons. If OFF, shows icons only. | ON | @@ -152,6 +153,7 @@ Settings are managed through StreamController's plugin settings system: ```python DEFAULT_KEY_DELAY = 0.03 # Seconds between key presses DEFAULT_MODIFIER_KEY = "KEY_LEFTCTRL" # evdev key code +DEFAULT_DIRECTION_KEY_LAYOUT = "arrow_keys" # Arrow keys or WASD DEFAULT_HOLD_MODIFIER = True # Hold modifier during sequence DEFAULT_SHOW_LABELS = True # Show text labels on buttons @@ -216,7 +218,7 @@ python test_stratagems.py ### How It Works 1. Focus the window -2. Press your Stream Deck button (or type arrow keys manually) +2. Press your Stream Deck button (or type arrow keys/WASD manually) 3. The tool displays the input sequence (↑ ↓ ← →) 4. Shows which stratagem matches the sequence 5. Press Escape to clear and try another diff --git a/README.md b/README.md index faf75b9..8884cf5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Assumes the following: * Left Control will open Stratagem menu (Hold/Press) -* Stratagem Directions have been remapped to arrow keys (Up, Down, Left, Right) +* Stratagem directions use either the arrow keys or W/A/S/D, selectable in the plugin settings ## For Users @@ -18,6 +18,7 @@ Access settings via **Settings → Plugins → HELLDIVERS 2 → ⚙️** |---------|-------------|---------| | **Key Delay** | Delay between key presses. Increase if stratagems fail to register. | 0.03s | | **Modifier Key** | Key to open stratagem menu (Left/Right Ctrl, Alt, or Shift) | Left Ctrl | +| **Direction Keys** | Keys used for Up, Down, Left, and Right stratagem inputs (Arrow keys or WASD) | Arrow keys | | **Hold Modifier Key** | Hold modifier during sequence vs press-and-release | ON | | **Show Labels** | Display text labels on buttons (OFF for icon-only look) | ON | diff --git a/key_mapping.py b/key_mapping.py new file mode 100644 index 0000000..99020f0 --- /dev/null +++ b/key_mapping.py @@ -0,0 +1,34 @@ +"""Direction-to-key mappings used when executing stratagem sequences.""" + +DEFAULT_DIRECTION_KEY_LAYOUT = "arrow_keys" + +DIRECTION_KEY_LAYOUTS = { + "arrow_keys": { + "UP": "KEY_UP", + "DOWN": "KEY_DOWN", + "LEFT": "KEY_LEFT", + "RIGHT": "KEY_RIGHT", + }, + "wasd": { + "UP": "KEY_W", + "DOWN": "KEY_S", + "LEFT": "KEY_A", + "RIGHT": "KEY_D", + }, +} + + +def normalize_direction_key_layout(layout: str) -> str: + """Return a supported layout, falling back for missing or stale settings.""" + if layout in DIRECTION_KEY_LAYOUTS: + return layout + return DEFAULT_DIRECTION_KEY_LAYOUT + + +def get_direction_key(direction: str, layout: str) -> str: + """Return the evdev key name for a direction in the selected layout.""" + normalized_layout = normalize_direction_key_layout(layout) + try: + return DIRECTION_KEY_LAYOUTS[normalized_layout][direction] + except KeyError as error: + raise ValueError(f"Unsupported stratagem direction: {direction!r}") from error diff --git a/main.py b/main.py index 1a60bcf..d85ed3f 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,12 @@ from gi.repository import Gtk, Adw from loguru import logger as log +from key_mapping import ( + DEFAULT_DIRECTION_KEY_LAYOUT, + get_direction_key as resolve_direction_key, + normalize_direction_key_layout, +) + log.debug("Init HELLDIVERS 2") @@ -23,6 +29,11 @@ DEFAULT_HOLD_MODIFIER = True # False = press/release, True = hold during sequence DEFAULT_SHOW_LABELS = True # Show text labels on buttons +DIRECTION_KEY_LAYOUT_OPTIONS = { + "Arrow keys": "arrow_keys", + "WASD keys": "wasd", +} + # Available modifier keys MODIFIER_KEYS = { "Left Ctrl": "KEY_LEFTCTRL", @@ -220,10 +231,11 @@ def on_key_down(self, data=None): sleep(key_delay) for key in sequence: - self.plugin_base.ui.write(ecodes.EV_KEY, ecodes.ecodes[f"KEY_{key}"], 1) + key_code = ecodes.ecodes[self.plugin_base.get_direction_key(key)] + self.plugin_base.ui.write(ecodes.EV_KEY, key_code, 1) self.plugin_base.ui.syn() sleep(key_delay) - self.plugin_base.ui.write(ecodes.EV_KEY, ecodes.ecodes[f"KEY_{key}"], 0) + self.plugin_base.ui.write(ecodes.EV_KEY, key_code, 0) self.plugin_base.ui.syn() sleep(key_delay) except Exception as e: @@ -410,10 +422,11 @@ def on_key_down(self, data=None): sleep(key_delay) for key in self.stratagem: - self.plugin_base.ui.write(ecodes.EV_KEY, ecodes.ecodes[f"KEY_{key}"], 1) + key_code = ecodes.ecodes[self.plugin_base.get_direction_key(key)] + self.plugin_base.ui.write(ecodes.EV_KEY, key_code, 1) self.plugin_base.ui.syn() sleep(key_delay) - self.plugin_base.ui.write(ecodes.EV_KEY, ecodes.ecodes[f"KEY_{key}"], 0) + self.plugin_base.ui.write(ecodes.EV_KEY, key_code, 0) self.plugin_base.ui.syn() sleep(key_delay) except Exception as e: @@ -520,6 +533,17 @@ def get_modifier_key(self) -> str: """Get the modifier key to use for opening stratagem menu.""" settings = self.get_settings() return settings.get("modifier_key", DEFAULT_MODIFIER_KEY) + + def get_direction_key_layout(self) -> str: + """Get the key layout used for stratagem directions.""" + settings = self.get_settings() + return normalize_direction_key_layout( + settings.get("direction_key_layout", DEFAULT_DIRECTION_KEY_LAYOUT) + ) + + def get_direction_key(self, direction: str) -> str: + """Get the evdev key name mapped to a stratagem direction.""" + return resolve_direction_key(direction, self.get_direction_key_layout()) def get_hold_modifier(self) -> bool: """Get whether to hold the modifier key during the sequence.""" @@ -549,6 +573,7 @@ def get_settings_area(self): # Execution settings group.add(self._create_key_delay_row()) group.add(self._create_modifier_key_row()) + group.add(self._create_direction_key_layout_row()) group.add(self._create_hold_modifier_row()) # Display settings @@ -621,6 +646,43 @@ def _on_modifier_key_changed(self, row, _, modifier_names): name = modifier_names[selected] key = MODIFIER_KEYS[name] self._save_setting("modifier_key", key) + + def _create_direction_key_layout_row(self) -> Adw.ComboRow: + """Create the direction key layout combo row.""" + row = Adw.ComboRow( + title="Direction Keys", + subtitle="Keys mapped to Up, Down, Left, and Right stratagem inputs" + ) + + string_list = Gtk.StringList() + layout_names = list(DIRECTION_KEY_LAYOUT_OPTIONS.keys()) + for name in layout_names: + string_list.append(name) + + row.set_model(string_list) + + current_layout = self.get_direction_key_layout() + for i, name in enumerate(layout_names): + if DIRECTION_KEY_LAYOUT_OPTIONS[name] == current_layout: + row.set_selected(i) + break + + row.connect( + "notify::selected", + self._on_direction_key_layout_changed, + layout_names, + ) + return row + + def _on_direction_key_layout_changed(self, row, _, layout_names): + """Handle direction key layout changes.""" + selected = row.get_selected() + if selected < len(layout_names): + name = layout_names[selected] + self._save_setting( + "direction_key_layout", + DIRECTION_KEY_LAYOUT_OPTIONS[name], + ) def _create_hold_modifier_row(self) -> Adw.SwitchRow: """Create the hold modifier switch row.""" diff --git a/test_key_mapping.py b/test_key_mapping.py new file mode 100644 index 0000000..296abc4 --- /dev/null +++ b/test_key_mapping.py @@ -0,0 +1,36 @@ +import unittest + +from key_mapping import ( + DEFAULT_DIRECTION_KEY_LAYOUT, + get_direction_key, + normalize_direction_key_layout, +) + + +class DirectionKeyMappingTests(unittest.TestCase): + def test_arrow_key_layout(self): + self.assertEqual(get_direction_key("UP", "arrow_keys"), "KEY_UP") + self.assertEqual(get_direction_key("DOWN", "arrow_keys"), "KEY_DOWN") + self.assertEqual(get_direction_key("LEFT", "arrow_keys"), "KEY_LEFT") + self.assertEqual(get_direction_key("RIGHT", "arrow_keys"), "KEY_RIGHT") + + def test_wasd_key_layout(self): + self.assertEqual(get_direction_key("UP", "wasd"), "KEY_W") + self.assertEqual(get_direction_key("DOWN", "wasd"), "KEY_S") + self.assertEqual(get_direction_key("LEFT", "wasd"), "KEY_A") + self.assertEqual(get_direction_key("RIGHT", "wasd"), "KEY_D") + + def test_unknown_layout_uses_backward_compatible_default(self): + self.assertEqual( + normalize_direction_key_layout("removed-layout"), + DEFAULT_DIRECTION_KEY_LAYOUT, + ) + self.assertEqual(get_direction_key("UP", "removed-layout"), "KEY_UP") + + def test_unknown_direction_is_rejected(self): + with self.assertRaisesRegex(ValueError, "Unsupported stratagem direction"): + get_direction_key("FORWARD", "wasd") + + +if __name__ == "__main__": + unittest.main() diff --git a/test_stratagems.py b/test_stratagems.py index d8340e6..2cd4b32 100644 --- a/test_stratagems.py +++ b/test_stratagems.py @@ -10,7 +10,7 @@ Controls: - Focus the window - - Press arrow keys (with or without modifier) + - Press arrow keys or W/A/S/D (with or without modifier) - The tool will match sequences to stratagems - Press Escape to clear the current sequence """ @@ -28,12 +28,20 @@ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) STRATAGEMS_PATH = os.path.join(SCRIPT_DIR, "assets", "data", "stratagems.json") -# Arrow key mappings +# Supported direction key mappings KEY_TO_DIRECTION = { Gdk.KEY_Up: "UP", Gdk.KEY_Down: "DOWN", Gdk.KEY_Left: "LEFT", Gdk.KEY_Right: "RIGHT", + Gdk.KEY_w: "UP", + Gdk.KEY_W: "UP", + Gdk.KEY_s: "DOWN", + Gdk.KEY_S: "DOWN", + Gdk.KEY_a: "LEFT", + Gdk.KEY_A: "LEFT", + Gdk.KEY_d: "RIGHT", + Gdk.KEY_D: "RIGHT", } DIRECTION_DISPLAY = { @@ -100,7 +108,7 @@ def build_ui(self): # Instructions instructions = Gtk.Label( - label="Press arrow keys to input a stratagem sequence.\n" + label="Press arrow keys or W/A/S/D to input a stratagem sequence.\n" "Press Escape to clear." ) instructions.set_wrap(True) @@ -193,7 +201,7 @@ def on_key_pressed(self, controller, keyval, keycode, state): self.update_modifier_display() return True - # Check if it's an arrow key + # Check if it is a configured direction key direction = KEY_TO_DIRECTION.get(keyval) if direction: # Check if modifier is currently held @@ -351,7 +359,7 @@ def main(): sys.exit(1) print(f"Loaded {len(stratagems)} stratagems") - print("Focus the window and press arrow keys to test.") + print("Focus the window and press arrow keys or W/A/S/D to test.") app = ValidatorApp(stratagems) app.run(sys.argv)