From 2284a376ae36f04f6ca16e69f11443625fd2e806 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 15:24:19 +0200 Subject: [PATCH] feat(D.2b): write-mode movement gate that preserves autorun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In chat write mode the keyboard belongs to the input — typing "swd" must not walk the character — but AUTORUN must keep going (the user can chat while running). - InputDispatcher.IsActionHeld now returns false while WantCaptureKeyboard is set (a focused chat input), the polling-path twin of the existing gate on Fired actions. This SUPERSEDES the old per-frame OnUpdate early-return, which also killed autorun. Gating here instead lets the movement block keep running, so autorun — a separate latched bool ORed into Forward at the call site, not a polled key — survives. Test updated to encode the new contract. - GameWindow: the movement suppress-guard reverts to ImGui-devtools-only (the retail write mode no longer early-returns); wires DefaultTextInput = the chat input (Tab/Enter activation) and Input.Keyboard for clipboard. Drops the one-shot UI-scale diagnostic. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 15 +++++++-- .../Input/InputDispatcher.cs | 6 ++++ .../Input/InputDispatcherIsActionHeldTests.cs | 33 ++++++++++--------- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6951c28e..9767fa8a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1854,9 +1854,11 @@ public sealed class GameWindow : IDisposable vitalsDatFont, _debugFont, ResolveChrome); if (chatController is not null) { - // Ctrl+C / Ctrl+A on the transcript need the keyboard for clipboard + modifiers. - // _uiHost.Keyboard is set by WireKeyboard above — it is non-null here. + // Ctrl+C / Ctrl+A on the transcript + Ctrl+C/X/V/A on the input need the + // keyboard for clipboard + modifier (Ctrl/Shift) state. _uiHost.Keyboard + // is set by WireKeyboard above — it is non-null here. chatController.Transcript.Keyboard = _uiHost.Keyboard; + chatController.Input.Keyboard = _uiHost.Keyboard; // Wrap the dat content in the universal 8-piece beveled window chrome — // the SAME UiNineSlicePanel the vitals window uses. The chat's own dat // layout only carries flat background sprites, so without this the window @@ -1887,6 +1889,10 @@ public sealed class GameWindow : IDisposable chatRoot.Draggable = false; chatRoot.Resizable = false; chatFrame.AddChild(chatRoot); _uiHost.Root.AddChild(chatFrame); + // Tab / Enter enters "write mode" by focusing this input (retail's chat + // activation); a focused input suppresses character movement (see the + // WantsKeyboard gate in the movement poll). + _uiHost.Root.DefaultTextInput = chatController.Input; Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006)."); } else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006."); @@ -7271,6 +7277,11 @@ public sealed class GameWindow : IDisposable // this guard adds defense-in-depth for the per-frame IsActionHeld // movement poll below (typing "walk" into a chat field shouldn't // walk). + // ImGui dev-tools text fields fully pause game input (incl. autorun) — fine, it's a + // debug overlay. The RETAIL chat "write mode" does NOT early-return here: the block + // below still runs so AUTORUN keeps driving the character while you type. Held WASD + // is silenced at the source instead — InputDispatcher.IsActionHeld returns false + // while WantCaptureKeyboard (which includes a focused chat input) is set. bool suppressGameInput = DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard; if (suppressGameInput) return; diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index 84bafce3..e62dc5e2 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -141,6 +141,12 @@ public sealed class InputDispatcher public bool IsActionHeld(InputAction action) { if (action == InputAction.None) return false; + // While a text field owns the keyboard ("write mode"), held game actions read as + // released: typing "swd" must not move the character. This is the polling-path twin + // of the WantCaptureKeyboard gate on Fired actions. NOTE: this suppresses KEY-driven + // movement only — latched state that isn't a key (e.g. autorun, ORed into Forward at + // the call site) keeps driving the character, so chat doesn't cancel autorun. + if (_mouse.WantCaptureKeyboard) return false; foreach (var b in _bindings.ForAction(action)) { if (IsChordHeld(b.Chord)) return true; diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs index d5003bba..e10d56e3 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs @@ -148,27 +148,30 @@ public class InputDispatcherIsActionHeldTests } [Fact] - public void IsActionHeld_does_not_check_WantCaptureMouse() + public void IsActionHeld_gated_off_while_keyboard_captured() { - // Per-frame held-state lookup is independent of UI capture: even - // with WantCaptureMouse=true a movement key already held when - // ImGui took focus continues to read as held until KeyUp. Press - // events ARE gated (the Press wouldn't fire while UI captures), - // but IsActionHeld answers the keyboard's underlying "is the - // physical key down right now" — which the legacy IsKeyPressed - // also did. The per-frame OnUpdate guard on - // ImGui.GetIO().WantCaptureKeyboard is what suppresses movement - // when chat is focused. + // Write-mode gate (2026-06-16): a focused chat input sets + // WantCaptureKeyboard, and held-key polling then reads RELEASED so typing + // "swd" doesn't move the character. This SUPERSEDES the old design (where the + // per-frame OnUpdate guard early-returned out of the whole movement block) — + // that approach also killed AUTORUN. By gating here instead, the movement block + // keeps running, so autorun (a separate latched bool ORed into Forward at the + // call site, NOT a polled key) survives write mode. WantCaptureMouse alone does + // NOT gate held-key polling — only keyboard capture does. var (dispatcher, kb, mouse, bindings) = Build(); bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); kb.EmitKeyDown(Key.W, ModifierMask.None); - mouse.WantCaptureMouse = true; - mouse.WantCaptureKeyboard = true; + // Held, no capture → reads held. + Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); - // Even with both capture flags set, IsActionHeld remains true - // because W is physically held. The dispatcher only suppresses - // press transitions. + // Keyboard captured (write mode) → held-key polling reads released. + mouse.WantCaptureKeyboard = true; + Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward)); + + // Mouse capture alone must NOT gate movement polling (only keyboard does). + mouse.WantCaptureKeyboard = false; + mouse.WantCaptureMouse = true; Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); } }