GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 100.0% 19 / 0 / 19
Functions: 100.0% 7 / 0 / 7
Branches: 85.7% 6 / 0 / 7

include/DetourModKit/input.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_INPUT_HPP
2 #define DETOURMODKIT_INPUT_HPP
3
4 #include "DetourModKit/input_codes.hpp"
5 #include "DetourModKit/config.hpp"
6
7 #include <atomic>
8 #include <chrono>
9 #include <condition_variable>
10 #include <cstdint>
11 #include <functional>
12 #include <memory>
13 #include <mutex>
14 #include <shared_mutex>
15 #include <string>
16 #include <string_view>
17 #include <thread>
18 #include <unordered_map>
19 #include <unordered_set>
20 #include <vector>
21
22 namespace DetourModKit
23 {
24 /**
25 * @enum InputMode
26 * @brief Defines how a registered key binding is triggered.
27 */
28 enum class InputMode
29 {
30 Press,
31 Hold
32 };
33
34 /**
35 * @brief Converts an InputMode enum to its string representation.
36 * @param mode The InputMode enum value.
37 * @return std::string_view String representation of the mode.
38 */
39 43 constexpr std::string_view input_mode_to_string(InputMode mode) noexcept
40 {
41
3/3
✓ Branch 2 → 3 taken 33 times.
✓ Branch 2 → 4 taken 9 times.
✓ Branch 2 → 5 taken 1 time.
43 switch (mode)
42 {
43 33 case InputMode::Press:
44 33 return "Press";
45 9 case InputMode::Hold:
46 9 return "Hold";
47 }
48 1 return "Unknown";
49 }
50
51 // Input system configuration defaults
52 inline constexpr std::chrono::milliseconds DEFAULT_POLL_INTERVAL{16};
53 inline constexpr std::chrono::milliseconds MIN_POLL_INTERVAL{1};
54 inline constexpr std::chrono::milliseconds MAX_POLL_INTERVAL{1000};
55
56 /**
57 * @struct InputBinding
58 * @brief Describes a single input-to-action binding.
59 * @details Holds the action name, input codes, modifier codes, input mode,
60 * and callbacks. For Press mode, the press callback fires on key-down edge.
61 * For Hold mode, the state callback fires with true on press and false on
62 * release (including during shutdown for active holds).
63 *
64 * The keys vector uses OR logic: any single input triggers the binding.
65 * The modifiers vector uses AND logic: all modifiers must be held
66 * simultaneously for the binding to activate. Modifier matching is
67 * strict: any key that appears as a modifier in *any* registered
68 * binding will block bindings that do not list it as a required
69 * modifier. This prevents "V" from firing when "Shift+V" is pressed.
70 *
71 * Each InputCode identifies both the device source (keyboard, mouse,
72 * gamepad) and the button/key code. All codes within a binding should
73 * be from the same device group (keyboard/mouse or gamepad).
74 *
75 * @warning Callbacks are invoked on the polling thread. They must not capture references
76 * or pointers to objects whose lifetime may end before shutdown() completes.
77 * Callbacks should execute quickly to avoid degrading the effective poll rate.
78 */
79 struct InputBinding
80 {
81 std::string name;
82 std::vector<InputCode> keys;
83 std::vector<InputCode> modifiers;
84 InputMode mode = InputMode::Press;
85 std::function<void()> on_press;
86 std::function<void(bool)> on_state_change;
87 };
88
89 /**
90 * @class InputPoller
91 * @brief RAII input polling engine that monitors key states on a background thread.
92 * @details Manages a dedicated polling thread that checks virtual key states via
93 * GetAsyncKeyState. Supports both press (edge-triggered) and hold
94 * (level-triggered) input modes with optional modifier key combinations.
95 * When require_focus is enabled (default), key events are only processed
96 * when the current process owns the foreground window.
97 *
98 * On shutdown, active hold bindings receive an on_state_change(false)
99 * callback to ensure consumers are notified of the release.
100 *
101 * @note Non-copyable, non-movable. Callbacks are invoked on the polling thread.
102 * @note This class is the building block for the InputManager singleton.
103 *
104 * @warning When used inside a DLL, shutdown() must be called before DLL_PROCESS_DETACH.
105 * Calling join() on a thread during DllMain can deadlock due to the loader lock.
106 * Use DMK_Shutdown() to ensure proper teardown ordering.
107 */
108 class InputPoller
109 {
110 public:
111 /**
112 * @brief Constructs an InputPoller with the given bindings and poll interval.
113 * @param bindings Vector of input bindings to monitor.
114 * @param poll_interval Time between polling cycles.
115 * @param require_focus When true, key events are ignored unless the current
116 * process owns the foreground window.
117 * @param gamepad_index XInput controller index (0-3) to poll for gamepad bindings.
118 * @param trigger_threshold Analog trigger deadzone threshold (0-255). Trigger values
119 * above this threshold are considered "pressed".
120 * @param stick_threshold Thumbstick deadzone threshold (0-32767). Axis values
121 * exceeding this threshold in any direction are "pressed".
122 * @note The polling thread does not start until start() is called.
123 */
124 explicit InputPoller(std::vector<InputBinding> bindings,
125 std::chrono::milliseconds poll_interval = DEFAULT_POLL_INTERVAL,
126 bool require_focus = true,
127 int gamepad_index = 0,
128 int trigger_threshold = GamepadCode::TriggerThreshold,
129 int stick_threshold = GamepadCode::StickThreshold);
130
131 ~InputPoller() noexcept;
132
133 InputPoller(const InputPoller &) = delete;
134 InputPoller &operator=(const InputPoller &) = delete;
135 InputPoller(InputPoller &&) = delete;
136 InputPoller &operator=(InputPoller &&) = delete;
137
138 /**
139 * @brief Starts the polling thread.
140 * @details Safe to call only once. Subsequent calls are ignored with a warning.
141 * @note Not thread-safe. Must be called from a single thread. Use
142 * InputManager::start() for a thread-safe wrapper.
143 */
144 void start();
145
146 /**
147 * @brief Checks if the polling thread is currently running.
148 * @return true if the poller is active and monitoring keys.
149 */
150 [[nodiscard]] bool is_running() const noexcept;
151
152 /**
153 * @brief Returns the number of registered bindings.
154 * @return size_t Number of bindings.
155 */
156 [[nodiscard]] size_t binding_count() const noexcept;
157
158 /**
159 * @brief Returns the configured poll interval.
160 * @return std::chrono::milliseconds The poll interval.
161 */
162 [[nodiscard]] std::chrono::milliseconds poll_interval() const noexcept;
163
164 /**
165 * @brief Returns the configured gamepad controller index.
166 * @return int The XInput controller index (0-3).
167 */
168 [[nodiscard]] int gamepad_index() const noexcept;
169
170 /**
171 * @brief Queries whether a binding is currently active by index.
172 * @param index Zero-based index into the bindings vector.
173 * @return true if the binding's key(s) are currently pressed.
174 * Returns false for out-of-range indices.
175 * @note Thread-safe. Acquires bindings_rw_mutex_ as a reader so the
176 * index/array pair stays consistent across reshape calls
177 * (add_binding, remove_bindings_by_name, update_combos). The
178 * fast path is the cheap shared_lock acquire when no writer
179 * is in flight.
180 */
181 [[nodiscard]] bool is_binding_active(size_t index) const noexcept;
182
183 /**
184 * @brief Queries whether a binding is currently active by name.
185 * @param name The binding name to look up.
186 * @return true if the named binding's key(s) are currently pressed.
187 * Returns false if no binding with the given name exists.
188 * @note Thread-safe. Can be called from any thread.
189 */
190 [[nodiscard]] bool is_binding_active(std::string_view name) const noexcept;
191
192 /**
193 * @brief Sets whether the poller requires the current process to own the
194 * foreground window before processing key events.
195 * @param require_focus true to enable focus checking (default), false to disable.
196 * @note Thread-safe. Can be called while the poller is running.
197 */
198 void set_require_focus(bool require_focus) noexcept;
199
200 /**
201 * @brief Stops the polling thread.
202 * @details Signals the thread to stop and waits for it to join. After the
203 * thread has joined, fires on_state_change(false) for any hold
204 * bindings that were active at the time of shutdown. Safe to call
205 * multiple times.
206 */
207 void shutdown() noexcept;
208
209 /**
210 * @brief Replaces the trigger combos of all bindings sharing @p name.
211 * @details The poller maps each combo passed to register_press/register_hold
212 * to an independent binding entry with a shared name. When the
213 * replacement count matches the existing entry count, keys and
214 * modifiers are overwritten in place. When the count differs,
215 * the existing entries are erased and one entry per replacement
216 * combo is appended; callbacks, binding mode, and binding name
217 * inherit from the first existing entry.
218 *
219 * An empty replacement list is a valid binding state meaning
220 * "no keys bound": the existing entries are erased and a
221 * single inert sentinel entry takes their place so the
222 * binding name remains addressable for a later non-empty
223 * update. Held bindings receive an on_state_change(false)
224 * callback before the swap completes. Safe to call while
225 * the poll thread is running.
226 * @param name Binding name previously registered.
227 * @param combos Replacement combos. May be empty to unbind.
228 * @return true on successful swap (including the unbind case), false
229 * only if the name was never registered.
230 */
231 [[nodiscard]] bool update_combos(std::string_view name, const Config::KeyComboList &combos) noexcept;
232
233 /**
234 * @brief Appends a binding to the running poller.
235 * @details Thread-safe. Takes the bindings rw mutex exclusively, so a
236 * concurrent poll cycle blocks for at most the duration of
237 * its current tick. The active_states_ array is rebuilt to
238 * match the new binding count, with the previous atomic
239 * value carried forward for every existing entry so a held
240 * binding does not flicker through one inactive tick.
241 * @param binding Binding to append.
242 */
243 void add_binding(InputBinding binding) noexcept;
244
245 /**
246 * @brief Removes every binding whose name matches @p name.
247 * @details Thread-safe. Active hold bindings receive an
248 * on_state_change(false) callback before erasure. The
249 * active_states_ array is rebuilt to match the new
250 * binding count, with the previous atomic value carried
251 * forward for every surviving entry.
252 * @param name Binding name to remove.
253 * @return Number of bindings removed (zero if the name was not registered).
254 */
255 size_t remove_bindings_by_name(std::string_view name) noexcept
256 {
257 return remove_bindings_by_name(name, true);
258 }
259
260 /**
261 * @brief Drops every binding without stopping the poll thread.
262 * @details Active hold bindings receive an on_state_change(false)
263 * callback before erasure. After the call the poller has
264 * zero bindings and the poll thread keeps running idle.
265 * Thread-safe.
266 */
267 void clear_bindings() noexcept
268 {
269 clear_bindings(true);
270 }
271
272 /**
273 * @brief Variant of remove_bindings_by_name that suppresses the
274 * on_state_change(false) release callbacks for active holds.
275 * @details Used by the loader-lock-safe Bootstrap unload path: user
276 * callbacks live in a Logic DLL whose code pages may be
277 * about to be unmapped, so invoking them under the loader
278 * lock would risk a deadlock or a use-after-unload.
279 * @param name Binding name to remove.
280 * @param invoke_callbacks When true (default for the public API),
281 * active hold bindings receive on_state_change(false) before
282 * erasure. When false, the release callbacks are dropped on
283 * the floor.
284 * @return Number of bindings removed.
285 */
286 size_t remove_bindings_by_name(std::string_view name, bool invoke_callbacks) noexcept;
287
288 /**
289 * @brief Variant of clear_bindings that suppresses the
290 * on_state_change(false) release callbacks for active holds.
291 * @details See the single-argument overload of remove_bindings_by_name
292 * for the rationale; both overloads serve the same
293 * loader-lock-safe teardown path.
294 * @param invoke_callbacks When true (default for the public API),
295 * active hold bindings receive on_state_change(false) before
296 * erasure. When false, the release callbacks are dropped.
297 */
298 void clear_bindings(bool invoke_callbacks) noexcept;
299
300 private:
301 void poll_loop(std::stop_token stop_token);
302 void release_active_holds() noexcept;
303 [[nodiscard]] bool is_process_foreground() const;
304 void recompute_modifier_caches_locked() noexcept;
305
306 /// Transparent hasher enabling std::string_view lookup without allocation.
307 struct StringHash
308 {
309 using is_transparent = void;
310 2166 size_t operator()(std::string_view sv) const noexcept
311 {
312 2166 return std::hash<std::string_view>{}(sv);
313 }
314 };
315
316 // bindings_rw_mutex_ protects bindings_, name_index_, known_modifiers_,
317 // and has_gamepad_bindings_ when a live update is in flight. The poll
318 // loop holds a shared lock for the duration of one polling cycle;
319 // update_combos() holds an exclusive lock across the swap. active_states_
320 // entries are always accessed via atomic ops and need no further guard.
321 mutable std::shared_mutex bindings_rw_mutex_;
322 std::vector<InputBinding> bindings_;
323 std::unordered_map<std::string, std::vector<size_t>, StringHash, std::equal_to<>> name_index_;
324 std::vector<InputCode> known_modifiers_;
325 std::chrono::milliseconds poll_interval_;
326 std::atomic<bool> require_focus_;
327 std::atomic<bool> running_{false};
328 std::jthread poll_thread_;
329 std::mutex cv_mutex_;
330 std::condition_variable_any cv_;
331
332 // Per-binding active state, indexed parallel to bindings_.
333 // Atomic for cross-thread reads via is_binding_active().
334 std::unique_ptr<std::atomic<uint8_t>[]> active_states_;
335
336 int gamepad_index_;
337 int trigger_threshold_;
338 int stick_threshold_;
339 std::atomic<bool> has_gamepad_bindings_{false};
340 };
341
342 /**
343 * @class InputManager
344 * @brief Singleton that provides a convenient interface for registering and
345 * monitoring hotkey bindings.
346 * @details Wraps an InputPoller internally. Bindings are registered before
347 * calling start(), which constructs and starts the poller. Integrates
348 * with DMK_Shutdown() for automatic cleanup.
349 *
350 * @note Thread-safe. For advanced use cases requiring multiple independent
351 * pollers or custom lifetime management, use InputPoller directly.
352 *
353 * @warning When used inside a DLL, shutdown() must be called before DLL_PROCESS_DETACH.
354 * Calling join() on a thread during DllMain can deadlock due to the loader lock.
355 */
356 class InputManager
357 {
358 public:
359 /**
360 * @brief Retrieves the singleton instance of the InputManager.
361 * @return InputManager& Reference to the single InputManager instance.
362 */
363 264 static InputManager &get_instance()
364 {
365
3/4
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 8 taken 263 times.
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 8 not taken.
264 static InputManager instance;
366 264 return instance;
367 }
368
369 /**
370 * @brief Registers a press-mode binding.
371 * @details The callback fires once per key-down edge for any key in the list.
372 * Can be called either before or after start(); a binding registered
373 * while the poller is running is appended to the live binding set
374 * and starts firing on the next poll cycle.
375 * @param name Unique, descriptive name for the binding.
376 * @param keys Vector of input codes (any triggers the action).
377 * @param callback Function to invoke on key press.
378 */
379 void register_press(std::string_view name, const std::vector<InputCode> &keys,
380 std::function<void()> callback);
381
382 /**
383 * @brief Registers a press-mode binding with modifier keys.
384 * @details The callback fires once per key-down edge for any key in the list,
385 * but only when all modifier inputs are simultaneously held. Live
386 * registration is supported (see the no-modifier overload).
387 * @param name Unique, descriptive name for the binding.
388 * @param keys Vector of input codes (any triggers the action).
389 * @param modifiers Vector of modifier input codes (all must be held).
390 * @param callback Function to invoke on key press.
391 */
392 void register_press(std::string_view name, const std::vector<InputCode> &keys,
393 const std::vector<InputCode> &modifiers,
394 std::function<void()> callback);
395
396 /**
397 * @brief Registers press-mode bindings from a KeyComboList.
398 * @details Registers one binding per combo in the list. All bindings share
399 * the same name, enabling OR-logic via is_binding_active(). When
400 * @p combos is empty, a single sentinel binding with no keys is
401 * registered so the name is reachable by update_binding_combos().
402 * Live registration is supported.
403 * @param name Shared binding name for all combos.
404 * @param combos List of key combinations (each combo is registered independently).
405 * @param callback Function to invoke on key press.
406 */
407 void register_press(std::string_view name, const Config::KeyComboList &combos,
408 std::function<void()> callback);
409
410 /**
411 * @brief Registers a hold-mode binding.
412 * @details The callback fires with true when any input in the list is pressed,
413 * and false when all are released. Live registration is supported
414 * (see register_press for semantics).
415 * @param name Unique, descriptive name for the binding.
416 * @param keys Vector of input codes (any activates the hold).
417 * @param callback Function invoked with the hold state (true = held, false = released).
418 */
419 void register_hold(std::string_view name, const std::vector<InputCode> &keys,
420 std::function<void(bool)> callback);
421
422 /**
423 * @brief Registers a hold-mode binding with modifier keys.
424 * @details The callback fires with true when any input in the list is pressed
425 * and all modifier inputs are simultaneously held, and false when the
426 * condition is no longer met. Live registration is supported.
427 * @param name Unique, descriptive name for the binding.
428 * @param keys Vector of input codes (any activates the hold).
429 * @param modifiers Vector of modifier input codes (all must be held).
430 * @param callback Function invoked with the hold state (true = held, false = released).
431 */
432 void register_hold(std::string_view name, const std::vector<InputCode> &keys,
433 const std::vector<InputCode> &modifiers,
434 std::function<void(bool)> callback);
435
436 /**
437 * @brief Registers hold-mode bindings from a KeyComboList.
438 * @details Registers one binding per combo in the list. All bindings share
439 * the same name, enabling OR-logic via is_binding_active(). When
440 * @p combos is empty, a single sentinel binding with no keys is
441 * registered so the name is reachable by update_binding_combos().
442 * Live registration is supported.
443 * @param name Shared binding name for all combos.
444 * @param combos List of key combinations (each combo is registered independently).
445 * @param callback Function invoked with the hold state (true = held, false = released).
446 */
447 void register_hold(std::string_view name, const Config::KeyComboList &combos,
448 std::function<void(bool)> callback);
449
450 /**
451 * @brief Sets whether the poller requires the current process to own the
452 * foreground window before processing key events.
453 * @param require_focus true to enable focus checking (default), false to disable.
454 * @note Can be called before or after start(). Changes take effect immediately.
455 */
456 void set_require_focus(bool require_focus);
457
458 /**
459 * @brief Sets the XInput controller index to poll for gamepad bindings.
460 * @param index Controller index (0-3). Clamped to valid range.
461 * @note Must be called before start(). Has no effect while the poller is running.
462 */
463 void set_gamepad_index(int index);
464
465 /**
466 * @brief Sets the analog trigger deadzone threshold for gamepad bindings.
467 * @param threshold Trigger values above this threshold (0-255) are "pressed".
468 * @note Must be called before start(). Has no effect while the poller is running.
469 */
470 void set_trigger_threshold(int threshold);
471
472 /**
473 * @brief Sets the thumbstick deadzone threshold for gamepad bindings.
474 * @param threshold Axis values exceeding this threshold (0-32767) are "pressed".
475 * @note Must be called before start(). Has no effect while the poller is running.
476 */
477 void set_stick_threshold(int threshold);
478
479 /**
480 * @brief Starts the input polling thread with all registered bindings.
481 * @details Constructs an internal InputPoller with the current bindings
482 * and begins monitoring. Subsequent register calls are ignored
483 * until the poller is stopped and bindings are cleared.
484 * @param poll_interval Time between polling cycles.
485 */
486 void start(std::chrono::milliseconds poll_interval = DEFAULT_POLL_INTERVAL);
487
488 /**
489 * @brief Checks if the input polling thread is currently running.
490 * @return true if the poller is active.
491 */
492 [[nodiscard]] bool is_running() const noexcept;
493
494 /**
495 * @brief Returns the number of registered bindings.
496 * @return size_t Number of bindings (pending or active).
497 */
498 [[nodiscard]] size_t binding_count() const noexcept;
499
500 /**
501 * @brief Queries whether a named binding is currently active.
502 * @param name The binding name to look up.
503 * @return true if the named binding's key(s) are currently pressed.
504 * Returns false if the poller is not running or the name is unknown.
505 * @note Thread-safe. Can be called from any thread (e.g., render thread).
506 */
507 [[nodiscard]] bool is_binding_active(std::string_view name) const noexcept;
508
509 /**
510 * @brief Replaces the trigger combos of all bindings sharing @p name.
511 * @details Forwards to the active InputPoller when running, or updates
512 * pending bindings before start(). The binding's name,
513 * callback, and mode are preserved; only keys and modifiers
514 * are swapped. Any cardinality is accepted: matching counts
515 * rewrite in place, differing counts rebuild the entry set
516 * carrying callback identity forward.
517 *
518 * An empty replacement list unbinds the named binding while
519 * keeping a single inert sentinel entry so a subsequent
520 * non-empty update can rebind it. Held bindings receive an
521 * on_state_change(false) callback before the swap completes.
522 * If the name is unknown the call is a no-op logged at
523 * Debug level. Thread-safe.
524 * @param name Binding name previously registered.
525 * @param combos Replacement combos. May be empty to unbind.
526 */
527 void update_binding_combos(std::string_view name, const Config::KeyComboList &combos) noexcept;
528
529 /**
530 * @brief Removes every binding whose name matches @p name.
531 * @details Forwards to the active InputPoller when running, or erases
532 * matching entries from pending bindings before start().
533 * Thread-safe.
534 * @param name Binding name to remove.
535 * @return Number of bindings removed.
536 */
537 2 size_t remove_binding_by_name(std::string_view name) noexcept
538 {
539 2 return remove_binding_by_name(name, true);
540 }
541
542 /**
543 * @brief Drops every registered binding without stopping the poller.
544 * @details Forwards to the active InputPoller when running and clears
545 * pending bindings. Active hold bindings receive an
546 * on_state_change(false) callback before erasure. The poll
547 * thread keeps running and can be reseeded via subsequent
548 * register_press / register_hold calls. Thread-safe.
549 */
550 1 void clear_bindings() noexcept
551 {
552 1 clear_bindings(true);
553 1 }
554
555 /**
556 * @brief Variant of remove_binding_by_name that suppresses the
557 * on_state_change(false) release callbacks for active holds.
558 * @details Forwarded straight to the underlying InputPoller. Loader-lock
559 * callers use this overload because user callbacks live in a
560 * Logic DLL whose code pages may be about to be unmapped.
561 * @param name Binding name to remove.
562 * @param invoke_callbacks When true, behaves identically to the public
563 * single-argument overload. When false, drops release callbacks.
564 * @return Number of bindings removed.
565 */
566 size_t remove_binding_by_name(std::string_view name, bool invoke_callbacks) noexcept;
567
568 /**
569 * @brief Variant of clear_bindings that suppresses the
570 * on_state_change(false) release callbacks for active holds.
571 * @param invoke_callbacks When true, behaves identically to the public
572 * zero-argument overload. When false, drops release callbacks.
573 */
574 void clear_bindings(bool invoke_callbacks) noexcept;
575
576 /**
577 * @brief Stops the polling thread and clears all registered bindings.
578 * @details Safe to call multiple times. After shutdown, new bindings
579 * can be registered and start() called again.
580 */
581 void shutdown() noexcept;
582
583 private:
584 1 InputManager() = default;
585 1 ~InputManager() = default;
586
587 InputManager(const InputManager &) = delete;
588 InputManager &operator=(const InputManager &) = delete;
589 InputManager(InputManager &&) = delete;
590 InputManager &operator=(InputManager &&) = delete;
591
592 mutable std::mutex mutex_;
593 std::vector<InputBinding> pending_bindings_;
594 std::shared_ptr<InputPoller> poller_;
595 std::atomic<std::shared_ptr<InputPoller>> active_poller_{};
596 std::atomic<bool> running_{false};
597 bool require_focus_ = true;
598 int gamepad_index_ = 0;
599 int trigger_threshold_ = GamepadCode::TriggerThreshold;
600 int stick_threshold_ = GamepadCode::StickThreshold;
601 };
602 } // namespace DetourModKit
603
604 #endif // DETOURMODKIT_INPUT_HPP
605