From af74eac0c21e601a48bca8a68d1400b68b1ea11b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 09:20:17 +0200 Subject: [PATCH] feat(input): #24 Phase K.2 - auto-enter player mode at login + MMB mouse-look + DebugPanel free-fly + Tab to chat-input focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five changes: 1. PlayerModeAutoEntry — testable guard class that fires once after EnterWorld + WorldSession.State.InWorld + player entity present + PlayerController.State == InWorld. GameWindow arms the entry after EnterWorld; per-frame Tick checks all four guards and invokes the same fly-to-player transition the Tab handler runs. User-initiated fly toggle (DebugPanel button) Cancel()s pending entry. Skip in offline mode (no ACDREAM_LIVE) — Holtburg orbit stays default for testing. 2. MouseLookState + KeyBindings.RetailDefaults() binds MMB Hold to InputAction.CameraInstantMouseLook. GameWindow subscribes: - Press: hide cursor, capture position, _mouseLookActive = true. - Release: restore cursor, deactivate. - WantCaptureMouse=true while held → suspend (release cursor). - MouseMove while active: combined drive — chase camera yaw + character heading move together (retail's signature mouse-look behavior). Camera Y still pitches camera-only. 3. DebugPanel "Toggle Free-Fly Mode" button via DebugVM.ToggleFlyMode action delegate — replaces the F-key as the primary discovery path for free-fly. Gated on DevToolsEnabled. 4. ChatPanel.FocusInput() one-shot + IPanelRenderer.SetKeyboardFocusHere primitive. GameWindow's ToggleChatEntry (Tab) subscriber calls _chatPanel.FocusInput() so Tab moves focus to the chat input field. Replaces the K.1c TODO stub. 5. WantCaptureMouse gating reinforcement on surviving mouse handlers (no new code; verified intact from K.1b). 21 new tests (8 PlayerModeAutoEntry, 10 MouseLookState, 3 ChatPanel focus). 1183 total green. 0 warnings, 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Input/PlayerModeAutoEntry.cs | 115 ++++++ src/AcDream.App/Rendering/GameWindow.cs | 352 ++++++++++++++---- src/AcDream.UI.Abstractions/IPanelRenderer.cs | 8 + .../Input/MouseLookState.cs | 124 ++++++ .../Panels/Chat/ChatPanel.cs | 26 ++ .../Panels/Debug/DebugPanel.cs | 6 + .../Panels/Debug/DebugVM.cs | 9 + src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs | 3 + .../Input/AutoEnterPlayerModeTests.cs | 155 ++++++++ .../FakePanelRenderer.cs | 3 + .../Input/MmbMouseLookTests.cs | 166 +++++++++ .../Panels/Chat/ChatPanelFocusTests.cs | 71 ++++ 12 files changed, 972 insertions(+), 66 deletions(-) create mode 100644 src/AcDream.App/Input/PlayerModeAutoEntry.cs create mode 100644 src/AcDream.UI.Abstractions/Input/MouseLookState.cs create mode 100644 tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Input/MmbMouseLookTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs diff --git a/src/AcDream.App/Input/PlayerModeAutoEntry.cs b/src/AcDream.App/Input/PlayerModeAutoEntry.cs new file mode 100644 index 0000000..4a7f8b4 --- /dev/null +++ b/src/AcDream.App/Input/PlayerModeAutoEntry.cs @@ -0,0 +1,115 @@ +using System; + +namespace AcDream.App.Input; + +/// +/// Phase K.2 — one-shot guard that auto-enters player mode after a +/// successful login once every prerequisite is satisfied. Owned by +/// GameWindow and ticked each frame from OnUpdate. +/// +/// +/// Why is this its own class? The auto-entry has three independent +/// preconditions (live session reaches InWorld, the player +/// entity has been streamed into the world dictionary, and the player +/// movement controller is constructible) plus a manual-override path +/// (the user can flip into fly mode before the auto-entry fires — +/// their choice wins). All four interact with each other in a way +/// that's painful to test through GameWindow but trivial here against +/// fakes. +/// +/// +/// +/// The public surface is: +/// +/// — call after EnterWorld succeeds to +/// arm the entry trigger. +/// — call when the user manually enters +/// fly mode (or any other code path that pre-empts the auto-entry). +/// — call once per frame; runs the +/// guard and fires the entry callback when armed AND every +/// precondition is satisfied; returns true on the firing tick. +/// +/// +/// +/// +/// All preconditions are passed in as predicates so the class doesn't +/// pull in WorldSession, PlayerMovementController, or +/// any GameWindow-internal types — the unit test wires them to plain +/// boolean fields. +/// +/// +public sealed class PlayerModeAutoEntry +{ + private readonly Func _isLiveInWorld; + private readonly Func _isPlayerEntityPresent; + private readonly Func _isPlayerControllerReady; + private readonly Action _enterPlayerMode; + + private bool _armed; + + /// + /// Build an auto-entry guard. + /// + /// True iff the live session is in the + /// InWorld state. Skip auto-entry when the session is null + /// or hasn't reached InWorld yet. + /// True iff the player's + /// server-guid is already in the local entity dictionary (server + /// has streamed at least one CreateObject for the character). + /// True iff the per-frame + /// PlayerMovementController is set up. Stays true once player mode + /// is established; the auto-entry's job is to flip it from false + /// to true exactly once. + /// Action invoked on the firing + /// tick. The same routine the manual Tab handler invokes (fly → + /// player transition). Must construct the controller + chase + /// camera and switch the active camera; the auto-entry doesn't + /// reach inside. + public PlayerModeAutoEntry( + Func isLiveInWorld, + Func isPlayerEntityPresent, + Func isPlayerControllerReady, + Action enterPlayerMode) + { + _isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld)); + _isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent)); + _isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady)); + _enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode)); + } + + /// True iff would still fire if the + /// preconditions become true. Flips false on a successful entry + /// (one-shot) or when is invoked. + public bool IsArmed => _armed; + + /// + /// Arm the trigger. Call after WorldSession.EnterWorld + /// returns successfully. Calling again while already armed is a + /// no-op. + /// + public void Arm() => _armed = true; + + /// + /// Disarm the trigger without firing the callback. Call when the + /// user has manually entered fly mode (or any other code path + /// that pre-empts the auto-entry) — the user's choice wins. + /// + public void Cancel() => _armed = false; + + /// + /// Guard tick. If the trigger is armed AND every precondition is + /// satisfied, invokes enterPlayerMode, disarms, and + /// returns true. Returns false otherwise (no side effects). + /// + public bool TryEnter() + { + if (!_armed) return false; + if (!_isLiveInWorld()) return false; + if (!_isPlayerEntityPresent()) return false; + if (!_isPlayerControllerReady()) return false; + + _armed = false; + _enterPlayerMode(); + return true; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e4a2731..bcb41d7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -403,6 +403,30 @@ public sealed class GameWindow : IDisposable // the orbited position (no snap back). private bool _rmbHeld; + // Phase K.2 — auto-enter player mode after a successful login. Armed + // by the EnterWorld branch in BeginLiveSessionAsync; ticked from + // OnUpdate; disarmed if the user manually enters fly mode (or any + // other path that pre-empts the chase camera). Skipped entirely + // offline (orbit camera stays the foreground). The class internally + // tracks IsArmed; we read it via the guard rather than mirroring + // the bool here. + private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry; + + // Phase K.2 — MMB-hold instant mouse-look state. Live throughout + // the session; flips Active on Press/Release. Defense-in-depth on + // ImGui's WantCaptureMouse — the dispatcher already filters, but + // OnWantCaptureMouseChanged also suspends the state if a panel + // claims focus mid-hold. + private AcDream.UI.Abstractions.Input.MouseLookState? _mouseLook; + // Tracks the previous WantCaptureMouse value so we can fire the + // changed-edge callback once per transition (vs every frame). + private bool _lastWantCaptureMouse; + // Cursor mode prior to entering MMB mouse-look. Restored on + // release so the user lands back in the same camera mode as + // before (raw for chase/fly, normal for orbit). Set non-null while + // mouse-look is active. + private Silk.NET.Input.CursorMode? _mouseLookSavedCursorMode; + // Phase K.1b — single input path. Every keyboard/mouse-button reaction // flows through InputDispatcher.Fired (see OnInputAction below) or // IsActionHeld (per-frame polling for movement). The legacy direct @@ -543,6 +567,31 @@ public sealed class GameWindow : IDisposable _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( _kbSource, _mouseSource, _keyBindings); _inputDispatcher.Fired += OnInputAction; + + // Phase K.2 — MMB-hold instant mouse-look. The yaw mutator + // drives _playerController.Yaw (the chase camera reads this + // automatically via ChaseCamera.Update). Active only while + // _playerController and _chaseCamera are live; the lambda + // safely no-ops outside player mode. + _mouseLook = new AcDream.UI.Abstractions.Input.MouseLookState( + applyYawDelta: dYaw => + { + if (_playerController is not null) _playerController.Yaw += dYaw; + }); + + // Phase K.2 — auto-enter player mode after EnterWorld + // succeeds. Predicates close over GameWindow state; the + // entry callback flips into player mode via the same code + // path TogglePlayerMode uses, just without the early-return + // when the entity isn't ready (the third predicate + // guarantees readiness before this fires). + _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( + isLiveInWorld: () => _liveSession is not null + && _liveSession.CurrentState == + AcDream.Core.Net.WorldSession.State.InWorld, + isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid), + isPlayerControllerReady: () => true, + enterPlayerMode: EnterPlayerModeFromAutoEntry); } // Mouse delta handler — kept direct because Silk.NET delivers mouse @@ -573,23 +622,35 @@ public sealed class GameWindow : IDisposable if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null) { float sens = _sensChase; - if (_rmbHeld) + if (_mouseLook is not null && _mouseLook.Active) + { + // Phase K.2 — MMB instant mouse-look. dx drives + // character yaw via the MouseLookState callback + // (which mutates _playerController.Yaw); the chase + // camera tracks the character automatically because + // ChaseCamera.Update reads the player yaw. So mouse-X + // here goes ONLY through ApplyDelta — no separate + // YawOffset write. dy still pitches the camera only. + _mouseLook.ApplyDelta(dx, sens); + _chaseCamera.AdjustPitch(dy * 0.003f * sens); + } + else if (_rmbHeld) { // Hold-RMB orbit: player stays the central point, camera // free-orbits around. X rotates around, Y pitches. On release // the camera STAYS at the new angle (no snap back). // K.1b: this is the ONLY mouse-delta path that affects // ANYTHING when in player mode — character yaw is - // dispatcher-only (A/D keys). MMB mouse-look comes - // back as hardcoded behavior in K.2. + // dispatcher-only (A/D keys). K.2: MMB mouse-look path + // above takes precedence when active. _chaseCamera.YawOffset -= dx * 0.004f * sens; _chaseCamera.AdjustPitch(dy * 0.003f * sens); } else { - // Without RMB held, mouse only pitches the chase - // camera (Y-axis). Mouse X is dropped — character - // turning is keyboard-only in K.1b. + // Without RMB or MMB held, mouse only pitches the + // chase camera (Y-axis). Mouse X is dropped — + // character turning is keyboard-only. _chaseCamera.AdjustPitch(dy * 0.003f * sens); } } @@ -743,8 +804,8 @@ public sealed class GameWindow : IDisposable FpsProvider = () => (float)_lastFps, PositionProvider = () => GetDebugPlayerPosition(), }; - _panelHost.Register( - new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm)); + _chatPanel = new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm); + _panelHost.Register(_chatPanel); // Phase I.2: DebugPanel — replaces the deleted custom // DebugOverlay (six floating panels + hint bar + toast). @@ -786,6 +847,19 @@ public sealed class GameWindow : IDisposable _debugVm.CycleTimeOfDay = CycleTimeOfDay; _debugVm.CycleWeather = CycleWeather; _debugVm.ToggleCollisionWires = ToggleCollisionWires; + // Phase K.2: free-fly toggle button — same routine the + // legacy F-key alias hits. Cancels the one-shot + // auto-entry if the user opts out of player mode before + // it fires, so the chase camera doesn't snap on top of + // the fly camera mid-inspection. + _debugVm.ToggleFlyMode = () => + { + // K.2: manual fly toggle pre-empts the auto-entry + // trigger (user's choice wins). Cancel is no-op when + // not yet armed. + _playerModeAutoEntry?.Cancel(); + _cameraController?.ToggleFly(); + }; _debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm); _panelHost.Register(_debugPanel); @@ -800,6 +874,7 @@ public sealed class GameWindow : IDisposable _vitalsVm = null; _debugVm = null; _debugPanel = null; + _chatPanel = null; } } @@ -1252,6 +1327,13 @@ public sealed class GameWindow : IDisposable _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); _liveSession.EnterWorld(user, characterIndex: 0); + // Phase K.2: arm auto-entry. The guard's predicates won't + // pass yet — the entity stream hasn't started — but the + // OnUpdate tick re-checks every frame and fires once + // everything converges (typically 100-300 ms after EnterWorld + // returns). User can pre-empt via DebugPanel "Toggle + // Free-Fly Mode" or Tab; both call Cancel() first. + _playerModeAutoEntry?.Arm(); Console.WriteLine($"live: in world — CreateObject stream active " + $"(so far: {_liveSpawnReceived} received, {_liveSpawnHydrated} hydrated)"); } @@ -3605,6 +3687,31 @@ public sealed class GameWindow : IDisposable // that actually consume the events. _inputDispatcher?.Tick(); + // Phase K.2 — re-evaluate WantCaptureMouse for the MMB + // mouse-look state machine. Detect rising/falling edges so the + // state suspends correctly when ImGui claims the cursor while + // MMB is held (e.g. a tooltip pop-up over the cursor). When the + // suspend deactivates an active session, restore the cursor so + // it doesn't get stuck hidden under a panel. + if (_mouseLook is not null) + { + bool wcm = DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse; + if (wcm != _lastWantCaptureMouse) + { + bool wasActive = _mouseLook.Active; + _mouseLook.OnWantCaptureMouseChanged(wcm); + if (wasActive && !_mouseLook.Active) RestoreCursorAfterMouseLook(); + _lastWantCaptureMouse = wcm; + } + } + + // Phase K.2 — auto-enter player mode at login. The guard + // returns true on the firing tick (one-shot); subsequent ticks + // are no-ops. Skipped offline (no _liveSession → IsLiveInWorld + // predicate stays false). Cancelled by manual fly-toggle in + // OnInputAction (Ctrl+Tab → TogglePlayerMode) or DebugPanel. + _playerModeAutoEntry?.TryEnter(); + if (_cameraController is null || _input is null) return; // Phase D.2a / K.1b — suppress game-side input polling when ImGui @@ -4936,6 +5043,11 @@ public sealed class GameWindow : IDisposable // in the DevToolsEnabled construction block above; null otherwise. private AcDream.UI.Abstractions.Panels.Debug.DebugPanel? _debugPanel; + // Cached chat-panel reference so the dispatcher's ToggleChatEntry + // (Tab) handler can call FocusInput() programmatically. Set in the + // DevToolsEnabled construction block; null otherwise. + private AcDream.UI.Abstractions.Panels.Chat.ChatPanel? _chatPanel; + // ── K.1b: dispatcher action handler ────────────────────────────────── // // SINGLE place where every game-side keyboard/mouse-button reaction @@ -4972,6 +5084,29 @@ public sealed class GameWindow : IDisposable return; } + // Phase K.2 — MMB-hold instant mouse-look. Press hides the + // cursor + activates yaw drive; release restores. WantCapture + // edge handling lives in OnUpdate; only Press needs to read it + // for the initial gate (defense in depth — the dispatcher + // already filters on WantCaptureMouse in OnMouseDown). + if (action == AcDream.UI.Abstractions.Input.InputAction.CameraInstantMouseLook) + { + if (_mouseLook is null) return; + if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press) + { + bool wcm = DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse; + _mouseLook.Press(_lastMouseX, _lastMouseY, wcm); + if (_mouseLook.Active) HideCursorForMouseLook(); + } + else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release) + { + bool wasActive = _mouseLook.Active; + _mouseLook.Release(); + if (wasActive) RestoreCursorAfterMouseLook(); + } + return; + } + // ScrollUp / ScrollDown — emit by InputDispatcher.OnScroll on every // wheel tick. Press is the only activation type for wheel. if (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp @@ -5031,12 +5166,13 @@ public sealed class GameWindow : IDisposable break; case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry: - // K.1c: Tab in retail focuses the chat input. Phase K.2 - // wires this to ChatPanel.FocusInput() once the panel grows - // an explicit focus method; the current ImGui-backed chat - // panel takes focus on click. Press is logged via the - // [input] diagnostic above so the cutover is observable. - // TODO K.2: call _chatPanel.FocusInput() once available. + // K.2: Tab focuses the chat input. ChatPanel.FocusInput() + // sets a one-shot flag that emits SetKeyboardFocusHere on + // the next render. No-op when devtools/_chatPanel is null + // (offline / non-devtools build) — the dispatcher still + // logs the action via the [input] diagnostic above so the + // path is observable in either case. + _chatPanel?.FocusInput(); break; case AcDream.UI.Abstractions.Input.InputAction.EscapeKey: @@ -5062,6 +5198,8 @@ public sealed class GameWindow : IDisposable /// the legacy direct keyboard handler — toggle player↔fly mode, set /// up + /// when entering player mode, tear them down on exit. + /// K.2: also disarms the auto-entry trigger when the user toggles + /// manually (their choice wins). /// private void TogglePlayerMode() { @@ -5070,62 +5208,16 @@ public sealed class GameWindow : IDisposable || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) return; + // Manual toggle pre-empts the K.2 auto-entry trigger regardless + // of direction — entering means "I'm in player mode now"; exiting + // means "I want fly, don't snap me back". + _playerModeAutoEntry?.Cancel(); + _playerMode = !_playerMode; if (_playerMode) { - if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)) - { - _playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine); - // Read the real step height from the player's Setup dat. - if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) - { - var playerSetup = _dats.Get(playerEntity.SourceGfxObjOrSetupId); - if (playerSetup is not null) - _physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup); - _playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f) - ? playerSetup.StepUpHeight - : 2f; - } - else - { - _playerController.StepUpHeight = 2f; - } - int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f); - int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f); - uint pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u; - var initResult = _physicsEngine.Resolve( - playerEntity.Position, pinitCellId & 0xFFFFu, - System.Numerics.Vector3.Zero, 100f); - _playerController.SetPosition(initResult.Position, initResult.CellId); - - if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE) - && playerAE.Sequencer is { } playerSeq) - { - _playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity); - } - - var q = playerEntity.Rotation; - float rawYaw = MathF.Atan2( - 2f * (q.W * q.Z + q.X * q.Y), - 1f - 2f * (q.Y * q.Y + q.Z * q.Z)); - _playerController.Yaw = rawYaw + MathF.PI / 2f; - - _chaseCamera = new AcDream.App.Rendering.ChaseCamera - { - Aspect = _window!.Size.X / (float)_window.Size.Y, - }; - // K.1b: _playerMouseDeltaX is no longer consumed by - // MovementInput, but we still reset it here so any stale - // accumulated value from a previous session doesn't leak - // into a future code path that re-enables mouse-yaw. - _playerMouseDeltaX = 0f; - _cameraController?.EnterChaseMode(_chaseCamera); - } - else - { + if (!EnterPlayerModeNow(loggingTag: "Tab")) _playerMode = false; - Console.WriteLine($"live: Tab pressed but player entity 0x{_playerServerGuid:X8} not found yet"); - } } else { @@ -5137,6 +5229,134 @@ public sealed class GameWindow : IDisposable } } + /// + /// K.2: callback the + /// guard invokes once login + entity stream + controller readiness + /// have all converged. Sets _playerMode = true and runs the + /// same construction path the manual Tab handler uses. Predicates on + /// the guard already guarantee _entitiesByServerGuid contains + /// the player guid, so the inner TryGetValue is a fast-path success. + /// + private void EnterPlayerModeFromAutoEntry() + { + _playerMode = true; + if (!EnterPlayerModeNow(loggingTag: "auto-entry")) + { + // Defense in depth: if construction failed (e.g. entity + // disappeared between predicate eval and here) drop back + // out cleanly. Re-arm so a later Tab still works. + _playerMode = false; + } + else + { + Console.WriteLine($"live: auto-entered player mode for 0x{_playerServerGuid:X8}"); + } + } + + /// + /// K.2: shared "construct controller + chase camera + enter chase + /// mode" body extracted from the on-enter branch of + /// . Returns false when the player + /// entity isn't in _entitiesByServerGuid yet — caller must + /// reset _playerMode in that case. + /// + private bool EnterPlayerModeNow(string loggingTag) + { + if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)) + { + Console.WriteLine($"live: {loggingTag} — player entity 0x{_playerServerGuid:X8} not found yet"); + return false; + } + + _playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine); + // Read the real step height from the player's Setup dat. + if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) + { + var playerSetup = _dats.Get(playerEntity.SourceGfxObjOrSetupId); + if (playerSetup is not null) + _physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup); + _playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f) + ? playerSetup.StepUpHeight + : 2f; + } + else + { + _playerController.StepUpHeight = 2f; + } + int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f); + int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f); + uint pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u; + var initResult = _physicsEngine.Resolve( + playerEntity.Position, pinitCellId & 0xFFFFu, + System.Numerics.Vector3.Zero, 100f); + _playerController.SetPosition(initResult.Position, initResult.CellId); + + if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE) + && playerAE.Sequencer is { } playerSeq) + { + _playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity); + } + + var q = playerEntity.Rotation; + float rawYaw = MathF.Atan2( + 2f * (q.W * q.Z + q.X * q.Y), + 1f - 2f * (q.Y * q.Y + q.Z * q.Z)); + _playerController.Yaw = rawYaw + MathF.PI / 2f; + + _chaseCamera = new AcDream.App.Rendering.ChaseCamera + { + Aspect = _window!.Size.X / (float)_window.Size.Y, + }; + // K.1b: _playerMouseDeltaX is no longer consumed by + // MovementInput, but we still reset it here so any stale + // accumulated value from a previous session doesn't leak + // into a future code path that re-enables mouse-yaw. + _playerMouseDeltaX = 0f; + _cameraController?.EnterChaseMode(_chaseCamera); + return true; + } + + /// + /// Phase K.2: hide the system cursor while MMB instant mouse-look is + /// held. Saves the previous CursorMode so + /// can put it back exactly. Skips when no mouse / no input — tests + /// and headless runs stay clean. + /// + private void HideCursorForMouseLook() + { + if (_input is null) return; + var mouse = _input.Mice.FirstOrDefault(); + if (mouse is null) return; + // Save previous mode (Normal in orbit, Raw in chase/fly) so the + // exact pre-hold mode is restored on release. + _mouseLookSavedCursorMode = mouse.Cursor.CursorMode; + mouse.Cursor.CursorMode = CursorMode.Hidden; + } + + /// + /// Phase K.2: restore the saved cursor mode after MMB instant + /// mouse-look ends. Called from the Release branch and from the + /// WantCaptureMouse-edge suspend path so the cursor never gets + /// stuck hidden. + /// + private void RestoreCursorAfterMouseLook() + { + if (_input is null) return; + var mouse = _input.Mice.FirstOrDefault(); + if (mouse is null) return; + if (_mouseLookSavedCursorMode is { } saved) + { + mouse.Cursor.CursorMode = saved; + _mouseLookSavedCursorMode = null; + } + else + { + // Defense in depth: never observed the saved value, fall + // back to Normal so the user always gets a visible cursor. + mouse.Cursor.CursorMode = CursorMode.Normal; + } + } + /// /// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies /// the currently-active mode's sensitivity (chase / fly / orbit) by the diff --git a/src/AcDream.UI.Abstractions/IPanelRenderer.cs b/src/AcDream.UI.Abstractions/IPanelRenderer.cs index 0c7c311..ff45550 100644 --- a/src/AcDream.UI.Abstractions/IPanelRenderer.cs +++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs @@ -198,4 +198,12 @@ public interface IPanelRenderer /// to force a scroll. /// void SetScrollHereY(float ratio); + + /// + /// Request keyboard focus for the NEXT widget rendered. Used by + /// ChatPanel when Tab fires ToggleChatEntry — the + /// chat input gets focused programmatically so the user can begin + /// typing without clicking the field. + /// + void SetKeyboardFocusHere(); } diff --git a/src/AcDream.UI.Abstractions/Input/MouseLookState.cs b/src/AcDream.UI.Abstractions/Input/MouseLookState.cs new file mode 100644 index 0000000..09c1385 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/MouseLookState.cs @@ -0,0 +1,124 @@ +using System; + +namespace AcDream.UI.Abstractions.Input; + +/// +/// Phase K.2 — state machine for MMB-hold "instant mouse-look" mode +/// (retail's CameraInstantMouseLook). While active, mouse-X +/// delta drives the character's heading AND the chase camera yaw +/// together (combined drive — the camera "instantly" follows the +/// character because mouse-X moves the character, and the chase +/// camera always tracks the character). Mouse-Y is left to the +/// caller — typically pitches the chase camera only. +/// +/// +/// The class owns three transitions: +/// +/// — MMB pressed AND ImGui isn't hovering a +/// panel; activate, capture initial cursor position for restore. +/// — MMB released; deactivate, signal +/// cursor restore. +/// — ImGui took mouse +/// focus while we were active (e.g. a panel pop-up); deactivate AS IF +/// the user released the button so the cursor is restored. +/// +/// +/// +/// +/// is the per-frame mouse-move hook: when +/// active, scales by sensitivity and feeds the +/// caller-supplied yaw mutator. The mutator is the only side-channel — +/// the class doesn't know about PlayerMovementController or +/// ChaseCamera. +/// +/// +public sealed class MouseLookState +{ + private readonly Action _applyYawDelta; + + /// True while MMB is held AND ImGui isn't capturing the + /// mouse. Mouse-X deltas drive yaw only when this is true. + public bool Active { get; private set; } + + /// Cursor X at the moment activated. + /// Restore on release. + public float CapturedCursorX { get; private set; } + + /// Cursor Y at the moment activated. + public float CapturedCursorY { get; private set; } + + /// + /// Per-radian yaw multiplier applied to mouse-X delta. The same + /// chase-camera sensitivity factor used elsewhere in GameWindow is + /// folded in by the caller; this class only owns the constant + /// scale that converts pixels to radians. Default 0.004 matches + /// the K.1b RMB-orbit factor. + /// + public float SensitivityRadiansPerPixel { get; set; } = 0.004f; + + public MouseLookState(Action applyYawDelta) + { + _applyYawDelta = applyYawDelta ?? throw new ArgumentNullException(nameof(applyYawDelta)); + } + + /// + /// MMB press transition. Activates only when ImGui isn't capturing + /// the mouse — the dispatcher should already gate this, but the + /// guard adds defense in depth in case a binding fires through. + /// + /// Current cursor X (captured for restore on release). + /// Current cursor Y. + /// Mirror of + /// ImGui.GetIO().WantCaptureMouse. When true, the press is + /// ignored so a hover-over-panel MMB doesn't grab the cursor. + public void Press(float cursorX, float cursorY, bool wantCaptureMouse) + { + if (wantCaptureMouse) return; + if (Active) return; + Active = true; + CapturedCursorX = cursorX; + CapturedCursorY = cursorY; + } + + /// MMB release transition. Always deactivates if active. + public void Release() + { + if (!Active) return; + Active = false; + } + + /// + /// Reactive deactivation when ImGui takes mouse focus mid-hold. + /// E.g. a tooltip pops open over the cursor while MMB is held — + /// we yield the cursor to the panel instead of staying captured. + /// + public void OnWantCaptureMouseChanged(bool wantCaptureMouse) + { + if (Active && wantCaptureMouse) Active = false; + } + + /// + /// Apply a per-frame mouse-X delta. When active, scales by + /// times the + /// caller-supplied (typically + /// the chase-camera sens) and feeds it to the yaw mutator. Sign + /// matches retail: dragging the mouse RIGHT yaws the character to + /// the right (positive yaw delta). + /// + /// Pixels of horizontal mouse motion since the + /// last frame. + /// Multiplier (e.g. chase-camera + /// sensitivity) applied on top of the radians-per-pixel scale. + public void ApplyDelta(float dx, float extraSensitivity) + { + if (!Active) return; + // Sign: dragging the mouse RIGHT (dx > 0) should yaw the + // character to the right. With the acdream Yaw convention + // (where Yaw 0 = +X, increasing to +Y), positive yaw is + // counter-clockwise viewed top-down — so dragging right means + // yaw goes DOWN (more clockwise). The dispatcher convention + // for chase YawOffset is `YawOffset -= dx * factor`; we keep + // the same sign here so character + camera rotate identically. + _applyYawDelta(-dx * SensitivityRadiansPerPixel * extraSensitivity); + } +} diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index fe8939f..f3a4a07 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -42,6 +42,14 @@ public sealed class ChatPanel : IPanel // entries without yanking the user's manual scroll. private int _lastRenderedCount; + // Phase K.2: one-shot focus request for the chat input. Set by + // FocusInput() (driven by Tab → ToggleChatEntry); the next Render + // call emits SetKeyboardFocusHere immediately before the input + // field and clears the flag. Without the one-shot semantics, the + // panel would steal focus on every frame and the user could never + // click into another widget. + private bool _focusRequested; + public ChatPanel(ChatVM vm) { _vm = vm ?? throw new ArgumentNullException(nameof(vm)); @@ -56,6 +64,15 @@ public sealed class ChatPanel : IPanel /// public bool IsVisible { get; set; } = true; + /// + /// Phase K.2: request keyboard focus for the chat input on the + /// NEXT . One-shot — fires once and resets, + /// so callers (e.g. GameWindow's Tab handler subscribing to + /// ToggleChatEntry) can drive it on a single key press + /// without trapping the user permanently in the input field. + /// + public void FocusInput() => _focusRequested = true; + /// public void Render(PanelContext ctx, IPanelRenderer renderer) { @@ -115,6 +132,15 @@ public sealed class ChatPanel : IPanel // Phase I.4: input field. Backend implementation clears _input // on submit per the IPanelRenderer contract. renderer.Separator(); + // Phase K.2: honor a pending FocusInput() request — emit + // SetKeyboardFocusHere immediately before the input widget so + // ImGui (or the future custom backend) applies it to that + // field. One-shot: clear the flag after firing. + if (_focusRequested) + { + renderer.SetKeyboardFocusHere(); + _focusRequested = false; + } if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) && submitted is not null) { diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs index 076faa2..e996a0d 100644 --- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs @@ -211,6 +211,12 @@ public sealed class DebugPanel : IPanel r.SameLine(); if (r.Button("Toggle collision wires")) _vm.ToggleCollisionWires?.Invoke(); + // Phase K.2 — explicit free-fly toggle button. Mirrors the + // legacy F-key alias but is discoverable to users who haven't + // memorized the Ctrl+F* debug bindings. Action handle owned + // by GameWindow; null-safe for tests / offline. + if (r.Button("Toggle Free-Fly Mode")) _vm.ToggleFlyMode?.Invoke(); + r.Text(_vm.DebugWireframes ? "collision wires: ON" : "collision wires: OFF"); } diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs index 7765f95..9e914af 100644 --- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs @@ -255,6 +255,15 @@ public sealed class DebugVM /// public Action? ToggleCollisionWires { get; set; } + /// + /// Phase K.2 — toggle the free-fly camera. Lets a user opt out of + /// the auto-entered chase camera (e.g. to inspect a remote part of + /// the world without the player following) without needing to find + /// the Ctrl+F* debug binding. Wired by GameWindow to the + /// same routine the legacy F-key fly toggle invokes. + /// + public Action? ToggleFlyMode { get; set; } + // ── Combat event ring + toast ring ───────────────────────────────── /// diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs index 671e7b2..b18eec2 100644 --- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -170,4 +170,7 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer /// public void SetScrollHereY(float ratio) => ImGuiNET.ImGui.SetScrollHereY(ratio); + + /// + public void SetKeyboardFocusHere() => ImGuiNET.ImGui.SetKeyboardFocusHere(); } diff --git a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs new file mode 100644 index 0000000..aa1463c --- /dev/null +++ b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs @@ -0,0 +1,155 @@ +using AcDream.App.Input; + +namespace AcDream.Core.Tests.Input; + +/// +/// Phase K.2 — guard logic for auto-entering player mode at login. +/// The trigger fires exactly once when: +/// +/// It has been 'd (login +/// succeeded). +/// The live session is in InWorld. +/// The player entity has been streamed into the local +/// dictionary. +/// The player movement controller is ready to attach. +/// +/// All four predicates are passed as ; tests +/// flip plain boolean fields and assert against an entry-counter that +/// the entry callback bumps. +/// +public sealed class AutoEnterPlayerModeTests +{ + private sealed class State + { + public bool LiveInWorld; + public bool PlayerEntityPresent; + public bool PlayerControllerReady; + public int EnteredCount; + + public PlayerModeAutoEntry Build() => + new( + isLiveInWorld: () => LiveInWorld, + isPlayerEntityPresent: () => PlayerEntityPresent, + isPlayerControllerReady: () => PlayerControllerReady, + enterPlayerMode: () => EnteredCount++); + } + + [Fact] + public void TryEnter_NotArmed_DoesNotFire() + { + var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = true }; + var guard = s.Build(); + + // Not armed → must NOT fire even though every precondition is true. + Assert.False(guard.TryEnter()); + Assert.Equal(0, s.EnteredCount); + Assert.False(guard.IsArmed); + } + + [Fact] + public void TryEnter_Armed_LiveNotInWorld_DoesNotFire() + { + var s = new State { LiveInWorld = false, PlayerEntityPresent = true, PlayerControllerReady = true }; + var guard = s.Build(); + guard.Arm(); + + Assert.False(guard.TryEnter()); + Assert.Equal(0, s.EnteredCount); + Assert.True(guard.IsArmed); + } + + [Fact] + public void TryEnter_Armed_PlayerEntityNotPresent_DoesNotFire() + { + var s = new State { LiveInWorld = true, PlayerEntityPresent = false, PlayerControllerReady = true }; + var guard = s.Build(); + guard.Arm(); + + Assert.False(guard.TryEnter()); + Assert.Equal(0, s.EnteredCount); + Assert.True(guard.IsArmed); + } + + [Fact] + public void TryEnter_Armed_PlayerControllerNotReady_DoesNotFire() + { + var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = false }; + var guard = s.Build(); + guard.Arm(); + + Assert.False(guard.TryEnter()); + Assert.Equal(0, s.EnteredCount); + Assert.True(guard.IsArmed); + } + + [Fact] + public void TryEnter_AllConditionsSatisfied_FiresExactlyOnce() + { + var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = true }; + var guard = s.Build(); + guard.Arm(); + + Assert.True(guard.TryEnter()); + Assert.Equal(1, s.EnteredCount); + Assert.False(guard.IsArmed); + + // Subsequent tick must not re-fire — one-shot semantics. + Assert.False(guard.TryEnter()); + Assert.Equal(1, s.EnteredCount); + } + + [Fact] + public void TryEnter_FiresOnLaterTickWhenPreconditionsBecomeTrue() + { + var s = new State(); + var guard = s.Build(); + guard.Arm(); + + // Tick 1: only LiveInWorld true. + s.LiveInWorld = true; + Assert.False(guard.TryEnter()); + + // Tick 2: + PlayerEntityPresent. + s.PlayerEntityPresent = true; + Assert.False(guard.TryEnter()); + + // Tick 3: + PlayerControllerReady → fires. + s.PlayerControllerReady = true; + Assert.True(guard.TryEnter()); + Assert.Equal(1, s.EnteredCount); + } + + [Fact] + public void Cancel_BeforeFiring_SuppressesAutoEntry() + { + // Manual fly-mode toggle BEFORE the auto-entry fires must + // disarm the trigger; the user's choice wins. + var s = new State(); + var guard = s.Build(); + guard.Arm(); + + // User opts out before any precondition is true. + guard.Cancel(); + Assert.False(guard.IsArmed); + + // Even when every precondition flips true, the guard stays + // silent — the user's manual fly-mode choice wins. + s.LiveInWorld = true; + s.PlayerEntityPresent = true; + s.PlayerControllerReady = true; + Assert.False(guard.TryEnter()); + Assert.Equal(0, s.EnteredCount); + } + + [Fact] + public void Arm_WhileAlreadyArmed_IsIdempotent() + { + var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = true }; + var guard = s.Build(); + guard.Arm(); + guard.Arm(); // second Arm() — no-op. + + Assert.True(guard.TryEnter()); + Assert.Equal(1, s.EnteredCount); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs index 5c67f6d..25f6c40 100644 --- a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs +++ b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs @@ -162,4 +162,7 @@ internal sealed class FakePanelRenderer : IPanelRenderer public void SetScrollHereY(float ratio) => Calls.Add(("SetScrollHereY", new object?[] { ratio })); + + public void SetKeyboardFocusHere() + => Calls.Add(("SetKeyboardFocusHere", Array.Empty())); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/MmbMouseLookTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/MmbMouseLookTests.cs new file mode 100644 index 0000000..96f7b74 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/MmbMouseLookTests.cs @@ -0,0 +1,166 @@ +using AcDream.UI.Abstractions.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +/// +/// Phase K.2 — MMB-hold instant mouse-look state machine. While +/// active, mouse-X drives the character's heading (and the chase +/// camera follows automatically because ChaseCamera.Update +/// reads the player yaw). The state machine guards activation on +/// ImGui's WantCaptureMouse so a panel-hovered MMB doesn't +/// hijack the cursor. +/// +public sealed class MmbMouseLookTests +{ + private sealed class YawSink + { + public float Total; + public int ApplyCount; + public void Apply(float d) { Total += d; ApplyCount++; } + } + + [Fact] + public void Press_ActivatesAndCapturesCursorPosition() + { + var sink = new YawSink(); + var ml = new MouseLookState(sink.Apply); + + ml.Press(cursorX: 320f, cursorY: 240f, wantCaptureMouse: false); + + Assert.True(ml.Active); + Assert.Equal(320f, ml.CapturedCursorX); + Assert.Equal(240f, ml.CapturedCursorY); + } + + [Fact] + public void Release_DeactivatesWhenActive() + { + var sink = new YawSink(); + var ml = new MouseLookState(sink.Apply); + ml.Press(0f, 0f, wantCaptureMouse: false); + + ml.Release(); + + Assert.False(ml.Active); + } + + [Fact] + public void Press_WhileImGuiCapturesMouse_DoesNotActivate() + { + // Defense in depth — the dispatcher already filters on + // WantCaptureMouse, but if a binding ever fires through the + // state machine itself must not turn on. + var sink = new YawSink(); + var ml = new MouseLookState(sink.Apply); + + ml.Press(0f, 0f, wantCaptureMouse: true); + + Assert.False(ml.Active); + } + + [Fact] + public void OnWantCaptureMouseChanged_TrueWhileActive_Deactivates() + { + var sink = new YawSink(); + var ml = new MouseLookState(sink.Apply); + ml.Press(0f, 0f, wantCaptureMouse: false); + Assert.True(ml.Active); + + // ImGui takes mouse focus mid-hold (e.g. tooltip pop-up over + // the cursor) → suspend mouse-look so the panel gets the + // cursor. + ml.OnWantCaptureMouseChanged(wantCaptureMouse: true); + + Assert.False(ml.Active); + } + + [Fact] + public void OnWantCaptureMouseChanged_FalseWhileInactive_NoOp() + { + // Going from "ImGui captures" to "ImGui doesn't capture" must + // NOT auto-reactivate — only an explicit Press does. + var sink = new YawSink(); + var ml = new MouseLookState(sink.Apply); + + ml.OnWantCaptureMouseChanged(wantCaptureMouse: false); + + Assert.False(ml.Active); + } + + [Fact] + public void ApplyDelta_WhileActive_DrivesYawSink() + { + var sink = new YawSink(); + var ml = new MouseLookState(sink.Apply) { SensitivityRadiansPerPixel = 0.01f }; + ml.Press(0f, 0f, wantCaptureMouse: false); + + ml.ApplyDelta(dx: 10f, extraSensitivity: 1.0f); + + // Sign: dragging right (positive dx) yaws the character right + // — by the acdream Yaw convention that's negative yaw delta. + // Magnitude: 10 * 0.01 * 1.0 = 0.1 rad. + Assert.Equal(1, sink.ApplyCount); + Assert.Equal(-0.1f, sink.Total, 5); + } + + [Fact] + public void ApplyDelta_WhileInactive_DoesNothing() + { + var sink = new YawSink(); + var ml = new MouseLookState(sink.Apply); + // Never pressed. + + ml.ApplyDelta(dx: 100f, extraSensitivity: 1.0f); + + Assert.Equal(0, sink.ApplyCount); + Assert.Equal(0f, sink.Total); + } + + [Fact] + public void ApplyDelta_AfterRelease_DoesNothing() + { + var sink = new YawSink(); + var ml = new MouseLookState(sink.Apply); + ml.Press(0f, 0f, wantCaptureMouse: false); + ml.Release(); + + ml.ApplyDelta(dx: 50f, extraSensitivity: 1.0f); + + Assert.Equal(0, sink.ApplyCount); + } + + [Fact] + public void Press_WhileAlreadyActive_IsIdempotent() + { + // Repeated Press calls must not blow away the originally + // captured cursor position — the cursor-restore-on-release + // path needs the original anchor. + var sink = new YawSink(); + var ml = new MouseLookState(sink.Apply); + ml.Press(100f, 200f, wantCaptureMouse: false); + + ml.Press(999f, 888f, wantCaptureMouse: false); + + Assert.Equal(100f, ml.CapturedCursorX); + Assert.Equal(200f, ml.CapturedCursorY); + } + + [Fact] + public void ApplyDelta_DriverDrivesCharacterAndCameraInOneSink() + { + // Combined drive: the test only exposes a single yaw mutator, + // and that's the design — the yaw mutator GameWindow passes + // updates _playerController.Yaw, and the chase camera reads + // the same yaw via ChaseCamera.Update(pos, playerYaw). So a + // single sink in this test models both behaviors. + var sink = new YawSink(); + var ml = new MouseLookState(sink.Apply) { SensitivityRadiansPerPixel = 0.005f }; + ml.Press(0f, 0f, wantCaptureMouse: false); + + ml.ApplyDelta(dx: 4f, extraSensitivity: 0.5f); // -0.01 + ml.ApplyDelta(dx: -2f, extraSensitivity: 0.5f); // +0.005 + + Assert.Equal(2, sink.ApplyCount); + Assert.Equal(-0.005f, sink.Total, 5); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs new file mode 100644 index 0000000..a09b633 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs @@ -0,0 +1,71 @@ +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +/// +/// Phase K.2 — Tab fires , +/// which calls . The chat panel honors +/// the request on the very next by emitting +/// a SetKeyboardFocusHere immediately before the input field. After +/// it fires once, subsequent renders without another FocusInput +/// call must not re-fire (one-shot semantics) — otherwise the chat field +/// would steal focus on every frame and the user could never click out. +/// +public sealed class ChatPanelFocusTests +{ + private sealed class NullBus : AcDream.UI.Abstractions.ICommandBus + { + public void Publish(T command) where T : notnull { } + } + + [Fact] + public void FocusInput_NextRender_EmitsSetKeyboardFocusHereBeforeInput() + { + var panel = new ChatPanel(new ChatVM(new ChatLog())); + var renderer = new FakePanelRenderer(); + + panel.FocusInput(); + panel.Render(new PanelContext(0.016f, new NullBus()), renderer); + + // Find the SetKeyboardFocusHere call; it must come before the + // InputTextSubmit call so ImGui applies the focus to that widget. + int focusIdx = -1, inputIdx = -1; + for (int i = 0; i < renderer.Calls.Count; i++) + { + if (renderer.Calls[i].Method == "SetKeyboardFocusHere") focusIdx = i; + else if (renderer.Calls[i].Method == "InputTextSubmit") inputIdx = i; + } + Assert.True(focusIdx >= 0, "ChatPanel must call SetKeyboardFocusHere when FocusInput requested."); + Assert.True(inputIdx >= 0, "ChatPanel must still render the InputTextSubmit field."); + Assert.True(focusIdx < inputIdx, "SetKeyboardFocusHere must precede the InputTextSubmit it targets."); + } + + [Fact] + public void Render_WithoutFocusInputCall_DoesNotEmitSetKeyboardFocusHere() + { + var panel = new ChatPanel(new ChatVM(new ChatLog())); + var renderer = new FakePanelRenderer(); + + panel.Render(new PanelContext(0.016f, new NullBus()), renderer); + + Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetKeyboardFocusHere"); + } + + [Fact] + public void FocusInput_OnlyAffectsTheNextRender_OneShot() + { + var panel = new ChatPanel(new ChatVM(new ChatLog())); + + // Frame 1 — FocusInput requested → expect a SetKeyboardFocusHere. + var r1 = new FakePanelRenderer(); + panel.FocusInput(); + panel.Render(new PanelContext(0.016f, new NullBus()), r1); + Assert.Contains(r1.Calls, c => c.Method == "SetKeyboardFocusHere"); + + // Frame 2 — no further FocusInput call → must NOT re-fire. + var r2 = new FakePanelRenderer(); + panel.Render(new PanelContext(0.016f, new NullBus()), r2); + Assert.DoesNotContain(r2.Calls, c => c.Method == "SetKeyboardFocusHere"); + } +}