Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>, UpdateSystem
Expand Down
35 changes: 35 additions & 0 deletions docs/input-automation.md
Original file line number Diff line number Diff line change
@@ -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<IInputAutomation>();
```

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.
3 changes: 3 additions & 0 deletions src/Pixely/App/PixelyAppBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ public IPixelyApp Build()
AddSingleton<TextInputService, PixelyFactory>();
AddAlias<ITextInputService, TextInputService>();

AddSingleton<InputAutomation, PixelyFactory>();
AddAlias<IInputAutomation, InputAutomation>();

AddSingleton<ClipboardService>();
AddAlias<IClipboardService, ClipboardService>();

Expand Down
24 changes: 14 additions & 10 deletions src/Pixely/EventService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Numerics;
using System.Runtime.InteropServices;
using Pixely.Input;
using SDL;

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions src/Pixely/Input/IInputAutomation.cs
Original file line number Diff line number Diff line change
@@ -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);
}
104 changes: 104 additions & 0 deletions src/Pixely/Input/InputAutomation.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
9 changes: 2 additions & 7 deletions src/Pixely/Input/KeyboardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
{
Expand Down
Loading