diff --git a/src/AcDream.App/Input/SilkKeyboardSource.cs b/src/AcDream.App/Input/SilkKeyboardSource.cs new file mode 100644 index 0000000..65c1d1d --- /dev/null +++ b/src/AcDream.App/Input/SilkKeyboardSource.cs @@ -0,0 +1,46 @@ +using System; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Input; + +/// +/// Bridges a Silk.NET to the test-fakeable +/// interface used by +/// . K.1a wiring: GameWindow constructs +/// one of these wrapping the first keyboard from +/// IInputContext.Keyboards; the dispatcher fans events out to +/// subscribers without ever touching Silk types directly. +/// +public sealed class SilkKeyboardSource : IKeyboardSource +{ + private readonly IKeyboard _keyboard; + + public event Action? KeyDown; + public event Action? KeyUp; + + public SilkKeyboardSource(IKeyboard keyboard) + { + _keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); + _keyboard.KeyDown += (_, key, _) => KeyDown?.Invoke(key, ReadModifiers()); + _keyboard.KeyUp += (_, key, _) => KeyUp?.Invoke(key, ReadModifiers()); + } + + public bool IsHeld(Key key) => _keyboard.IsKeyPressed(key); + + public ModifierMask CurrentModifiers => ReadModifiers(); + + private ModifierMask ReadModifiers() + { + ModifierMask m = ModifierMask.None; + if (_keyboard.IsKeyPressed(Key.ShiftLeft) || _keyboard.IsKeyPressed(Key.ShiftRight)) + m |= ModifierMask.Shift; + if (_keyboard.IsKeyPressed(Key.ControlLeft) || _keyboard.IsKeyPressed(Key.ControlRight)) + m |= ModifierMask.Ctrl; + if (_keyboard.IsKeyPressed(Key.AltLeft) || _keyboard.IsKeyPressed(Key.AltRight)) + m |= ModifierMask.Alt; + if (_keyboard.IsKeyPressed(Key.SuperLeft) || _keyboard.IsKeyPressed(Key.SuperRight)) + m |= ModifierMask.Win; + return m; + } +} diff --git a/src/AcDream.App/Input/SilkMouseSource.cs b/src/AcDream.App/Input/SilkMouseSource.cs new file mode 100644 index 0000000..3669189 --- /dev/null +++ b/src/AcDream.App/Input/SilkMouseSource.cs @@ -0,0 +1,76 @@ +using System; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Input; + +/// +/// Bridges a Silk.NET + the active ImGui IO +/// (for / ) +/// to the test-fakeable used by +/// . +/// +/// +/// We don't link Hexa.NET.ImGui or ImGuiNET directly here — the +/// constructor takes two delegates so the App.Rendering layer can +/// proxy ImGui.GetIO().WantCaptureMouse via whichever ImGui +/// package is currently active without leaking the type onto the +/// abstraction interface. +/// +/// +public sealed class SilkMouseSource : IMouseSource +{ + private readonly IMouse _mouse; + private readonly Func _wantCaptureMouse; + private readonly Func _wantCaptureKeyboard; + private float _lastX; + private float _lastY; + private bool _haveLastPos; + + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; + + /// Caller-supplied probe for the current modifier mask. Reused + /// from the keyboard source so mouse events carry consistent modifier + /// state. + public Func ModifierProbe { get; set; } = () => ModifierMask.None; + + public SilkMouseSource( + IMouse mouse, + Func wantCaptureMouse, + Func wantCaptureKeyboard) + { + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + _wantCaptureMouse = wantCaptureMouse ?? throw new ArgumentNullException(nameof(wantCaptureMouse)); + _wantCaptureKeyboard = wantCaptureKeyboard ?? throw new ArgumentNullException(nameof(wantCaptureKeyboard)); + + _mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ModifierProbe()); + _mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ModifierProbe()); + _mouse.MouseMove += (_, pos) => + { + float dx, dy; + if (_haveLastPos) + { + dx = pos.X - _lastX; + dy = pos.Y - _lastY; + } + else + { + dx = 0f; + dy = 0f; + _haveLastPos = true; + } + _lastX = pos.X; + _lastY = pos.Y; + MouseMove?.Invoke(dx, dy); + }; + _mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y); + } + + public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button); + + public bool WantCaptureMouse => _wantCaptureMouse(); + public bool WantCaptureKeyboard => _wantCaptureKeyboard(); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 77fe759..7aae2cd 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -396,6 +396,16 @@ public sealed class GameWindow : IDisposable // the orbited position (no snap back). private bool _rmbHeld; + // Phase K.1a — input architecture skeleton. Lives ALONGSIDE the + // existing IsKeyPressed + KeyDown handlers; nothing subscribes to + // dispatcher.Fired except a diagnostic logger. K.1b cuts the legacy + // paths over. + private AcDream.App.Input.SilkKeyboardSource? _kbSource; + private AcDream.App.Input.SilkMouseSource? _mouseSource; + private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher; + private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings = + AcDream.UI.Abstractions.Input.KeyBindings.RetailDefaults(); + // Phase 4.7: optional live connection to an ACE server. Enabled only when // ACDREAM_LIVE=1 is in the environment — fully backward compatible with // the offline rendering pipeline. @@ -801,6 +811,29 @@ public sealed class GameWindow : IDisposable }; } + // Phase K.1a — input dispatcher skeleton wired ALONGSIDE the + // existing IsKeyPressed + KeyDown handlers above. The dispatcher + // observes the same Silk.NET keyboard/mouse and fires high-level + // InputAction events; in K.1a nothing actually drives behavior + // off the dispatcher except a diagnostic Console.WriteLine, so + // this is a no-op for the player. K.1b cuts the legacy paths + // over to subscribe to dispatcher.Fired. + var firstKb = _input.Keyboards.FirstOrDefault(); + var firstMouse = _input.Mice.FirstOrDefault(); + if (firstKb is not null && firstMouse is not null) + { + _kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb); + _mouseSource = new AcDream.App.Input.SilkMouseSource( + firstMouse, + wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse, + wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard); + _mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers; + _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( + _kbSource, _mouseSource, _keyBindings); + _inputDispatcher.Fired += (action, activation) => + Console.WriteLine($"[input] {action} {activation}"); + } + _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); _gl.Enable(EnableCap.DepthTest); @@ -3783,6 +3816,11 @@ public sealed class GameWindow : IDisposable // are in the kernel buffer. Fires EntitySpawned events synchronously. _liveSession?.Tick(); + // Phase K.1a — tick the input dispatcher so Hold-type bindings + // re-fire while their chord is held. K.1b adds the subscribers + // that actually consume the events. + _inputDispatcher?.Tick(); + if (_cameraController is null || _input is null) return; var kb = _input.Keyboards[0]; diff --git a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj index 0fc070c..2f4ca83 100644 --- a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj +++ b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj @@ -6,6 +6,9 @@ latest true + + + diff --git a/src/AcDream.UI.Abstractions/Input/ActivationType.cs b/src/AcDream.UI.Abstractions/Input/ActivationType.cs new file mode 100644 index 0000000..14f40da --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/ActivationType.cs @@ -0,0 +1,27 @@ +namespace AcDream.UI.Abstractions.Input; + +/// +/// When (in the lifecycle of a chord) the bound +/// fires. Modeled on the retail keymap's "activation type" sixth-field — +/// the retail format uses it primarily for mouse double-click detection, +/// but we also surface for the +/// CameraInstantMouseLook MMB-hold behavior and +/// for future axis bindings. +/// +public enum ActivationType +{ + /// Fire on key-down. Default for most actions. + Press, + /// Fire on key-up. Used for paired walk-mode toggles. + Release, + /// Fire continuously while the chord is held — the + /// dispatcher emits a on key-down and a + /// on key-up, then ticks + /// in between via Tick(). + Hold, + /// Mouse-button double-click within retail's chord window. + DoubleClick, + /// Mouse axis or other analog input. Reserved for future + /// rebindable mouse-look — K.1a does not emit this. + Analog, +} diff --git a/src/AcDream.UI.Abstractions/Input/Binding.cs b/src/AcDream.UI.Abstractions/Input/Binding.cs new file mode 100644 index 0000000..7f49740 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/Binding.cs @@ -0,0 +1,16 @@ +namespace AcDream.UI.Abstractions.Input; + +/// +/// Single chord → action binding. Multiple bindings may resolve to the +/// same action (e.g. retail's W and Up-arrow both → MovementForward); +/// enumerates them. +/// +/// The keyboard or mouse chord that triggers the action. +/// The high-level action emitted on the dispatcher's +/// Fired event when the chord matches in the active scope. +/// When the action fires — defaults to +/// (key-down). +public readonly record struct Binding( + KeyChord Chord, + InputAction Action, + ActivationType Activation = ActivationType.Press); diff --git a/src/AcDream.UI.Abstractions/Input/IKeyboardSource.cs b/src/AcDream.UI.Abstractions/Input/IKeyboardSource.cs new file mode 100644 index 0000000..e179039 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/IKeyboardSource.cs @@ -0,0 +1,38 @@ +using System; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Input; + +/// +/// Test-fakeable wrapper over a keyboard event source. The production +/// adapter lives in src/AcDream.App/Input/SilkKeyboardSource.cs +/// and bridges Silk.NET's to this interface; +/// tests use a hand-rolled FakeKeyboardSource that lets the +/// test drive / events on +/// demand. +/// +/// +/// The interface deliberately omits any Silk.NET-specific types other +/// than (which is a value-type enum). That lets the +/// dispatcher be unit-testable without spinning a window context. +/// ImGui's WantCaptureKeyboard / WantCaptureMouse flags are exposed +/// on (single source of UI-capture truth) +/// rather than split across both surfaces. +/// +/// +public interface IKeyboardSource +{ + /// Fires on every transition from up → down for any key. + event Action? KeyDown; + + /// Fires on every transition from down → up for any key. + event Action? KeyUp; + + /// True iff the given key is currently pressed. + bool IsHeld(Key key); + + /// Bitmask of modifier keys currently held. Used by the + /// dispatcher's per-frame Tick() to assemble fresh chords for held + /// keys without re-listening to every event. + ModifierMask CurrentModifiers { get; } +} diff --git a/src/AcDream.UI.Abstractions/Input/IMouseSource.cs b/src/AcDream.UI.Abstractions/Input/IMouseSource.cs new file mode 100644 index 0000000..f4f9251 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/IMouseSource.cs @@ -0,0 +1,49 @@ +using System; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Input; + +/// +/// Test-fakeable wrapper over a mouse event source. The production +/// adapter lives in src/AcDream.App/Input/SilkMouseSource.cs +/// and bridges Silk.NET's to this interface; +/// tests use a FakeMouseSource in +/// tests/AcDream.UI.Abstractions.Tests/Input/. +/// +/// +/// and +/// are proxied from the active UI backend (ImGui in K.1a) so the +/// dispatcher can suppress firing while a panel is hovered or a text +/// field is focused. They live on IMouseSource rather than being +/// split across both surfaces because the dispatcher uses a single +/// reference to gate both branches. +/// +/// +public interface IMouseSource +{ + /// Fires on every transition from up → down for any button. + event Action? MouseDown; + + /// Fires on every transition from down → up for any button. + event Action? MouseUp; + + /// Fires on every cursor delta. Args are (dx, dy) in pixels + /// since the previous fire. + event Action? MouseMove; + + /// Fires on every wheel-tick. Positive = up, negative = down. + event Action? Scroll; + + /// True iff the given mouse button is currently pressed. + bool IsHeld(MouseButton button); + + /// True when the UI (ImGui) wants the mouse — the dispatcher + /// suppresses MouseDown/MouseUp/MouseMove/Scroll firing in that case + /// so e.g. RMB orbit doesn't trigger while hovering a panel. + bool WantCaptureMouse { get; } + + /// True when the UI (ImGui) wants the keyboard — the dispatcher + /// suppresses KeyDown firing in that case so typing into a chat field + /// doesn't fire game actions. + bool WantCaptureKeyboard { get; } +} diff --git a/src/AcDream.UI.Abstractions/Input/InputAction.cs b/src/AcDream.UI.Abstractions/Input/InputAction.cs new file mode 100644 index 0000000..6afd512 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/InputAction.cs @@ -0,0 +1,259 @@ +namespace AcDream.UI.Abstractions.Input; + +/// +/// High-level input actions. Each name matches an entry in the retail +/// acclient.keymap "Bindings" header (see +/// docs/research/named-retail/retail-default.keymap.txt). The +/// Acdream* entries at the bottom are extensions for current +/// debug bindings that have no retail equivalent. +/// +/// +/// K.1a defines the enum but only the existing acdream-current chords +/// resolve to actions; K.1c flips the bindings table to the full retail +/// preset. Several actions in this enum (UseQuickSlot_*, +/// Combat*, UseSpellSlot_*) have NO subscribers in K.1 — +/// the chord fires the event but nothing acts on it. Phase L panels +/// will subscribe. +/// +/// +public enum InputAction +{ + /// Sentinel — no action. Reserved for "unbound". + None = 0, + + // ── MovementCommands ────────────────────────────────── + /// Move forward (run by default; walk if active). + MovementForward, + /// Move backward. + MovementBackup, + /// Turn the character to the left. + MovementTurnLeft, + /// Turn the character to the right. + MovementTurnRight, + /// Strafe (sidestep) to the left. + MovementStrafeLeft, + /// Strafe (sidestep) to the right. + MovementStrafeRight, + /// Hold-modifier — toggles forward motion to walk while held (retail Shift). + MovementWalkMode, + /// Toggle autorun on/off (retail Q). + MovementRunLock, + /// Charged jump — hold to power, release to launch. + MovementJump, + /// Cancel current motion / return to ready stance. + MovementStop, + /// Ready posture / sheathe-unsheathe (retail Y). + Ready, + /// Sit posture (retail G). + Sitting, + /// Crouch posture (retail H). + Crouch, + /// Sleep posture (retail B). + Sleeping, + + // ── ItemSelectionCommands ───────────────────────────── + /// Pick up the selected item. + SelectionPickUp, + /// Split a stack of the selected item. + SelectionSplitStack, + /// Cycle back to the previous selection. + SelectionPreviousSelection, + /// Closest compass-tracked item. + SelectionClosestCompassItem, + /// Previous compass-tracked item. + SelectionPreviousCompassItem, + /// Next compass-tracked item. + SelectionNextCompassItem, + /// Closest item in radius. + SelectionClosestItem, + /// Previous item in radius. + SelectionPreviousItem, + /// Next item in radius. + SelectionNextItem, + /// Closest monster. + SelectionClosestMonster, + /// Previous monster. + SelectionPreviousMonster, + /// Next monster. + SelectionNextMonster, + /// Most-recent attacker (for retaliation). + SelectionLastAttacker, + /// Closest player. + SelectionClosestPlayer, + /// Previous player. + SelectionPreviousPlayer, + /// Next player. + SelectionNextPlayer, + /// Previous fellow. + SelectionPreviousFellow, + /// Next fellow. + SelectionNextFellow, + /// Examine (Appraise) the current selection. + SelectionExamine, + + // ── UICommands ──────────────────────────────────────── + /// Use the selected item / interact (retail R). + UseSelected, + /// Cancel the topmost UI / clear selection / open log-out menu. + EscapeKey, + /// Log out of the game (retail Shift+Esc). + LOGOUT, + /// Toggle the help / control reference panel (retail F1). + ToggleHelp, + /// Toggle the plugin manager panel (retail F1+Shift+Ctrl). + TogglePluginManager, + /// Toggle the allegiance panel (retail F3). + ToggleAllegiancePanel, + /// Toggle the fellowship panel (retail F4). + ToggleFellowshipPanel, + /// Toggle the spellbook panel (retail F5). + ToggleSpellbookPanel, + /// Toggle the spell-components panel (retail F6). + ToggleSpellComponentsPanel, + /// Toggle the attributes panel (retail F8). + ToggleAttributesPanel, + /// Toggle the skills panel (retail F9). + ToggleSkillsPanel, + /// Toggle the world / map panel (retail F10). + ToggleWorldPanel, + /// Toggle the options / settings panel (retail F11) — opens + /// our SettingsPanel in K.3. + ToggleOptionsPanel, + /// Toggle the inventory panel (retail F12). + ToggleInventoryPanel, + /// Toggle floating chat window 1 (retail Alt+1). + ToggleFloatingChatWindow1, + /// Toggle floating chat window 2. + ToggleFloatingChatWindow2, + /// Toggle floating chat window 3. + ToggleFloatingChatWindow3, + /// Toggle floating chat window 4. + ToggleFloatingChatWindow4, + /// Capture a screenshot (retail PrintScreen). + CaptureScreenshot, + + // ── QuickslotCommands ───────────────────────────────── + UseQuickSlot_1, + UseQuickSlot_2, + UseQuickSlot_3, + UseQuickSlot_4, + UseQuickSlot_5, + UseQuickSlot_6, + UseQuickSlot_7, + UseQuickSlot_8, + UseQuickSlot_9, + UseQuickSlot_14, + UseQuickSlot_15, + UseQuickSlot_16, + UseQuickSlot_17, + UseQuickSlot_18, + /// Drop-target shortcut creation (retail 0 / drag-drop). + CreateShortcut, + + // ── Chat ────────────────────────────────────────────── + /// Focus the chat input field (retail Tab). + ToggleChatEntry, + /// Send the current chat-input contents (retail Return). + EnterChatMode, + + // ── Combat ──────────────────────────────────────────── + /// Toggle combat-stance on/off (retail Grave / `). + CombatToggleCombat, + // Mode-dependent (dormant in K — Phase L lights them up) + CombatDecreaseAttackPower, + CombatIncreaseAttackPower, + CombatLowAttack, + CombatMediumAttack, + CombatHighAttack, + CombatDecreaseMissileAccuracy, + CombatIncreaseMissileAccuracy, + CombatAimLow, + CombatAimMedium, + CombatAimHigh, + CombatPrevSpellTab, + CombatNextSpellTab, + CombatPrevSpell, + CombatCastCurrentSpell, + CombatNextSpell, + CombatFirstSpellTab, + CombatLastSpellTab, + CombatFirstSpell, + CombatLastSpell, + UseSpellSlot_1, + UseSpellSlot_2, + UseSpellSlot_3, + UseSpellSlot_4, + UseSpellSlot_5, + UseSpellSlot_6, + UseSpellSlot_7, + UseSpellSlot_8, + UseSpellSlot_9, + + // ── Emotes ──────────────────────────────────────────── + /// Cry emote (retail U). + Cry, + /// Laugh emote (retail I). + Laugh, + /// Cheer emote — celebratory jump (retail O). + Cheer, + /// Wave emote (retail J). + Wave, + /// Point emote (retail K). + PointState, + + // ── Camera ──────────────────────────────────────────── + /// Toggle alternate camera mode (retail F2 / Numpad-/). + CameraActivateAlternateMode, + /// Hold-MMB instant mouse-look — hardcoded behavior in K.2. + CameraInstantMouseLook, + CameraRotateLeft, + CameraRotateRight, + CameraRotateUp, + CameraRotateDown, + CameraMoveToward, + CameraMoveAway, + CameraViewDefault, + CameraViewFirstPerson, + CameraViewLookDown, + CameraViewMapMode, + + // ── Scroll ──────────────────────────────────────────── + /// Scroll up — wheel up or Ctrl+Up. + ScrollUp, + /// Scroll down — wheel down or Ctrl+Down. + ScrollDown, + + // ── Mouse selection ─────────────────────────────────── + /// Single left-click select. + SelectLeft, + /// Single right-click select. + SelectRight, + /// Single middle-click select. + SelectMid, + /// Double left-click select. + SelectDblLeft, + /// Double right-click select. + SelectDblRight, + /// Double middle-click select. + SelectDblMid, + + // ── Acdream debug actions (existing F-key behavior) ── + /// F1 toggles entire DebugPanel visibility (acdream-only). + AcdreamToggleDebugPanel, + /// F2 toggles collision wireframes. + AcdreamToggleCollisionWires, + /// F3 dumps player + nearby entities to console. + AcdreamDumpNearby, + /// F7 cycles time of day. + AcdreamCycleTimeOfDay, + /// F8 decreases mouse sensitivity. + AcdreamSensitivityDown, + /// F9 increases mouse sensitivity. + AcdreamSensitivityUp, + /// F10 cycles weather. + AcdreamCycleWeather, + /// F (existing) toggles between fly camera and orbit/chase mode. + AcdreamToggleFlyMode, + /// Tab — currently toggles fly↔player mode (will be reassigned to ToggleChatEntry in K.1c). + AcdreamTogglePlayerMode, +} diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs new file mode 100644 index 0000000..d955da4 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Input; + +/// +/// Multicast event dispatcher: subscribes to an +/// + , looks up +/// the active in , and +/// fires for every match. Owns the runtime scope +/// stack — top of stack wins; when no binding matches the topmost +/// scope, lookup falls through to the scope below. +/// +/// +/// Why not LiveCommandBus? +/// is single-handler-per-type by design (one canonical recipient per +/// command). Input is fundamentally multicast: a single key press may +/// drive movement, animation, audio, and a panel toggle simultaneously. +/// The two are complementary — input fans out to many subscribers, who +/// then issue commands through the bus to one canonical recipient. +/// +/// +/// +/// K.1a wiring: GameWindow constructs a dispatcher alongside the +/// existing IsKeyPressed + event-handler paths. Nothing +/// subscribes to yet except a diagnostic console +/// logger — the dispatcher is observable but doesn't drive any +/// behavior. K.1b cuts the existing handlers over to the dispatcher's +/// action stream. +/// +/// +public sealed class InputDispatcher +{ + private readonly IKeyboardSource _keyboard; + private readonly IMouseSource _mouse; + private readonly KeyBindings _bindings; + private readonly Stack _scopes = new(); + private readonly HashSet _heldHoldChords = new(); + + /// Fires every time a binding matches a press / release / hold tick. + /// Multicast — every subscriber gets every event in subscription order. + public event Action? Fired; + + public InputDispatcher(IKeyboardSource keyboard, IMouseSource mouse, KeyBindings bindings) + { + _keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings)); + + _scopes.Push(InputScope.Always); // bottom of the stack + _scopes.Push(InputScope.Game); // default top for normal play + + _keyboard.KeyDown += OnKeyDown; + _keyboard.KeyUp += OnKeyUp; + _mouse.MouseDown += OnMouseDown; + _mouse.MouseUp += OnMouseUp; + _mouse.Scroll += OnScroll; + } + + /// Topmost scope on the stack — what the dispatcher looks up first. + public InputScope ActiveScope => _scopes.Peek(); + + /// Push a scope onto the active stack. Top wins. + public void PushScope(InputScope scope) => _scopes.Push(scope); + + /// Pop the topmost scope. is the + /// scope the caller believes is on top; mismatch throws to catch + /// unbalanced push/pop early. + public void PopScope(InputScope expected) + { + if (_scopes.Peek() != expected) + throw new InvalidOperationException( + $"PopScope expected {expected} but top is {_scopes.Peek()}"); + _scopes.Pop(); + } + + /// + /// Per-frame tick. Re-fires + /// activations for every chord that's currently held. Call once per + /// game-update tick (NOT once per render frame — Hold semantics are + /// "fire while held," not "fire every render"). + /// + public void Tick() + { + if (_mouse.WantCaptureKeyboard) return; + + // Snapshot to avoid issues if a subscriber mutates _heldHoldChords. + if (_heldHoldChords.Count == 0) return; + var snapshot = new KeyChord[_heldHoldChords.Count]; + _heldHoldChords.CopyTo(snapshot); + for (int i = 0; i < snapshot.Length; i++) + { + var chord = snapshot[i]; + var hold = _bindings.Find(chord, ActivationType.Hold); + if (hold is not null) + Fired?.Invoke(hold.Value.Action, ActivationType.Hold); + } + } + + private void OnKeyDown(Key key, ModifierMask mods) + { + if (_mouse.WantCaptureKeyboard) return; + var chord = new KeyChord(key, mods, Device: 0); + + var press = _bindings.Find(chord, ActivationType.Press); + if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press); + + var hold = _bindings.Find(chord, ActivationType.Hold); + if (hold is not null) + { + // Emit a Press transition so subscribers can latch state, then + // record the chord so Tick() will re-fire Hold every frame. + Fired?.Invoke(hold.Value.Action, ActivationType.Press); + _heldHoldChords.Add(chord); + } + } + + private void OnKeyUp(Key key, ModifierMask mods) + { + // Release fires regardless of WantCaptureKeyboard so we don't + // strand a Hold subscriber in the "held" state if the UI captured + // mid-press. + var chord = new KeyChord(key, mods, Device: 0); + + var release = _bindings.Find(chord, ActivationType.Release); + if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release); + + // Any matching Hold binding gets a Release transition. Walk the + // tracked set looking for a chord with a matching Key (ignoring + // modifiers — modifiers may have been released first). + var toRemove = new List(); + foreach (var held in _heldHoldChords) + { + if (held.Key == key && held.Device == 0) + toRemove.Add(held); + } + foreach (var held in toRemove) + { + _heldHoldChords.Remove(held); + var hold = _bindings.Find(held, ActivationType.Hold); + if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release); + } + } + + private void OnMouseDown(MouseButton button, ModifierMask mods) + { + if (_mouse.WantCaptureMouse) return; + var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); + + var press = _bindings.Find(chord, ActivationType.Press); + if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press); + + var hold = _bindings.Find(chord, ActivationType.Hold); + if (hold is not null) + { + Fired?.Invoke(hold.Value.Action, ActivationType.Press); + _heldHoldChords.Add(chord); + } + } + + private void OnMouseUp(MouseButton button, ModifierMask mods) + { + var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); + + var release = _bindings.Find(chord, ActivationType.Release); + if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release); + + var keyForLookup = MouseButtonToKey(button); + var toRemove = new List(); + foreach (var held in _heldHoldChords) + { + if (held.Key == keyForLookup && held.Device == 1) + toRemove.Add(held); + } + foreach (var held in toRemove) + { + _heldHoldChords.Remove(held); + var hold = _bindings.Find(held, ActivationType.Hold); + if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release); + } + } + + private void OnScroll(float delta) + { + if (_mouse.WantCaptureMouse) return; + // Wheel ticks emit ScrollUp / ScrollDown actions if either chord + // is bound. We don't go through KeyBindings.Find here — wheel is + // a fixed mapping for now (rebindable in K.1c). + // Empty in K.1a — no subscribers; the action is observable via + // direct subscription if a future caller wants it. + _ = delta; + } + + /// + /// Encode a Silk.NET as a + /// value for chord lookup. Reuses the high values of the Key enum + /// that Silk.NET doesn't assign meaningful keyboard codes to. This + /// is an internal-only convention; the dispatcher always sets + /// Device=1 on the resulting . + /// + public static Key MouseButtonToKey(MouseButton button) => button switch + { + MouseButton.Left => (Key)(-1001), + MouseButton.Right => (Key)(-1002), + MouseButton.Middle => (Key)(-1003), + MouseButton.Button4 => (Key)(-1004), + MouseButton.Button5 => (Key)(-1005), + _ => (Key)(-1000 - (int)button), + }; +} diff --git a/src/AcDream.UI.Abstractions/Input/InputScope.cs b/src/AcDream.UI.Abstractions/Input/InputScope.cs new file mode 100644 index 0000000..857f381 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/InputScope.cs @@ -0,0 +1,44 @@ +namespace AcDream.UI.Abstractions.Input; + +/// +/// Categorical buckets the InputDispatcher uses to gate which +/// bindings are active. Managed as a stack at runtime — the topmost +/// scope wins; if a chord doesn't match in the topmost scope the +/// dispatcher falls through to the next one down. +/// sits at the bottom of the stack and catches global chords like +/// Esc / F1 that should fire regardless of focus. +/// +/// +/// K.1a defines the enum but only pushes + +/// by default. Combat scopes light up in Phase L +/// when CombatState.CurrentMode tracking lands. +/// +/// +public enum InputScope +{ + /// Bottom of the stack — Esc, F1, F11 etc fire here so they + /// work no matter what else is focused. + Always, + /// Game world — movement, camera, targeting, hotbar. + Game, + /// Chat input has focus — scope is suspended + /// (W goes to the text field, not the player). + Chat, + /// A generic non-chat text-edit field (rebind capture, name + /// entry, etc.) has focus. + EditField, + /// A modal dialog is open and capturing input. + Dialog, + /// Combat with melee weapon equipped — Insert/PgUp/Delete/End/PgDn + /// remap to power + attack-level. Dormant until Phase L. + MeleeCombat, + /// Combat with missile weapon equipped — Insert/PgUp/Delete/End/PgDn + /// remap to accuracy + aim-level. Dormant until Phase L. + MissileCombat, + /// Magic mode — 1-9 cast UseSpellSlot; Insert/PgUp etc. + /// page through spell tabs. Dormant until Phase L. + MagicCombat, + /// Camera alternate mode (F2 / Numpad-/) — arrow keys rotate + /// the camera instead of the character. + Camera, +} diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs new file mode 100644 index 0000000..527b9f5 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Input; + +/// +/// Mutable collection of s. Owns lookup by chord +/// (for the dispatcher) and lookup by action (for the Settings UI). +/// Insertion-order preserved — first-match-wins on lookup, so a user +/// can add a custom binding ahead of a default and have it take effect +/// without removing the default. +/// +/// +/// JSON IO + the real preset land in K.1c. +/// For K.1a, proxies +/// so the bindings table matches +/// today's hard-coded chords — no behavior change during the K.1a/K.1b +/// cutover. +/// +/// +public sealed class KeyBindings +{ + private readonly List _bindings = new(); + + /// All bindings in insertion order. + public IReadOnlyList All => _bindings; + + /// Append a binding. Duplicates are allowed; first match wins. + public void Add(Binding b) => _bindings.Add(b); + + /// Remove the first occurrence of the given binding (structural equality). + public bool Remove(Binding b) => _bindings.Remove(b); + + /// Drop every binding. + public void Clear() => _bindings.Clear(); + + /// + /// First binding (in insertion order) whose chord matches AND whose + /// activation type matches the requested phase. Null if none. + /// + public Binding? Find(KeyChord chord, ActivationType activation) + { + for (int i = 0; i < _bindings.Count; i++) + { + var b = _bindings[i]; + if (b.Chord == chord && b.Activation == activation) return b; + } + return null; + } + + /// + /// All bindings for a given action (one action can have multiple keys + /// — retail's W and Up-arrow both → MovementForward). + /// + public IEnumerable ForAction(InputAction action) + { + for (int i = 0; i < _bindings.Count; i++) + if (_bindings[i].Action == action) yield return _bindings[i]; + } + + /// + /// K.1a stub: returns CURRENT acdream binds (W=fwd, S=back, A/D=turn, + /// Z/X=strafe, Shift=run, Tab=toggle player↔fly mode, F1=DebugPanel, + /// F2=collision wires, F3=dump, F7=cycle time, F8/F9=sensitivity, + /// F10=cycle weather, F=fly toggle, Space=jump). NOT the retail + /// preset — that lands in K.1c. This stub keeps behavior unchanged + /// during K.1a/K.1b cutover. + /// + public static KeyBindings AcdreamCurrentDefaults() + { + var b = new KeyBindings(); + + // Movement (current acdream — wrong for retail but unchanged for K.1a) + b.Add(new(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup)); + b.Add(new(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); + b.Add(new(new KeyChord(Key.D, ModifierMask.None), InputAction.MovementTurnRight)); + b.Add(new(new KeyChord(Key.Z, ModifierMask.None), InputAction.MovementStrafeLeft)); + b.Add(new(new KeyChord(Key.X, ModifierMask.None), InputAction.MovementStrafeRight)); + b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.None), InputAction.MovementRunLock, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump)); + + // Acdream debug binds + b.Add(new(new KeyChord(Key.F1, ModifierMask.None), InputAction.AcdreamToggleDebugPanel)); + b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.AcdreamToggleCollisionWires)); + b.Add(new(new KeyChord(Key.F3, ModifierMask.None), InputAction.AcdreamDumpNearby)); + b.Add(new(new KeyChord(Key.F7, ModifierMask.None), InputAction.AcdreamCycleTimeOfDay)); + b.Add(new(new KeyChord(Key.F8, ModifierMask.None), InputAction.AcdreamSensitivityDown)); + b.Add(new(new KeyChord(Key.F9, ModifierMask.None), InputAction.AcdreamSensitivityUp)); + b.Add(new(new KeyChord(Key.F10, ModifierMask.None), InputAction.AcdreamCycleWeather)); + b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.AcdreamToggleFlyMode)); + b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.AcdreamTogglePlayerMode)); + + return b; + } + + /// + /// K.1c will replace this with the retail-faithful preset built from + /// docs/research/named-retail/retail-default.keymap.txt. + /// For K.1a, returns the acdream-current map so behavior is unchanged. + /// + // TODO K.1c: replace with full retail preset + JSON LoadOrDefault. + public static KeyBindings RetailDefaults() => AcdreamCurrentDefaults(); +} diff --git a/src/AcDream.UI.Abstractions/Input/KeyChord.cs b/src/AcDream.UI.Abstractions/Input/KeyChord.cs new file mode 100644 index 0000000..e2aed26 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/KeyChord.cs @@ -0,0 +1,19 @@ +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Input; + +/// +/// A single key (keyboard or mouse) plus its modifier bitmask. Equality +/// is structural: two chords match iff key, modifier mask, and device +/// index all match — so Ctrl+A does not match Shift+Ctrl+A +/// or bare A. +/// +/// The primary trigger key. Encoded as a Silk.NET +/// for keyboard chords; for mouse chords the key is reinterpreted via +/// conventions and +/// is set to 1. +/// Bitmask of held modifier keys. +/// for bare key chords. +/// Retail keymap convention: 0 = keyboard, 1 = mouse, +/// 2+ = future devices. Defaults to 0 (keyboard). +public readonly record struct KeyChord(Key Key, ModifierMask Modifiers, byte Device = 0); diff --git a/src/AcDream.UI.Abstractions/Input/ModifierMask.cs b/src/AcDream.UI.Abstractions/Input/ModifierMask.cs new file mode 100644 index 0000000..55831d0 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/ModifierMask.cs @@ -0,0 +1,25 @@ +using System; + +namespace AcDream.UI.Abstractions.Input; + +/// +/// Bitmask of modifier keys held alongside a primary chord key. Bit values +/// are taken from the retail acclient.keymap "Metakeys" table: +/// LSHIFT=1, LCTRL=2, LALT=4, LWIN=8. Same numeric layout the +/// retail keymap text format writes — keeping it compatible lets us load +/// future user keymap exports byte-for-byte. +/// +[Flags] +public enum ModifierMask : uint +{ + /// No modifier held — bare key. + None = 0, + /// Shift (left or right) — retail keymap bit 1. + Shift = 0x01, + /// Control (left or right) — retail keymap bit 2. + Ctrl = 0x02, + /// Alt / Menu (left or right) — retail keymap bit 3. + Alt = 0x04, + /// Windows / GUI key — retail keymap bit 4. + Win = 0x08, +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/FakeKeyboardSource.cs b/tests/AcDream.UI.Abstractions.Tests/Input/FakeKeyboardSource.cs new file mode 100644 index 0000000..4b59296 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/FakeKeyboardSource.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +/// +/// Hand-rolled in-memory for unit tests. +/// Tests drive the surface by calling / +/// ; the dispatcher under test reacts via the +/// usual / events. +/// +internal sealed class FakeKeyboardSource : IKeyboardSource +{ + public event Action? KeyDown; + public event Action? KeyUp; + + private readonly HashSet _held = new(); + + public ModifierMask CurrentModifiers { get; set; } = ModifierMask.None; + + public bool IsHeld(Key key) => _held.Contains(key); + + public void EmitKeyDown(Key key, ModifierMask mods) + { + CurrentModifiers = mods; + _held.Add(key); + KeyDown?.Invoke(key, mods); + } + + public void EmitKeyUp(Key key, ModifierMask mods) + { + CurrentModifiers = mods; + _held.Remove(key); + KeyUp?.Invoke(key, mods); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/FakeMouseSource.cs b/tests/AcDream.UI.Abstractions.Tests/Input/FakeMouseSource.cs new file mode 100644 index 0000000..f88a9b2 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/FakeMouseSource.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +/// +/// Hand-rolled in-memory for unit tests. +/// Tests drive button + cursor + wheel events; tests can also flip +/// / +/// to simulate ImGui-focus behavior. +/// +internal sealed class FakeMouseSource : IMouseSource +{ + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; + + private readonly HashSet _held = new(); + + public bool IsHeld(MouseButton button) => _held.Contains(button); + + public bool WantCaptureMouse { get; set; } + public bool WantCaptureKeyboard { get; set; } + + public void EmitMouseDown(MouseButton button, ModifierMask mods) + { + _held.Add(button); + MouseDown?.Invoke(button, mods); + } + + public void EmitMouseUp(MouseButton button, ModifierMask mods) + { + _held.Remove(button); + MouseUp?.Invoke(button, mods); + } + + public void EmitMouseMove(float dx, float dy) => MouseMove?.Invoke(dx, dy); + public void EmitScroll(float delta) => Scroll?.Invoke(delta); +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs new file mode 100644 index 0000000..bedb5a5 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +public class InputDispatcherTests +{ + private static (InputDispatcher dispatcher, FakeKeyboardSource kb, FakeMouseSource mouse, KeyBindings bindings, List<(InputAction, ActivationType)> fired) + Build() + { + var kb = new FakeKeyboardSource(); + var mouse = new FakeMouseSource(); + var bindings = new KeyBindings(); + var dispatcher = new InputDispatcher(kb, mouse, bindings); + var fired = new List<(InputAction, ActivationType)>(); + dispatcher.Fired += (a, t) => fired.Add((a, t)); + return (dispatcher, kb, mouse, bindings, fired); + } + + [Fact] + public void KeyDown_with_matching_chord_fires_press_action() + { + var (_, kb, _, bindings, fired) = Build(); + bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + + kb.EmitKeyDown(Key.W, ModifierMask.None); + + Assert.Single(fired); + Assert.Equal((InputAction.MovementForward, ActivationType.Press), fired[0]); + } + + [Fact] + public void KeyDown_with_mismatched_modifier_does_not_fire() + { + var (_, kb, _, bindings, fired) = Build(); + bindings.Add(new Binding(new KeyChord(Key.A, ModifierMask.Ctrl), InputAction.SelectionExamine)); + + // Press A without Ctrl — no fire. + kb.EmitKeyDown(Key.A, ModifierMask.None); + Assert.Empty(fired); + + // Press A with Shift+Ctrl — also no fire (extra modifier). + kb.EmitKeyDown(Key.A, ModifierMask.Shift | ModifierMask.Ctrl); + Assert.Empty(fired); + + // Exactly Ctrl+A fires. + kb.EmitKeyDown(Key.A, ModifierMask.Ctrl); + Assert.Single(fired); + } + + [Fact] + public void WantCaptureKeyboard_suppresses_KeyDown_events() + { + var (_, kb, mouse, bindings, fired) = Build(); + bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + mouse.WantCaptureKeyboard = true; + + kb.EmitKeyDown(Key.W, ModifierMask.None); + + Assert.Empty(fired); + } + + [Fact] + public void WantCaptureMouse_suppresses_MouseDown_events() + { + var (_, _, mouse, bindings, fired) = Build(); + var leftClick = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1); + bindings.Add(new Binding(leftClick, InputAction.SelectLeft)); + mouse.WantCaptureMouse = true; + + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); + + Assert.Empty(fired); + } + + [Fact] + public void Default_active_scope_is_Game() + { + var (dispatcher, _, _, _, _) = Build(); + Assert.Equal(InputScope.Game, dispatcher.ActiveScope); + } + + [Fact] + public void PushScope_changes_ActiveScope() + { + var (dispatcher, _, _, _, _) = Build(); + dispatcher.PushScope(InputScope.Chat); + Assert.Equal(InputScope.Chat, dispatcher.ActiveScope); + } + + [Fact] + public void PopScope_with_mismatched_expected_throws() + { + var (dispatcher, _, _, _, _) = Build(); + dispatcher.PushScope(InputScope.Chat); + Assert.Throws(() => dispatcher.PopScope(InputScope.Dialog)); + } + + [Fact] + public void PopScope_restores_previous() + { + var (dispatcher, _, _, _, _) = Build(); + dispatcher.PushScope(InputScope.Chat); + dispatcher.PopScope(InputScope.Chat); + Assert.Equal(InputScope.Game, dispatcher.ActiveScope); + } + + [Fact] + public void Hold_binding_fires_Press_on_KeyDown_and_Release_on_KeyUp() + { + var (_, kb, _, bindings, fired) = Build(); + bindings.Add(new Binding( + new KeyChord(Key.ShiftLeft, ModifierMask.None), + InputAction.MovementRunLock, + ActivationType.Hold)); + + kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.None); + Assert.Contains((InputAction.MovementRunLock, ActivationType.Press), fired); + + kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.None); + Assert.Contains((InputAction.MovementRunLock, ActivationType.Release), fired); + } + + [Fact] + public void Tick_fires_Hold_for_currently_held_chords() + { + var (dispatcher, kb, _, bindings, fired) = Build(); + bindings.Add(new Binding( + new KeyChord(Key.ShiftLeft, ModifierMask.None), + InputAction.MovementRunLock, + ActivationType.Hold)); + + kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.None); + fired.Clear(); // discard the initial Press transition + + dispatcher.Tick(); + dispatcher.Tick(); + + // Two Hold ticks while held. + Assert.Equal(2, fired.Count); + Assert.All(fired, e => Assert.Equal((InputAction.MovementRunLock, ActivationType.Hold), e)); + + kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.None); + fired.Clear(); + + dispatcher.Tick(); + Assert.Empty(fired); // no longer held + } + + [Fact] + public void Release_binding_fires_only_on_KeyUp() + { + var (_, kb, _, bindings, fired) = Build(); + bindings.Add(new Binding( + new KeyChord(Key.W, ModifierMask.None), + InputAction.MovementStop, + ActivationType.Release)); + + kb.EmitKeyDown(Key.W, ModifierMask.None); + Assert.Empty(fired); + + kb.EmitKeyUp(Key.W, ModifierMask.None); + Assert.Single(fired); + Assert.Equal((InputAction.MovementStop, ActivationType.Release), fired[0]); + } + + [Fact] + public void MouseDown_with_matching_chord_fires_action() + { + var (_, _, mouse, bindings, fired) = Build(); + var leftClick = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1); + bindings.Add(new Binding(leftClick, InputAction.SelectLeft)); + + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); + + Assert.Single(fired); + Assert.Equal((InputAction.SelectLeft, ActivationType.Press), fired[0]); + } + + [Fact] + public void Tick_no_op_when_no_chords_held() + { + var (dispatcher, _, _, _, fired) = Build(); + dispatcher.Tick(); + Assert.Empty(fired); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs new file mode 100644 index 0000000..4faffa4 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs @@ -0,0 +1,106 @@ +using System.Linq; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +public class KeyBindingsTests +{ + [Fact] + public void Add_appends_to_All() + { + var b = new KeyBindings(); + var binding = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward); + b.Add(binding); + Assert.Single(b.All); + Assert.Equal(binding, b.All[0]); + } + + [Fact] + public void Remove_drops_binding() + { + var b = new KeyBindings(); + var binding = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward); + b.Add(binding); + Assert.True(b.Remove(binding)); + Assert.Empty(b.All); + } + + [Fact] + public void Clear_empties_all() + { + var b = new KeyBindings(); + b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + b.Add(new Binding(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup)); + b.Clear(); + Assert.Empty(b.All); + } + + [Fact] + public void Find_returns_matching_binding_by_chord_and_activation() + { + var b = new KeyBindings(); + var press = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward, ActivationType.Press); + var release = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward, ActivationType.Release); + b.Add(press); + b.Add(release); + + Assert.Equal(press, b.Find(new KeyChord(Key.W, ModifierMask.None), ActivationType.Press)); + Assert.Equal(release, b.Find(new KeyChord(Key.W, ModifierMask.None), ActivationType.Release)); + } + + [Fact] + public void Find_returns_null_when_no_match() + { + var b = new KeyBindings(); + b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + Assert.Null(b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press)); + } + + [Fact] + public void ForAction_returns_all_chords_bound_to_action() + { + var b = new KeyBindings(); + // Retail-style: W and Up both → MovementForward. + b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + b.Add(new Binding(new KeyChord(Key.Up, ModifierMask.None), InputAction.MovementForward)); + b.Add(new Binding(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup)); + + var forward = b.ForAction(InputAction.MovementForward).ToList(); + Assert.Equal(2, forward.Count); + Assert.Contains(forward, x => x.Chord.Key == Key.W); + Assert.Contains(forward, x => x.Chord.Key == Key.Up); + } + + [Fact] + public void AcdreamCurrentDefaults_includes_WASD_movement() + { + var b = KeyBindings.AcdreamCurrentDefaults(); + Assert.NotNull(b.Find(new KeyChord(Key.W, ModifierMask.None), ActivationType.Press)); + Assert.NotNull(b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press)); + Assert.NotNull(b.Find(new KeyChord(Key.A, ModifierMask.None), ActivationType.Press)); + Assert.NotNull(b.Find(new KeyChord(Key.D, ModifierMask.None), ActivationType.Press)); + Assert.NotNull(b.Find(new KeyChord(Key.Z, ModifierMask.None), ActivationType.Press)); + Assert.NotNull(b.Find(new KeyChord(Key.X, ModifierMask.None), ActivationType.Press)); + } + + [Fact] + public void AcdreamCurrentDefaults_binds_shift_as_hold_for_run() + { + var b = KeyBindings.AcdreamCurrentDefaults(); + var hold = b.Find(new KeyChord(Key.ShiftLeft, ModifierMask.None), ActivationType.Hold); + Assert.NotNull(hold); + Assert.Equal(InputAction.MovementRunLock, hold!.Value.Action); + } + + [Fact] + public void RetailDefaults_proxies_AcdreamCurrentDefaults_in_K1a() + { + // K.1a stub — RetailDefaults() returns the acdream-current binds so + // K.1b cutover doesn't change behavior. K.1c flips this to the + // retail preset. + var retail = KeyBindings.RetailDefaults(); + var current = KeyBindings.AcdreamCurrentDefaults(); + Assert.Equal(current.All.Count, retail.All.Count); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyChordTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyChordTests.cs new file mode 100644 index 0000000..9ffcf0c --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyChordTests.cs @@ -0,0 +1,54 @@ +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +public class KeyChordTests +{ + [Fact] + public void Same_key_no_modifier_equal() + { + var a = new KeyChord(Key.W, ModifierMask.None); + var b = new KeyChord(Key.W, ModifierMask.None); + Assert.Equal(a, b); + } + + [Fact] + public void CtrlA_does_not_match_bare_A() + { + var ctrlA = new KeyChord(Key.A, ModifierMask.Ctrl); + var bareA = new KeyChord(Key.A, ModifierMask.None); + Assert.NotEqual(ctrlA, bareA); + } + + [Fact] + public void CtrlA_does_not_match_ShiftCtrlA() + { + var ctrlA = new KeyChord(Key.A, ModifierMask.Ctrl); + var shiftCtrlA = new KeyChord(Key.A, ModifierMask.Shift | ModifierMask.Ctrl); + Assert.NotEqual(ctrlA, shiftCtrlA); + } + + [Fact] + public void Different_keys_with_same_modifier_distinct() + { + Assert.NotEqual( + new KeyChord(Key.A, ModifierMask.Shift), + new KeyChord(Key.B, ModifierMask.Shift)); + } + + [Fact] + public void Default_device_is_zero_keyboard() + { + var c = new KeyChord(Key.W, ModifierMask.None); + Assert.Equal(0, c.Device); + } + + [Fact] + public void Different_device_with_same_key_distinct() + { + var keyboard = new KeyChord(Key.A, ModifierMask.None, Device: 0); + var mouse = new KeyChord(Key.A, ModifierMask.None, Device: 1); + Assert.NotEqual(keyboard, mouse); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/ModifierMaskTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/ModifierMaskTests.cs new file mode 100644 index 0000000..43bdc29 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/ModifierMaskTests.cs @@ -0,0 +1,42 @@ +using AcDream.UI.Abstractions.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +public class ModifierMaskTests +{ + [Fact] + public void None_is_zero() + { + Assert.Equal((uint)0, (uint)ModifierMask.None); + } + + [Fact] + public void Bit_values_match_retail_keymap_metakey_table() + { + // Retail's "Metakeys" table: LSHIFT=1, LCTRL=2, LALT=4, LWIN=8. + // The retail keymap text format writes these as bit-flags so a + // user editing acclient.keymap by hand sees the same values. + Assert.Equal((uint)0x01, (uint)ModifierMask.Shift); + Assert.Equal((uint)0x02, (uint)ModifierMask.Ctrl); + Assert.Equal((uint)0x04, (uint)ModifierMask.Alt); + Assert.Equal((uint)0x08, (uint)ModifierMask.Win); + } + + [Fact] + public void Or_combines_bits() + { + var combo = ModifierMask.Shift | ModifierMask.Ctrl; + Assert.True(combo.HasFlag(ModifierMask.Shift)); + Assert.True(combo.HasFlag(ModifierMask.Ctrl)); + Assert.False(combo.HasFlag(ModifierMask.Alt)); + } + + [Fact] + public void Equality_distinguishes_distinct_masks() + { + Assert.NotEqual(ModifierMask.Shift, ModifierMask.Ctrl); + Assert.NotEqual(ModifierMask.None, ModifierMask.Shift); + Assert.Equal(ModifierMask.Shift | ModifierMask.Ctrl, + ModifierMask.Ctrl | ModifierMask.Shift); + } +}