diff --git a/AGENTS.md b/AGENTS.md index 5934da84..51134787 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ - `docs/class-registration.md` - ServiceCollection/ServiceProvider API: registration overloads, source generator requirements, lifecycle, aliases, multi-registration, parent/child provider callback merging and scoped lifecycles - `docs/events.md` - Pixely.Events EventBus, event handlers, publishing, and DI auto-subscription +- `docs/input-automation.md` - Synchronous synthetic mouse, keyboard, and text input, coordinate semantics, view targeting, and physical-input boundaries - `docs/static-factory-methods.md` - Static Create() method pattern - `docs/componentize.md` - Pixely.Componentize setup and usage - `docs/components.md` - GameWorld, GameObject, GameComponent lifecycle, Services, UpdateSystem diff --git a/docs/input-automation.md b/docs/input-automation.md new file mode 100644 index 00000000..0899acf6 --- /dev/null +++ b/docs/input-automation.md @@ -0,0 +1,35 @@ +# Input automation + +`IInputAutomation` synchronously delivers synthetic mouse, keyboard, and text input through the ordinary Pixely input services. Existing view-scoped subscriptions, priorities, consumption, and device state apply to automated input. Handlers finish before an automation method returns. + +Resolve the application-lifetime service from the app: + +```csharp +IInputAutomation input = app.GetRequiredService(); +``` + +Mouse positions use logical coordinates relative to the target window's top-left corner. `MouseMoveTo` moves to a window position and derives the relative motion from the synthetic mouse's previous position. `MouseMoveBy` applies a delta to that previous position. + +```csharp +input.MouseMoveTo(new Vector2(320, 180)); +input.MouseMoveBy(new Vector2(10, -5)); +input.MouseClick(MouseButton.Left, new Vector2(330, 175)); +input.MouseWheel(new Vector2(0, -1), new Vector2(330, 175)); +``` + +Use explicit down and up calls for held input: + +```csharp +input.KeyDown(Scancode.W); +input.KeyUp(Scancode.W); + +input.MouseDown(MouseButton.Left, new Vector2(100, 100)); +input.MouseMoveTo(new Vector2(200, 100)); +input.MouseUp(MouseButton.Left, new Vector2(200, 100)); +``` + +`KeyPress` dispatches a key down followed by a key up. `TextInput` delivers text directly and supports characters that do not have a corresponding keyboard scancode. + +The default `ViewScope` targets the ordinary single-window application. Pass a scope explicitly for another registered window. + +Automated input affects Pixely's event-derived synthetic device state. It does not move the operating-system cursor, change window focus, or modify SDL's physical/global device state. diff --git a/src/Pixely/App/PixelyAppBuilder.cs b/src/Pixely/App/PixelyAppBuilder.cs index 7e9e7864..f9b2c101 100644 --- a/src/Pixely/App/PixelyAppBuilder.cs +++ b/src/Pixely/App/PixelyAppBuilder.cs @@ -64,6 +64,9 @@ public IPixelyApp Build() AddSingleton(); AddAlias(); + AddSingleton(); + AddAlias(); + AddSingleton(); AddAlias(); diff --git a/src/Pixely/EventService.cs b/src/Pixely/EventService.cs index 3b4b688e..50a6c298 100644 --- a/src/Pixely/EventService.cs +++ b/src/Pixely/EventService.cs @@ -1,3 +1,5 @@ +using System.Numerics; +using System.Runtime.InteropServices; using Pixely.Input; using SDL; @@ -39,14 +41,14 @@ public void Process() { if (_windowRegistry.TryGetWindow((uint)evt.key.windowID, out Window window)) { - _keyboardService.OnKeyEvent(window.ViewScope, evt.key); + _keyboardService.OnKeyEvent(window.ViewScope, evt.key.which, (Scancode)evt.key.scancode, (VirtualKey)evt.key.key, evt.key.down, evt.key.timestamp); } } else if (evt.Type == SDL_EventType.SDL_EVENT_KEY_UP) { if (_windowRegistry.TryGetWindow((uint)evt.key.windowID, out Window window)) { - _keyboardService.OnKeyEvent(window.ViewScope, evt.key); + _keyboardService.OnKeyEvent(window.ViewScope, evt.key.which, (Scancode)evt.key.scancode, (VirtualKey)evt.key.key, evt.key.down, evt.key.timestamp); } } else if (evt.Type == SDL_EventType.SDL_EVENT_GAMEPAD_ADDED) @@ -73,56 +75,58 @@ public void Process() { if (_windowRegistry.TryGetWindow((uint)evt.button.windowID, out Window window)) { - _mouseService.OnMouseButtonEvent(window.ViewScope, evt.button); + _mouseService.OnMouseButtonEvent(window.ViewScope, evt.button.which, (MouseButton)evt.button.button, new Vector2(evt.button.x, evt.button.y), evt.button.down, evt.button.timestamp); } } else if (evt.Type == SDL_EventType.SDL_EVENT_MOUSE_BUTTON_UP) { if (_windowRegistry.TryGetWindow((uint)evt.button.windowID, out Window window)) { - _mouseService.OnMouseButtonEvent(window.ViewScope, evt.button); + _mouseService.OnMouseButtonEvent(window.ViewScope, evt.button.which, (MouseButton)evt.button.button, new Vector2(evt.button.x, evt.button.y), evt.button.down, evt.button.timestamp); } } else if (evt.Type == SDL_EventType.SDL_EVENT_MOUSE_MOTION) { if (_windowRegistry.TryGetWindow((uint)evt.motion.windowID, out Window window)) { - _mouseService.OnMouseMotionEvent(window.ViewScope, evt.motion); + _mouseService.OnMouseMotionEvent(window.ViewScope, evt.motion.which, new Vector2(evt.motion.x, evt.motion.y), new Vector2(evt.motion.xrel, evt.motion.yrel), evt.motion.timestamp); } } else if (evt.Type == SDL_EventType.SDL_EVENT_MOUSE_WHEEL) { if (_windowRegistry.TryGetWindow((uint)evt.wheel.windowID, out Window window)) { - _mouseService.OnMouseWheelEvent(window.ViewScope, evt.wheel); + _mouseService.OnMouseWheelEvent(window.ViewScope, evt.wheel.which, new Vector2(evt.wheel.x, evt.wheel.y), new Vector2(evt.wheel.mouse_x, evt.wheel.mouse_y), evt.wheel.timestamp); } } else if (evt.Type == SDL_EventType.SDL_EVENT_WINDOW_MOUSE_ENTER) { if (_windowRegistry.TryGetWindow((uint)evt.window.windowID, out Window window)) { - _mouseService.OnMouseWindowPresenceEvent(window.ViewScope, evt.window, true); + _mouseService.OnMouseWindowPresenceEvent(window.ViewScope, true, evt.window.timestamp); } } else if (evt.Type == SDL_EventType.SDL_EVENT_WINDOW_MOUSE_LEAVE) { if (_windowRegistry.TryGetWindow((uint)evt.window.windowID, out Window window)) { - _mouseService.OnMouseWindowPresenceEvent(window.ViewScope, evt.window, false); + _mouseService.OnMouseWindowPresenceEvent(window.ViewScope, false, evt.window.timestamp); } } else if (evt.Type == SDL_EventType.SDL_EVENT_TEXT_INPUT) { if (_windowRegistry.TryGetWindow((uint)evt.text.windowID, out Window window)) { - _textInputService.OnTextInputEvent(window.ViewScope, evt.text); + string text = Marshal.PtrToStringUTF8((IntPtr)evt.text.text) ?? string.Empty; + _textInputService.OnTextInputEvent(window.ViewScope, text, evt.text.timestamp); } } else if (evt.Type == SDL_EventType.SDL_EVENT_TEXT_EDITING) { if (_windowRegistry.TryGetWindow((uint)evt.edit.windowID, out Window window)) { - _textInputService.OnTextEditingEvent(window.ViewScope, evt.edit); + string text = Marshal.PtrToStringUTF8((IntPtr)evt.edit.text) ?? string.Empty; + _textInputService.OnTextEditingEvent(window.ViewScope, text, evt.edit.start, evt.edit.length, evt.edit.timestamp); } } else if (evt.Type == SDL_EventType.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) diff --git a/src/Pixely/Input/IInputAutomation.cs b/src/Pixely/Input/IInputAutomation.cs new file mode 100644 index 00000000..54dcf26b --- /dev/null +++ b/src/Pixely/Input/IInputAutomation.cs @@ -0,0 +1,17 @@ +using System.Numerics; + +namespace Pixely.Input; + +public interface IInputAutomation +{ + void MouseMoveTo(Vector2 windowPosition, ViewScope viewScope = default); + void MouseMoveBy(Vector2 delta, ViewScope viewScope = default); + void MouseDown(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default); + void MouseUp(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default); + void MouseClick(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default); + void MouseWheel(Vector2 delta, Vector2 windowPosition, ViewScope viewScope = default); + void KeyDown(Scancode scancode, ViewScope viewScope = default); + void KeyUp(Scancode scancode, ViewScope viewScope = default); + void KeyPress(Scancode scancode, ViewScope viewScope = default); + void TextInput(string text, ViewScope viewScope = default); +} diff --git a/src/Pixely/Input/InputAutomation.cs b/src/Pixely/Input/InputAutomation.cs new file mode 100644 index 00000000..3c771a4b --- /dev/null +++ b/src/Pixely/Input/InputAutomation.cs @@ -0,0 +1,104 @@ +using System.Numerics; +using SDL; + +namespace Pixely.Input; + +internal sealed class InputAutomation : IInputAutomation +{ + private const SDL_MouseID VirtualMouseId = (SDL_MouseID)0; + private const SDL_KeyboardID VirtualKeyboardId = (SDL_KeyboardID)0; + + private readonly WindowRegistry _windowRegistry; + private readonly MouseService _mouseService; + private readonly KeyboardService _keyboardService; + private readonly TextInputService _textInputService; + + internal InputAutomation(WindowRegistry windowRegistry, MouseService mouseService, KeyboardService keyboardService, TextInputService textInputService) + { + _windowRegistry = windowRegistry; + _mouseService = mouseService; + _keyboardService = keyboardService; + _textInputService = textInputService; + } + + public void MouseMoveTo(Vector2 windowPosition, ViewScope viewScope = default) + { + ValidateView(viewScope); + _mouseService.OnMouseMoveTo(viewScope, VirtualMouseId, windowPosition, GetTimestamp()); + } + + public void MouseMoveBy(Vector2 delta, ViewScope viewScope = default) + { + ValidateView(viewScope); + _mouseService.OnMouseMoveBy(viewScope, VirtualMouseId, delta, GetTimestamp()); + } + + public void MouseDown(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default) + { + ValidateView(viewScope); + _mouseService.OnMouseButtonEvent(viewScope, VirtualMouseId, button, windowPosition, true, GetTimestamp()); + } + + public void MouseUp(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default) + { + ValidateView(viewScope); + _mouseService.OnMouseButtonEvent(viewScope, VirtualMouseId, button, windowPosition, false, GetTimestamp()); + } + + public void MouseClick(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default) + { + ValidateView(viewScope); + _mouseService.OnMouseMoveTo(viewScope, VirtualMouseId, windowPosition, GetTimestamp()); + _mouseService.OnMouseButtonEvent(viewScope, VirtualMouseId, button, windowPosition, true, GetTimestamp()); + _mouseService.OnMouseButtonEvent(viewScope, VirtualMouseId, button, windowPosition, false, GetTimestamp()); + } + + public void MouseWheel(Vector2 delta, Vector2 windowPosition, ViewScope viewScope = default) + { + ValidateView(viewScope); + _mouseService.OnMouseWheelEvent(viewScope, VirtualMouseId, delta, windowPosition, GetTimestamp()); + } + + public void KeyDown(Scancode scancode, ViewScope viewScope = default) + { + ValidateView(viewScope); + _keyboardService.OnKeyEvent(viewScope, VirtualKeyboardId, scancode, GetVirtualKey(scancode), true, GetTimestamp()); + } + + public void KeyUp(Scancode scancode, ViewScope viewScope = default) + { + ValidateView(viewScope); + _keyboardService.OnKeyEvent(viewScope, VirtualKeyboardId, scancode, GetVirtualKey(scancode), false, GetTimestamp()); + } + + public void KeyPress(Scancode scancode, ViewScope viewScope = default) + { + ValidateView(viewScope); + VirtualKey virtualKey = GetVirtualKey(scancode); + _keyboardService.OnKeyEvent(viewScope, VirtualKeyboardId, scancode, virtualKey, true, GetTimestamp()); + _keyboardService.OnKeyEvent(viewScope, VirtualKeyboardId, scancode, virtualKey, false, GetTimestamp()); + } + + public void TextInput(string text, ViewScope viewScope = default) + { + ArgumentNullException.ThrowIfNull(text); + ValidateView(viewScope); + _textInputService.OnTextInputEvent(viewScope, text, GetTimestamp()); + } + + private void ValidateView(ViewScope viewScope) + { + _windowRegistry.GetWindow(viewScope); + } + + private static VirtualKey GetVirtualKey(Scancode scancode) + { + SDL_Keycode keycode = SDL3.SDL_GetKeyFromScancode((SDL_Scancode)scancode, SDL_Keymod.SDL_KMOD_NONE, true); + return (VirtualKey)keycode; + } + + private static ulong GetTimestamp() + { + return SDL3.SDL_GetTicksNS(); + } +} diff --git a/src/Pixely/Input/KeyboardService.cs b/src/Pixely/Input/KeyboardService.cs index 60f12713..6aac4314 100644 --- a/src/Pixely/Input/KeyboardService.cs +++ b/src/Pixely/Input/KeyboardService.cs @@ -60,13 +60,8 @@ internal KeyboardService(AppControl appControl) _appControl = appControl; } - internal void OnKeyEvent(ViewScope viewScope, in SDL_KeyboardEvent keyboardEvent) + internal void OnKeyEvent(ViewScope viewScope, SDL_KeyboardID keyboardId, Scancode scancode, VirtualKey virtualKey, bool isPressed, ulong timestamp) { - Scancode scancode = (Scancode)keyboardEvent.scancode; - ulong timestamp = keyboardEvent.timestamp; - SDL_KeyboardID keyboardId = keyboardEvent.which; - VirtualKey virtualKey = (VirtualKey)keyboardEvent.key; - ref Keyboard? keyboard = ref CollectionsMarshal.GetValueRefOrAddDefault(_keyboards, keyboardId, out bool exists); if (!exists || keyboard == null) @@ -78,7 +73,7 @@ internal void OnKeyEvent(ViewScope viewScope, in SDL_KeyboardEvent keyboardEvent _keyEventArgs.Scancode = scancode; _keyEventArgs.Key = virtualKey; _keyEventArgs.Timestamp = timestamp; - if (keyboardEvent.down) + if (isPressed) { if (keyboard.Set(scancode)) { diff --git a/src/Pixely/Input/MouseService.cs b/src/Pixely/Input/MouseService.cs index 0256fd1b..50b75fb5 100644 --- a/src/Pixely/Input/MouseService.cs +++ b/src/Pixely/Input/MouseService.cs @@ -242,13 +242,10 @@ public void SubscribeWindowLeave(ViewScope viewScope, int priority, InputEventHa _windowLeaveHandlers.Add(viewScope, priority, handler); } - internal void OnMouseWindowPresenceEvent( - ViewScope viewScope, - in SDL_WindowEvent windowEvent, - bool isInWindow) + internal void OnMouseWindowPresenceEvent(ViewScope viewScope, bool isInWindow, ulong timestamp) { _windowPresenceEventArgs.IsInWindow = isInWindow; - _windowPresenceEventArgs.Timestamp = windowEvent.timestamp; + _windowPresenceEventArgs.Timestamp = timestamp; ViewScopedPriorityEventHandlers handlers = isInWindow ? _windowEnterHandlers @@ -257,21 +254,9 @@ internal void OnMouseWindowPresenceEvent( handlers.Invoke(viewScope, _windowPresenceEventArgs); } - internal void OnMouseButtonEvent( - ViewScope viewScope, - in SDL_MouseButtonEvent mouseButtonEvent) + internal void OnMouseButtonEvent(ViewScope viewScope, SDL_MouseID mouseId, MouseButton button, Vector2 position, bool isPressed, ulong timestamp) { - SDL_MouseID mouseId = mouseButtonEvent.which; - MouseButton button = (MouseButton)mouseButtonEvent.button; - Vector2 position = new(mouseButtonEvent.x, mouseButtonEvent.y); - ulong timestamp = mouseButtonEvent.timestamp; - - ref Mouse? mouse = ref CollectionsMarshal.GetValueRefOrAddDefault(_mice, mouseId, out bool exists); - - if (!exists || mouse == null) - { - mouse = new Mouse(mouseId); - } + Mouse mouse = GetOrCreateMouse(mouseId); mouse.Position = position; @@ -279,7 +264,7 @@ internal void OnMouseButtonEvent( _buttonEventArgs.Button = button; _buttonEventArgs.Position = position; _buttonEventArgs.Timestamp = timestamp; - if (mouseButtonEvent.down) + if (isPressed) { if (mouse.Set(button)) { @@ -294,21 +279,9 @@ internal void OnMouseButtonEvent( } } - internal void OnMouseMotionEvent( - ViewScope viewScope, - in SDL_MouseMotionEvent mouseMotionEvent) + internal void OnMouseMotionEvent(ViewScope viewScope, SDL_MouseID mouseId, Vector2 position, Vector2 relativeMotion, ulong timestamp) { - SDL_MouseID mouseId = mouseMotionEvent.which; - Vector2 position = new(mouseMotionEvent.x, mouseMotionEvent.y); - Vector2 relativeMotion = new(mouseMotionEvent.xrel, mouseMotionEvent.yrel); - ulong timestamp = mouseMotionEvent.timestamp; - - ref Mouse? mouse = ref CollectionsMarshal.GetValueRefOrAddDefault(_mice, mouseId, out bool exists); - - if (!exists || mouse == null) - { - mouse = new Mouse(mouseId); - } + Mouse mouse = GetOrCreateMouse(mouseId); mouse.Position = position; @@ -319,21 +292,21 @@ internal void OnMouseMotionEvent( _motionHandlers.Invoke(viewScope, _motionEventArgs); } - internal void OnMouseWheelEvent( - ViewScope viewScope, - in SDL_MouseWheelEvent mouseWheelEvent) + internal void OnMouseMoveTo(ViewScope viewScope, SDL_MouseID mouseId, Vector2 position, ulong timestamp) { - SDL_MouseID mouseId = mouseWheelEvent.which; - Vector2 delta = new(mouseWheelEvent.x, mouseWheelEvent.y); - Vector2 position = new(mouseWheelEvent.mouse_x, mouseWheelEvent.mouse_y); - ulong timestamp = mouseWheelEvent.timestamp; + Mouse mouse = GetOrCreateMouse(mouseId); + OnMouseMotionEvent(viewScope, mouseId, position, position - mouse.Position, timestamp); + } - ref Mouse? mouse = ref CollectionsMarshal.GetValueRefOrAddDefault(_mice, mouseId, out bool exists); + internal void OnMouseMoveBy(ViewScope viewScope, SDL_MouseID mouseId, Vector2 relativeMotion, ulong timestamp) + { + Mouse mouse = GetOrCreateMouse(mouseId); + OnMouseMotionEvent(viewScope, mouseId, mouse.Position + relativeMotion, relativeMotion, timestamp); + } - if (!exists || mouse == null) - { - mouse = new Mouse(mouseId); - } + internal void OnMouseWheelEvent(ViewScope viewScope, SDL_MouseID mouseId, Vector2 delta, Vector2 position, ulong timestamp) + { + Mouse mouse = GetOrCreateMouse(mouseId); mouse.Position = position; @@ -343,4 +316,16 @@ internal void OnMouseWheelEvent( _wheelEventArgs.Timestamp = timestamp; _wheelHandlers.Invoke(viewScope, _wheelEventArgs); } + + private Mouse GetOrCreateMouse(SDL_MouseID mouseId) + { + ref Mouse? mouse = ref CollectionsMarshal.GetValueRefOrAddDefault(_mice, mouseId, out bool exists); + + if (!exists || mouse == null) + { + mouse = new Mouse(mouseId); + } + + return mouse; + } } diff --git a/src/Pixely/Input/TextInputService.cs b/src/Pixely/Input/TextInputService.cs index d7c2a54e..70a8b881 100644 --- a/src/Pixely/Input/TextInputService.cs +++ b/src/Pixely/Input/TextInputService.cs @@ -1,4 +1,3 @@ -using System.Runtime.InteropServices; using SDL; namespace Pixely.Input; @@ -90,35 +89,19 @@ internal TextInputService(WindowRegistry windowRegistry) _windowRegistry = windowRegistry; } - internal void OnTextInputEvent( - ViewScope viewScope, - in SDL_TextInputEvent textInputEvent) + internal void OnTextInputEvent(ViewScope viewScope, string text, ulong timestamp) { - string text; - unsafe - { - text = Marshal.PtrToStringUTF8((IntPtr)textInputEvent.text) ?? string.Empty; - } - _textInputEventArgs.Text = text; - _textInputEventArgs.Timestamp = textInputEvent.timestamp; + _textInputEventArgs.Timestamp = timestamp; _textInputHandlers.Invoke(viewScope, _textInputEventArgs); } - internal void OnTextEditingEvent( - ViewScope viewScope, - in SDL_TextEditingEvent textEditingEvent) + internal void OnTextEditingEvent(ViewScope viewScope, string text, int start, int length, ulong timestamp) { - string text; - unsafe - { - text = Marshal.PtrToStringUTF8((IntPtr)textEditingEvent.text) ?? string.Empty; - } - _textEditingEventArgs.Text = text; - _textEditingEventArgs.Start = textEditingEvent.start; - _textEditingEventArgs.Length = textEditingEvent.length; - _textEditingEventArgs.Timestamp = textEditingEvent.timestamp; + _textEditingEventArgs.Start = start; + _textEditingEventArgs.Length = length; + _textEditingEventArgs.Timestamp = timestamp; _textEditingHandlers.Invoke(viewScope, _textEditingEventArgs); } } diff --git a/src/Pixely/PixelyFactory.cs b/src/Pixely/PixelyFactory.cs index ee77a5c6..22179ce0 100644 --- a/src/Pixely/PixelyFactory.cs +++ b/src/Pixely/PixelyFactory.cs @@ -307,6 +307,11 @@ internal TextInputService CreateTextInputService(WindowRegistry windowRegistry) return new TextInputService(windowRegistry); } + internal InputAutomation CreateInputAutomation(WindowRegistry windowRegistry, MouseService mouseService, KeyboardService keyboardService, TextInputService textInputService) + { + return new InputAutomation(windowRegistry, mouseService, keyboardService, textInputService); + } + internal EventService CreateEventService( KeyboardService keyboardService, GamepadService gamepadService, diff --git a/tests/Pixely.Tests/InputAutomationTests.cs b/tests/Pixely.Tests/InputAutomationTests.cs new file mode 100644 index 00000000..628c26cd --- /dev/null +++ b/tests/Pixely.Tests/InputAutomationTests.cs @@ -0,0 +1,128 @@ +using System.Numerics; +using System.Reflection; +using System.Runtime.CompilerServices; +using Pixely.Input; + +namespace Pixely.Tests; + +public sealed class InputAutomationTests +{ + private static readonly ViewScope _viewScope = new(7); + + [Test] + public void MouseMoveTo_UsesWindowPositionAndDerivesRelativeMotion() + { + (InputAutomation automation, MouseService mouseService, _, _) = CreateAutomation(); + List<(Vector2 Position, Vector2 RelativeMotion)> motions = new(); + mouseService.SubscribeMotion(_viewScope, 0, eventArgs => motions.Add((eventArgs.Position, eventArgs.RelativeMotion))); + + automation.MouseMoveTo(new Vector2(20, 30), _viewScope); + automation.MouseMoveTo(new Vector2(25, 28), _viewScope); + + Assert.That(motions, Is.EqualTo(new[] + { + (new Vector2(20, 30), new Vector2(20, 30)), + (new Vector2(25, 28), new Vector2(5, -2)) + })); + } + + [Test] + public void MouseMoveBy_UsesCurrentAutomatedMousePosition() + { + (InputAutomation automation, MouseService mouseService, _, _) = CreateAutomation(); + List<(Vector2 Position, Vector2 RelativeMotion)> motions = new(); + mouseService.SubscribeMotion(_viewScope, 0, eventArgs => motions.Add((eventArgs.Position, eventArgs.RelativeMotion))); + automation.MouseMoveTo(new Vector2(20, 30), _viewScope); + + automation.MouseMoveBy(new Vector2(5, -2), _viewScope); + + Assert.That(motions[^1], Is.EqualTo((new Vector2(25, 28), new Vector2(5, -2)))); + } + + [Test] + public void MouseClick_DispatchesMotionPressAndReleaseInOrder() + { + (InputAutomation automation, MouseService mouseService, _, _) = CreateAutomation(); + List events = new(); + List pressedStates = new(); + mouseService.SubscribeMotion(_viewScope, 0, _ => events.Add("motion")); + mouseService.SubscribeButtonPress(_viewScope, 0, eventArgs => + { + events.Add("press"); + pressedStates.Add(eventArgs.Mouse.IsPressed(MouseButton.Left)); + }); + mouseService.SubscribeButtonRelease(_viewScope, 0, eventArgs => + { + events.Add("release"); + pressedStates.Add(eventArgs.Mouse.IsPressed(MouseButton.Left)); + }); + + automation.MouseClick(MouseButton.Left, new Vector2(20, 30), _viewScope); + + Assert.Multiple(() => + { + Assert.That(events, Is.EqualTo(new[] { "motion", "press", "release" })); + Assert.That(pressedStates, Is.EqualTo(new[] { true, false })); + }); + } + + [Test] + public void KeyPress_DispatchesDownAndUpWithCorrespondingState() + { + (InputAutomation automation, _, KeyboardService keyboardService, _) = CreateAutomation(); + List<(string Event, bool IsPressed)> events = new(); + keyboardService.SubscribeKeyDown(_viewScope, 0, eventArgs => events.Add(("down", eventArgs.Keyboard.IsPressed(Scancode.A)))); + keyboardService.SubscribeKeyUp(_viewScope, 0, eventArgs => events.Add(("up", eventArgs.Keyboard.IsPressed(Scancode.A)))); + + automation.KeyPress(Scancode.A, _viewScope); + + Assert.That(events, Is.EqualTo(new[] { ("down", true), ("up", false) })); + } + + [Test] + public void TextInput_DispatchesTextUnchanged() + { + (InputAutomation automation, _, _, TextInputService textInputService) = CreateAutomation(); + string? receivedText = null; + textInputService.SubscribeTextInput(_viewScope, 0, eventArgs => receivedText = eventArgs.Text); + + automation.TextInput("Hello 👋", _viewScope); + + Assert.That(receivedText, Is.EqualTo("Hello 👋")); + } + + [Test] + public void InputForUnregisteredView_Throws() + { + (InputAutomation automation, _, _, _) = CreateAutomation(); + + InvalidOperationException? exception = Assert.Throws(() => automation.MouseMoveTo(Vector2.Zero, new ViewScope(99))); + + Assert.That(exception!.Message, Does.Contain("ViewScope 99")); + } + + private static (InputAutomation Automation, MouseService MouseService, KeyboardService KeyboardService, TextInputService TextInputService) CreateAutomation() + { + WindowRegistry windowRegistry = new(); + windowRegistry.Register(CreateWindow(_viewScope, 42)); + MouseService mouseService = new(windowRegistry); + KeyboardService keyboardService = new(new AppControl()); + TextInputService textInputService = new(windowRegistry); + InputAutomation automation = new(windowRegistry, mouseService, keyboardService, textInputService); + return (automation, mouseService, keyboardService, textInputService); + } + + private static Window CreateWindow(ViewScope viewScope, uint sdlId) + { + Window window = (Window)RuntimeHelpers.GetUninitializedObject(typeof(Window)); + SetBackingField(window, nameof(Window.ViewScope), viewScope); + SetBackingField(window, nameof(Window.SdlId), sdlId); + return window; + } + + private static void SetBackingField(Window window, string propertyName, T value) + { + FieldInfo field = typeof(Window).GetField($"<{propertyName}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)!; + field.SetValue(window, value); + } +} diff --git a/tests/Pixely.Tests/ViewScopeInputEventTests.cs b/tests/Pixely.Tests/ViewScopeInputEventTests.cs index 5099bd50..611c6f3d 100644 --- a/tests/Pixely.Tests/ViewScopeInputEventTests.cs +++ b/tests/Pixely.Tests/ViewScopeInputEventTests.cs @@ -23,9 +23,7 @@ public void KeyDown_ScopedHandlerReceivesOnlyMatchingView() eventKeyboard = eventArgs.Keyboard; }); keyboardService.SubscribeKeyDown(_secondView, 0, _ => secondCalls++); - SDL_KeyboardEvent keyboardEvent = new() { down = true, timestamp = 42 }; - - keyboardService.OnKeyEvent(_firstView, keyboardEvent); + keyboardService.OnKeyEvent(_firstView, (SDL_KeyboardID)1, Scancode.A, VirtualKey.A, true, 42); Assert.Multiple(() => { @@ -51,10 +49,8 @@ public void MouseMotion_ScopedHandlerReceivesOnlyMatchingView() eventMouse = eventArgs.Mouse; }); mouseService.SubscribeMotion(_secondView, 0, _ => secondCalls++); - SDL_MouseMotionEvent mouseMotionEvent = new() { timestamp = 42 }; - - mouseService.OnMouseMotionEvent(_firstView, mouseMotionEvent); - mouseService.OnMouseMotionEvent(default, mouseMotionEvent); + mouseService.OnMouseMotionEvent(_firstView, (SDL_MouseID)1, default, default, 42); + mouseService.OnMouseMotionEvent(default, (SDL_MouseID)1, default, default, 42); Assert.Multiple(() => { @@ -75,10 +71,8 @@ public void TextInput_ScopedHandlerReceivesOnlyMatchingView() textInputService.TextInput += _ => defaultCalls++; textInputService.SubscribeTextInput(_firstView, 0, _ => firstCalls++); textInputService.SubscribeTextInput(_secondView, 0, _ => secondCalls++); - SDL_TextInputEvent textInputEvent = new() { timestamp = 42 }; - - textInputService.OnTextInputEvent(_firstView, textInputEvent); - textInputService.OnTextInputEvent(default, textInputEvent); + textInputService.OnTextInputEvent(_firstView, string.Empty, 42); + textInputService.OnTextInputEvent(default, string.Empty, 42); Assert.Multiple(() => {