diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 03efa87b74..b9f6941232 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,10 +3,11 @@
"isRoot": true,
"tools": {
"csharpier": {
- "version": "0.29.2",
+ "version": "1.0.1",
"commands": [
- "dotnet-csharpier"
- ]
+ "csharpier"
+ ],
+ "rollForward": false
}
}
}
\ No newline at end of file
diff --git a/Silk.NET.sln b/Silk.NET.sln
index 7c83ca6760..ff14ca8dae 100644
--- a/Silk.NET.sln
+++ b/Silk.NET.sln
@@ -118,6 +118,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OpenAL", "OpenAL", "{662A1A
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tutorial001.HelloSound", "examples\CSharp\OpenAL\Tutorial001.HelloSound\Tutorial001.HelloSound.csproj", "{946C912C-5BBB-446A-A566-0D1696D19F59}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Input", "Input", "{61DB4B9A-9EF6-4F31-A8F5-BEAF37FA8AD1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Silk.NET.Input", "sources\Input\Input\Silk.NET.Input.csproj", "{DDADB0F1-DFC9-4297-B0B0-92984D6F6F4C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -196,6 +200,10 @@ Global
{946C912C-5BBB-446A-A566-0D1696D19F59}.Debug|Any CPU.Build.0 = Debug|Any CPU
{946C912C-5BBB-446A-A566-0D1696D19F59}.Release|Any CPU.ActiveCfg = Release|Any CPU
{946C912C-5BBB-446A-A566-0D1696D19F59}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DDADB0F1-DFC9-4297-B0B0-92984D6F6F4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DDADB0F1-DFC9-4297-B0B0-92984D6F6F4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DDADB0F1-DFC9-4297-B0B0-92984D6F6F4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DDADB0F1-DFC9-4297-B0B0-92984D6F6F4C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -233,8 +241,8 @@ Global
{12B4D1CB-8938-4EC4-8895-79C4E6ABD1E8} = {6077EDD4-F16F-4CA4-B72E-E4627D64B104}
{662A1AEC-91F2-48FA-AA29-6F27038D30F2} = {12B4D1CB-8938-4EC4-8895-79C4E6ABD1E8}
{946C912C-5BBB-446A-A566-0D1696D19F59} = {662A1AEC-91F2-48FA-AA29-6F27038D30F2}
- {5E20252F-E2A0-46C9-BBEF-4CE5C96D0E07} = {DD29EA8F-B1A6-45AA-8D2E-B38DA56D9EF6}
- {E5E8FFBF-1319-4D33-B084-E732656E8A04} = {5E20252F-E2A0-46C9-BBEF-4CE5C96D0E07}
+ {61DB4B9A-9EF6-4F31-A8F5-BEAF37FA8AD1} = {DD29EA8F-B1A6-45AA-8D2E-B38DA56D9EF6}
+ {DDADB0F1-DFC9-4297-B0B0-92984D6F6F4C} = {61DB4B9A-9EF6-4F31-A8F5-BEAF37FA8AD1}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {78D2CF6A-60A1-43E3-837B-00B73C9DA384}
diff --git a/docs/silk.net/diagnostics/ST0001.md b/docs/silk.net/diagnostics/ST0001.md
new file mode 100644
index 0000000000..f04906d857
--- /dev/null
+++ b/docs/silk.net/diagnostics/ST0001.md
@@ -0,0 +1,21 @@
+# ST0001 - ProcessClass failure
+
+## Overview
+
+This internal error was raised by SilkTouch when failing to generate an implementation for a binding at source
+generation time. It provided details regarding the exception that led to the entire native API class failing to have its
+implementation generated.
+
+| Attribute | Value |
+|--------------------|----------------------|
+| Diagnostic ID | ST0001 |
+| Title | ProcessClass failure |
+| Category | SilkTouch.Internal |
+| Default Severity | Error |
+| Enabled by Default | Yes |
+
+Example message: `ProcessClass failed. Exception: '...'`
+
+## Explanation & Solutions
+
+This functionality is no longer supported in 3.0, where this diagnostic is never raised.
diff --git a/docs/silk.net/diagnostics/ST0002.md b/docs/silk.net/diagnostics/ST0002.md
new file mode 100644
index 0000000000..89742a49a7
--- /dev/null
+++ b/docs/silk.net/diagnostics/ST0002.md
@@ -0,0 +1,21 @@
+# ST0002 - MethodClass failure
+
+## Overview
+
+This internal error was raised by SilkTouch when failing to generate an implementation for a binding at source
+generation time. It provided details regarding the exception that led to a specific native API method failing to have
+its implementation generated.
+
+| Attribute | Value |
+|--------------------|---------------------|
+| Diagnostic ID | ST0002 |
+| Title | MethodClass failure |
+| Category | SilkTouch.Internal |
+| Default Severity | Error |
+| Enabled by Default | Yes |
+
+Example message: `MethodClass failed. Exception: '...'`
+
+## Explanation & Solutions
+
+This functionality is no longer supported in 3.0, where this diagnostic is never raised.
diff --git a/docs/silk.net/diagnostics/ST0003.md b/docs/silk.net/diagnostics/ST0003.md
new file mode 100644
index 0000000000..d722f5f6d7
--- /dev/null
+++ b/docs/silk.net/diagnostics/ST0003.md
@@ -0,0 +1,20 @@
+# ST0003 - Silk.NET.Core is Missing
+
+## Overview
+
+This internal diagnostic was raised by SilkTouch when failing to generate an implementation for bindings at source
+generation time due to the binding project missing a reference to Silk.NET.Core.
+
+| Attribute | Value |
+|--------------------|---------------------|
+| Diagnostic ID | ST0003 |
+| Title | MethodClass failure |
+| Category | SilkTouch.Internal |
+| Default Severity | Info |
+| Enabled by Default | Yes |
+
+Example message: `Silk.NET.Core is missing from references. You should use SilkTouch with Silk.NET.Core`
+
+## Explanation & Solutions
+
+This functionality is no longer supported in 3.0, where this diagnostic is never raised.
diff --git a/docs/silk.net/diagnostics/ST0004.md b/docs/silk.net/diagnostics/ST0004.md
new file mode 100644
index 0000000000..4680d3faa3
--- /dev/null
+++ b/docs/silk.net/diagnostics/ST0004.md
@@ -0,0 +1,20 @@
+# ST0004 - Build Info
+
+## Overview
+
+This internal diagnostic was raised by SilkTouch when configured to do so. It provided diagnostic information relating
+to the performance and characteristics of SilkTouch's internals.
+
+| Attribute | Value |
+|--------------------|--------------------|
+| Diagnostic ID | ST0004 |
+| Title | Build Info |
+| Category | SilkTouch.Internal |
+| Default Severity | Warning |
+| Enabled by Default | Yes |
+
+Example message: `GCSlotCount: '127'. Time: '6437ms'`
+
+## Explanation & Solutions
+
+This functionality is no longer supported in 3.0, where this diagnostic is never raised.
diff --git a/docs/silk.net/diagnostics/ST0005.md b/docs/silk.net/diagnostics/ST0005.md
new file mode 100644
index 0000000000..8a7c730766
--- /dev/null
+++ b/docs/silk.net/diagnostics/ST0005.md
@@ -0,0 +1,15 @@
+# ST0005 - Intentionally Unstable API
+
+## Overview
+
+This diagnostic is raised when trying to use a Silk.NET API that has been marked with the `Experimental` attribute due
+to its API and/or ABI being unstable. When this diagnostic ID is used, it indicates that it is intentional that this is
+the case and that this API is extremely unlikely to ever graduate to a stable, versioned API.
+
+## Explanation & Solutions
+
+Typically, APIs meeting this description are internal APIs and are not intended for use outside of the assembly they're
+defined in. As a result, where this diagnostic is raised, you should cease use of this API or at least only continue if
+you can guarantee that you will never update Silk.NET ever again and that your downstream consumers, if applicable, will
+lock their version to the same version referenced by your project. However, please reconsider use of the API if this is
+the case.
diff --git a/eng/build/Silk.NET.NUKE.csproj b/eng/build/Silk.NET.NUKE.csproj
index b6071aa7bd..a485d81551 100644
--- a/eng/build/Silk.NET.NUKE.csproj
+++ b/eng/build/Silk.NET.NUKE.csproj
@@ -19,4 +19,10 @@
+
+
+
+ Directory.Build\tests\Input\Input\Silk.NET.Input.UnitTests.csproj
+
+
diff --git a/eng/submodules/openal-soft b/eng/submodules/openal-soft
index 6e0d0b39b3..9f6fa42d90 160000
--- a/eng/submodules/openal-soft
+++ b/eng/submodules/openal-soft
@@ -1 +1 @@
-Subproject commit 6e0d0b39b3d9e5b027f01bdd2fbf18ca8ce5a2db
+Subproject commit 9f6fa42d902609bb9f77e4831d9183effad546f5
diff --git a/eng/submodules/silk.net-2.x b/eng/submodules/silk.net-2.x
index c87fc664cc..ca36450946 160000
--- a/eng/submodules/silk.net-2.x
+++ b/eng/submodules/silk.net-2.x
@@ -1 +1 @@
-Subproject commit c87fc664cc5d15abd754997b0a9ad138470554c4
+Subproject commit ca364509467f0fb40af40f7ed040b4c63670a4c6
diff --git a/sources/Core/Core/Pointers/PointerExtensions.cs b/sources/Core/Core/Pointers/PointerExtensions.cs
index cfacf2de27..41f57aa519 100644
--- a/sources/Core/Core/Pointers/PointerExtensions.cs
+++ b/sources/Core/Core/Pointers/PointerExtensions.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
@@ -232,6 +233,43 @@ public static string ReadToString(this Ptr @this)
}
}
+ ///
+ /// Populates the given span with the characters of this as a c-style string.
+ ///
+ ///
+ /// The span to populate characters into
+ /// True if the given span is of sufficient length and can be filled - false otherwise, in which case
+ /// no data has been modified in the given span
+ public static bool TryReadToSpan(this Ptr @this, ref Span span)
+ {
+ fixed (void* raw = @this)
+ {
+ var bytes = MemoryMarshal.CreateReadOnlySpanFromNullTerminated((byte*)raw);
+ var count = Encoding.UTF8.GetCharCount(bytes);
+ if (span.Length < count)
+ {
+ return false;
+ }
+
+ #if DEBUG
+ // This if-def is here to prevent this constant string from taking up space in extremely constrained
+ // release environments.
+ const string assertionLog = $"{nameof(Encoding)}.{nameof(Encoding.UTF8)}." +
+ $"{nameof(Encoding.UTF8.GetChars)}) returned an unexpected number of " +
+ $"characters";
+
+ var charCount = Encoding.UTF8.GetChars(bytes, span);
+ Debug.Assert(charCount == count, assertionLog);;
+ #else
+ Encoding.UTF8.GetChars(bytes, span);
+ #endif
+
+ span = span[..count];
+ return true;
+ }
+ }
+
+
///
/// Creates a string from this with the given length
///
diff --git a/sources/Core/Core/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/sources/Core/Core/PublicAPI/net10.0/PublicAPI.Unshipped.txt
index c1f3456ebc..9addc7e060 100644
--- a/sources/Core/Core/PublicAPI/net10.0/PublicAPI.Unshipped.txt
+++ b/sources/Core/Core/PublicAPI/net10.0/PublicAPI.Unshipped.txt
@@ -481,6 +481,7 @@ static Silk.NET.Core.PointerExtensions.ReadToStringArray(this Silk.NET.Core.Ref3
static Silk.NET.Core.PointerExtensions.ReadToStringArray(this Silk.NET.Core.Ref3D this, int length, int[]! lengths) -> string?[]?[]?
static Silk.NET.Core.PointerExtensions.ReadToStringArray(this Silk.NET.Core.Ref3D this, int length, int[]! lengths) -> string?[]?[]?
static Silk.NET.Core.PointerExtensions.ReadToStringArray(this Silk.NET.Core.Ref3D this, int length, int[]! lengths) -> string?[]?[]?
+static Silk.NET.Core.PointerExtensions.TryReadToSpan(this Silk.NET.Core.Ptr this, ref System.Span span) -> bool
static Silk.NET.Core.Ptr.explicit operator nint(Silk.NET.Core.Ptr ptr) -> nint
static Silk.NET.Core.Ptr.explicit operator Silk.NET.Core.Ptr(nint ptr) -> Silk.NET.Core.Ptr
static Silk.NET.Core.Ptr.explicit operator Silk.NET.Core.Ptr(Silk.NET.Core.Ref ptr) -> Silk.NET.Core.Ptr
diff --git a/sources/Core/Core/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/sources/Core/Core/PublicAPI/net8.0/PublicAPI.Unshipped.txt
index 8123f98932..2b7101b7a3 100644
--- a/sources/Core/Core/PublicAPI/net8.0/PublicAPI.Unshipped.txt
+++ b/sources/Core/Core/PublicAPI/net8.0/PublicAPI.Unshipped.txt
@@ -416,6 +416,7 @@ static Silk.NET.Core.PointerExtensions.ReadToStringArray(this Silk.NET.Core.Ref3
static Silk.NET.Core.PointerExtensions.ReadToStringArray(this Silk.NET.Core.Ref3D this, int length, int[]! lengths) -> string?[]?[]?
static Silk.NET.Core.PointerExtensions.ReadToStringArray(this Silk.NET.Core.Ref3D this, int length, int[]! lengths) -> string?[]?[]?
static Silk.NET.Core.PointerExtensions.ReadToStringArray(this Silk.NET.Core.Ref3D this, int length, int[]! lengths) -> string?[]?[]?
+static Silk.NET.Core.PointerExtensions.TryReadToSpan(this Silk.NET.Core.Ptr this, ref System.Span span) -> bool
static Silk.NET.Core.Ptr.explicit operator nint(Silk.NET.Core.Ptr ptr) -> nint
static Silk.NET.Core.Ptr.explicit operator Silk.NET.Core.Ptr(nint ptr) -> Silk.NET.Core.Ptr
static Silk.NET.Core.Ptr.explicit operator Silk.NET.Core.Ptr(Silk.NET.Core.Ref ptr) -> Silk.NET.Core.Ptr
diff --git a/sources/Input/Input/Button.cs b/sources/Input/Input/Button.cs
new file mode 100644
index 0000000000..1ae8875c65
--- /dev/null
+++ b/sources/Input/Input/Button.cs
@@ -0,0 +1,24 @@
+namespace Silk.NET.Input;
+
+///
+/// Represents a button the user can push.
+///
+/// The name of the button.
+/// Whether the user is pushing the button.
+///
+/// The pressure with which the user is pushing the button, where 0.0 is the smallest measurable pressure and
+/// 1.0 is the largest measurable pressure.
+///
+///
+/// The button type (e.g. , , etc).
+///
+public readonly record struct Button(T Name, bool IsDown, float Pressure)
+ where T : unmanaged, Enum
+{
+ ///
+ /// Collapses this struct into just its value.
+ ///
+ /// The button state.
+ /// The value.
+ public static implicit operator bool(Button state) => state.IsDown;
+}
diff --git a/sources/Input/Input/ButtonChangedEvent.cs b/sources/Input/Input/ButtonChangedEvent.cs
new file mode 100644
index 0000000000..eb2c07ad73
--- /dev/null
+++ b/sources/Input/Input/ButtonChangedEvent.cs
@@ -0,0 +1,22 @@
+using System.Diagnostics;
+using Silk.NET.Input.SDL3;
+
+namespace Silk.NET.Input;
+
+///
+/// Contains information pertaining to a button state change (e.g. press, depress, etc).
+///
+/// The device on which the button being pressed or depressed resides.
+///
+/// The timestamp (as retrieved from ) at which the event occurred.
+///
+/// The new state of the button being pressed or depressed.
+/// The previous state of the button.
+/// The button type e.g. , , etc.
+public readonly record struct ButtonChangedEvent(
+ IButtonDevice Device,
+ long Timestamp,
+ Button Button,
+ Button Previous
+)
+ : ITimestampedEvent where T : unmanaged, Enum;
diff --git a/sources/Input/Input/ButtonReadOnlyList.cs b/sources/Input/Input/ButtonReadOnlyList.cs
new file mode 100644
index 0000000000..1cd98aa366
--- /dev/null
+++ b/sources/Input/Input/ButtonReadOnlyList.cs
@@ -0,0 +1,47 @@
+using System.Collections;
+
+namespace Silk.NET.Input;
+
+///
+/// An implementation of providing utility APIs for getting a
+/// given a button name , that is optimised for storing s with the
+/// given button name type using the most memory-efficient mechanism available.
+///
+///
+/// The button type (e.g. , , etc).
+///
+public readonly record struct ButtonReadOnlyList : IReadOnlyList>
+ where T : unmanaged, Enum
+{
+ private readonly Func _indexMap;
+ private readonly IReadOnlyList> _list;
+
+ ///
+ /// A constructor for an input list that takes in:
+ ///
+ /// A list of buttons that will be indexed
+ /// A pre-built mapping function, if required,
+ /// used for iterating through the button list in order, regardless of the backend's internal button order.
+ public ButtonReadOnlyList(IReadOnlyList> buttonList, Func? indexMap = null)
+ {
+ _list = buttonList;
+ _indexMap = indexMap ?? (i => i);
+ }
+
+ ///
+ /// Gets the state for the button with the given name.
+ ///
+ /// The button name.
+ public Button this[T name] => _list[EnumInfo.ValueIndexOf(name)];
+
+ ///
+ public IEnumerator> GetEnumerator() => _list.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ ///
+ public int Count => _list.Count;
+
+ ///
+ public Button this[int index] => _list[_indexMap(index)];
+}
diff --git a/sources/Input/Input/ConnectionEvent.cs b/sources/Input/Input/ConnectionEvent.cs
new file mode 100644
index 0000000000..85d57230c2
--- /dev/null
+++ b/sources/Input/Input/ConnectionEvent.cs
@@ -0,0 +1,14 @@
+using System.Diagnostics;
+using Silk.NET.Input.SDL3;
+
+namespace Silk.NET.Input;
+
+///
+/// Contains information pertaining to a device connection or disconnection event.
+///
+/// The device that has disconnected or connected.
+///
+/// The timestamp (as retrieved from ) at which the event occurred.
+///
+/// Whether the device has connected (true ) or disconnected (false ).
+public readonly record struct ConnectionEvent(IInputDevice Device, long Timestamp, bool IsConnected) : ITimestampedEvent;
diff --git a/sources/Input/Input/CursorModes.cs b/sources/Input/Input/CursorModes.cs
new file mode 100644
index 0000000000..a5ce021ea8
--- /dev/null
+++ b/sources/Input/Input/CursorModes.cs
@@ -0,0 +1,57 @@
+namespace Silk.NET.Input;
+
+///
+/// Enumerates the modes in which a mouse cursor can operate.
+///
+///
+/// implementations for implementations typically have two
+/// :
+///
+/// -
+///
Bounded
+///
+/// An that is bounded to the desktop environment i.e. the
+/// are not infinite and reflect the total screen space that is available to the
+/// running application in window coordinates. This is typically the sum of all monitor resolutions, with the positions
+/// being defined using an implementation-defined mechanism. The window bounds operate in this same coordinate space.
+/// It is highly unlikely that you will be unable to determine the individual points for multiple mice on this target,
+/// as desktop environments typically aggregate all movement from all mice into a single .
+/// This target is used for every cursor mode except .
+///
+///
+/// -
+///
Unbounded
+///
+/// An that is unbounded and operates in an arbitrary coordinate space. This target is used
+/// for raw mouse mode and points on this target represent the net mouse movement from a mouse. Implementations
+/// are more likely to be able to give multiple s for each mouse when this target is used. This
+/// target is used when the cursor mode is enabled. will
+/// represent an infinitely large unbounded target.
+///
+///
+///
+///
+[Flags]
+public enum CursorModes
+{
+ ///
+ /// The cursor is visible to the user and operating within the bounds of the desktop environment . The
+ /// coordinates received are in desktop coordinates, operating in the same coordinate space as the window
+ /// position/size.
+ ///
+ Normal = 1 << 0,
+
+ ///
+ /// The cursor is visible to the user but is constrained to the window's client area . The coordinates
+ /// received are in desktop coordinates, operating in the same coordinate space as the window position/size.
+ /// The bounded to the desktop environment is used.
+ ///
+ Confined = 1 << 1,
+
+ ///
+ /// The cursor is invisible to the user and is unconstrained/unbounded . The coordinates received are
+ /// arbitrary values that have no bounds representing the net mouse movement since entering into this cursor mode.
+ /// The unbounded is used. This is the equivalent of raw mouse mode .
+ ///
+ Unbounded = 1 << 2,
+}
\ No newline at end of file
diff --git a/sources/Input/Input/CursorStyles.cs b/sources/Input/Input/CursorStyles.cs
new file mode 100644
index 0000000000..65ecfc6f55
--- /dev/null
+++ b/sources/Input/Input/CursorStyles.cs
@@ -0,0 +1,61 @@
+namespace Silk.NET.Input;
+
+///
+/// Enumerates the cursor styles with which the desktop environment should render the cursor.
+///
+[Flags]
+public enum CursorStyles
+{
+ ///
+ /// The cursor should be rendered using its default image.
+ ///
+ Default,
+
+ ///
+ /// The cursor should be rendered using an arrow cursor image.
+ ///
+ Arrow = 1 << 0,
+
+ ///
+ /// The cursor should be rendered using an I-beam cursor image, which is used to show where the text cursor appears
+ /// when the mouse is clicked.
+ ///
+ IBeam = 1 << 1,
+
+ ///
+ /// The cursor should be rendered using a crosshair cursor image.
+ ///
+ Crosshair = 1 << 2,
+
+ ///
+ /// The cursor should be rendered using a hand cursor image, typically used when hovering over a web link.
+ ///
+ Hand = 1 << 3,
+
+ ///
+ /// The cursor should be rendered using a two-headed horizontal sizing cursor image.
+ ///
+ HResize = 1 << 4,
+
+ ///
+ /// The cursor should be rendered using a two-headed vertical sizing cursor image.
+ ///
+ VResize = 1 << 5,
+
+ ///
+ /// The cursor should not be rendered.
+ ///
+ ///
+ /// When is used, the cursor ceases to exist anyway. As such, while the
+ /// property may not reflect this (as it is retained across changes to
+ /// and just ignored when is used),
+ /// can be implied as being when
+ /// is used.
+ ///
+ Hidden = 1 << 6,
+
+ ///
+ /// The cursor should be rendered using a custom application-provided image.
+ ///
+ Custom = 1 << 7,
+}
\ No newline at end of file
diff --git a/sources/Input/Input/CustomCursor.cs b/sources/Input/Input/CustomCursor.cs
new file mode 100644
index 0000000000..2a38345bd0
--- /dev/null
+++ b/sources/Input/Input/CustomCursor.cs
@@ -0,0 +1,69 @@
+namespace Silk.NET.Input;
+
+///
+/// Represents a custom image for a mouse cursor.
+///
+public readonly ref struct CustomCursor : IEquatable
+{
+ ///
+ /// The number of pixels in the X axis.
+ ///
+ public int Width { get; init; }
+
+ ///
+ /// The number of pixels in the Y axis.
+ ///
+ public int Height { get; init; }
+
+ ///
+ /// The row-major 32-bit RGBA pixel data (i.e. 8 bits for each colour component).
+ ///
+ public ReadOnlySpan Data { get; init; } // Rgba32
+
+ // equality operator override
+ ///
+ /// Value-based equality operator
+ ///
+ /// Note that this operator does not consider reference equality
+ public static bool operator ==(CustomCursor left, CustomCursor right) => left.Width == right.Width &&
+ left.Height == right.Height &&
+ left.Data.Length == right.Data.Length &&
+ left.Data.SequenceEqual(right.Data);
+
+ ///
+ /// Value-based inequality operator
+ ///
+ /// Note that this operator does not consider reference equality
+ public static bool operator !=(CustomCursor left, CustomCursor right) => !(left == right);
+
+ ///
+ /// Value-based equality check.
+ ///
+ ///
+ ///
+ public bool Equals(CustomCursor other) => Width == other.Width && Height == other.Height && Data.SequenceEqual(other.Data);
+
+ ///
+ /// Value-based hashcode
+ ///
+ public override int GetHashCode()
+ {
+ HashCode hashCode = new();
+ hashCode.Add(Width);
+ hashCode.Add(Height);
+ for (var i = 0; i < Data.Length; ++i)
+ {
+ hashCode.Add(Data[i]);
+ }
+
+ return hashCode.ToHashCode();
+ }
+
+ ///
+ /// Reference-based equality check. Because this is a ref struct , this will always return false , as
+ /// this cannot escape the stack, and thus cannot be boxed, and thus cannot be cast to/from object .
+ ///
+ ///
+ ///
+ public override bool Equals(object? o) => false;
+}
diff --git a/sources/Input/Input/DualReadOnlyList.cs b/sources/Input/Input/DualReadOnlyList.cs
new file mode 100644
index 0000000000..2181706f22
--- /dev/null
+++ b/sources/Input/Input/DualReadOnlyList.cs
@@ -0,0 +1,58 @@
+using System.Collections;
+
+namespace Silk.NET.Input;
+
+///
+/// Represents a list that has exactly two elements.
+///
+/// The element type.
+public readonly struct DualReadOnlyList : IReadOnlyList
+{
+ ///
+ /// Represents a list that has exactly two elements.
+ ///
+ /// The element type.
+
+ public DualReadOnlyList(Func left, Func right)
+ {
+ _left = left;
+ _right = right;
+ }
+
+ ///
+ /// The first/leftmost element.
+ ///
+ public T Left => _left();
+
+ ///
+ /// The second/rightmost element.
+ ///
+ public T Right => _right();
+
+
+ ///
+ public IEnumerator GetEnumerator()
+ {
+ yield return Left;
+ yield return Right;
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ ///
+ public int Count => 2;
+
+ ///
+ public T this[int index] =>
+ index switch
+ {
+ 0 => Left,
+ 1 => Right,
+ _ => throw new IndexOutOfRangeException(),
+ };
+
+
+
+ private readonly Func _left;
+ private readonly Func _right;
+}
diff --git a/sources/Input/Input/GamepadState.cs b/sources/Input/Input/GamepadState.cs
new file mode 100644
index 0000000000..62ea3e9605
--- /dev/null
+++ b/sources/Input/Input/GamepadState.cs
@@ -0,0 +1,59 @@
+using System.Numerics;
+
+namespace Silk.NET.Input;
+
+///
+/// Contains user input received from an .
+///
+public class GamepadState
+{
+ ///
+ /// The constructor for a new GamepadState object
+ ///
+ /// The list of buttons
+ /// The list of states of the controllers axes that the triggers and joysticks will
+ /// be read from via their specific indices in this array
+ /// The joystick X axes.
+ /// The Joystick Y axes.
+ ///
+ ///
+ /// For and , the must be either of length
+ /// 2 or 4.
+ ///
+ /// If two are provided, the first is assumed to be the left stick, and the second is assumed to be the right stick
+ ///
+ /// if 4 are provided, it is assumed that the first two are - and + sides of the first axis, and so on.
+ ///[leftX, rightX] OR [-leftX, +leftX, -rightX, +rightX]
+ ///
+ ///
+ ///
+ public GamepadState(IReadOnlyList> buttons, IReadOnlyList axisStates)
+ {
+ _axisStates = axisStates;
+ Buttons = new ButtonReadOnlyList(buttons);
+ Triggers = new DualReadOnlyList(
+ left: () => _axisStates[JoystickAxis.LeftTrigger.Index()],
+ right: () =>_axisStates[JoystickAxis.RightTrigger.Index()]);
+ Thumbsticks = new DualReadOnlyList(
+ left: () => new Vector2(_axisStates[JoystickAxis.LeftX.Index()], _axisStates[JoystickAxis.LeftY.Index()]),
+ right: () => new Vector2(_axisStates[JoystickAxis.RightX.Index()], _axisStates[JoystickAxis.RightY.Index()]));
+ }
+
+ ///
+ /// Gets the gamepad button state denoting the buttons being pressed or depressed.
+ ///
+ public ButtonReadOnlyList Buttons { get; }
+
+ ///
+ /// Gets the state of the twin sticks on the gamepad.
+ ///
+ public DualReadOnlyList Thumbsticks { get; internal set; }
+
+ ///
+ /// Gets the state of the triggers on the gamepad.
+ ///
+ public DualReadOnlyList Triggers { get; internal set; }
+
+ // ReSharper disable PrivateFieldCanBeConvertedToLocalVariable <- keeps closures consistent
+ private readonly IReadOnlyList _axisStates;
+}
diff --git a/sources/Input/Input/GamepadThumbstickMoveEvent.cs b/sources/Input/Input/GamepadThumbstickMoveEvent.cs
new file mode 100644
index 0000000000..4fccbeb7b1
--- /dev/null
+++ b/sources/Input/Input/GamepadThumbstickMoveEvent.cs
@@ -0,0 +1,22 @@
+using System.Diagnostics;
+using System.Numerics;
+using Silk.NET.Input.SDL3;
+
+namespace Silk.NET.Input;
+
+///
+/// Contains information pertaining to the movement of a thumbstick.
+///
+/// The gamepad on which the thumbstick resides.
+///
+/// The timestamp (as retrieved from ) at which the event occurred.
+///
+///
+/// The new position of the thumbstick, where each axis is between -1.0 and 1.0 .
+///
+/// The change in as a result of this event.
+///
+/// Todo: this should probably include a thumbstick/axis identifier, right? At least left/right?
+/// see , which has an Axis field
+///
+public readonly record struct GamepadThumbstickMoveEvent(IGamepad Gamepad, long Timestamp, Vector2 Value, Vector2 Delta) : ITimestampedEvent;
diff --git a/sources/Input/Input/GamepadTriggerMoveEvent.cs b/sources/Input/Input/GamepadTriggerMoveEvent.cs
new file mode 100644
index 0000000000..1711de71aa
--- /dev/null
+++ b/sources/Input/Input/GamepadTriggerMoveEvent.cs
@@ -0,0 +1,18 @@
+using System.Diagnostics;
+using Silk.NET.Input.SDL3;
+
+namespace Silk.NET.Input;
+
+///
+/// Contains information pertaining to the movement of a trigger.
+///
+/// The gamepad on which the trigger resides.
+///
+/// The timestamp (as retrieved from ) at which the event occurred.
+///
+/// The index of the trigger that has moved.
+///
+/// The new value of the trigger, between 0.0 (fully depressed) and 1.0 (fully pressed).
+///
+/// The change in as a result of this event.
+public readonly record struct GamepadTriggerMoveEvent(IGamepad Gamepad, long Timestamp, int Axis, float Value, float Delta) : ITimestampedEvent;
diff --git a/sources/Input/Input/Handlers/Gamepads.cs b/sources/Input/Input/Handlers/Gamepads.cs
new file mode 100644
index 0000000000..74501a9aad
--- /dev/null
+++ b/sources/Input/Input/Handlers/Gamepads.cs
@@ -0,0 +1,45 @@
+namespace Silk.NET.Input;
+
+///
+/// Represents a collection of s from which input events can be received.
+///
+public sealed class Gamepads : InputContextDeviceList, IGamepadInputHandler
+{
+ internal Gamepads(InputContext ctx)
+ : base(ctx)
+ {
+ }
+
+ ///
+ /// Raised when state pertaining to a pushable button on the gamepad changes (e.g. button up, button down).
+ ///
+ public event Action>? ButtonChanged;
+
+ ///
+ /// Raised when a thumbstick on the gamepad moves.
+ ///
+ public event Action? ThumbstickMove;
+
+ ///
+ /// Raised when a trigger on the gamepad moves.
+ ///
+ public event Action? TriggerMove;
+
+ internal void HandleButtonChanged(ButtonChangedEvent @event) =>
+ ButtonChanged?.Invoke(@event);
+
+ void IButtonInputHandler.HandleButtonChanged(
+ ButtonChangedEvent @event
+ ) => HandleButtonChanged(@event);
+
+ internal void HandleThumbstickMove(GamepadThumbstickMoveEvent @event) =>
+ ThumbstickMove?.Invoke(@event);
+
+ void IGamepadInputHandler.HandleThumbstickMove(GamepadThumbstickMoveEvent @event) =>
+ HandleThumbstickMove(@event);
+
+ internal void HandleTriggerMove(GamepadTriggerMoveEvent @event) => TriggerMove?.Invoke(@event);
+
+ void IGamepadInputHandler.HandleTriggerMove(GamepadTriggerMoveEvent @event) =>
+ HandleTriggerMove(@event);
+}
diff --git a/sources/Input/Input/Handlers/InputContext.cs b/sources/Input/Input/Handlers/InputContext.cs
new file mode 100644
index 0000000000..b32bd34d72
--- /dev/null
+++ b/sources/Input/Input/Handlers/InputContext.cs
@@ -0,0 +1,288 @@
+using System.Collections;
+using System.Diagnostics;
+
+namespace Silk.NET.Input;
+
+///
+/// Represents an "input context" containing multiple s from which
+/// s, their state, and their events are aggregated and laid-out in a user-friendly fashion.
+///
+///
+/// The onus is on the user to coordinate using this type across threads, as the input backend is not thread safe
+/// In addition, certain backends may have (unavoidable) restrictions on what thread can be called
+/// on - the user is responsible for respecting these threading rules as well.
+///
+public class InputContext
+ : IJoystickInputHandler,
+ IGamepadInputHandler,
+ IMouseInputHandler,
+ IPointerInputHandler,
+ IKeyboardInputHandler,
+ IList,
+ IReadOnlyList
+{
+ // These are lazy-initialized as they contain their own device lists in addition to the device list stored here and
+ // the device lists stored in each of the backends. You could argue having this many duplicated lists is inefficient
+ // and you'd be absolutely right, but realistically: how many devices will the average user have connected to their
+ // PC? If you're worried about your game's memory consumption, you're probably not looking at the small lists that
+ // input allocates... This way we can also provide sane/consistent indices.
+ private Pointers? _pointers;
+ private Keyboards? _keyboards;
+ private Gamepads? _gamepads;
+ private Joysticks? _joysticks;
+ private readonly List _backends = [];
+ private List? _devices;
+
+ ///
+ /// Gets the s enumerated by the s attached to this context.
+ ///
+ public Pointers Pointers => _pointers ??= new Pointers(this);
+
+ ///
+ /// Gets the s enumerated by the s attached to this context.
+ ///
+ public Keyboards Keyboards => _keyboards ??= new Keyboards(this);
+
+ ///
+ /// Gets the s enumerated by the s attached to this context.
+ ///
+ public Gamepads Gamepads => _gamepads ??= new Gamepads(this);
+
+ ///
+ /// Gets the s enumerated by the s attached to this context.
+ ///
+ public Joysticks Joysticks => _joysticks ??= new Joysticks(this);
+
+ ///
+ /// Gets the s enumerated by the s attached to this context.
+ ///
+ public IReadOnlyList Devices
+ {
+ get
+ {
+ if (_devices is not null)
+ {
+ return _devices;
+ }
+
+ if (Backends.Count == 0)
+ {
+ return _devices = [];
+ }
+
+ var deviceCount = 0;
+
+ foreach(var backend in Backends)
+ {
+ deviceCount += backend.Devices.Count;
+ }
+
+ _devices = new List(deviceCount);
+
+ foreach (var backend in Backends)
+ {
+ _devices ??= new List(backend.Devices.Count);
+ _devices.AddRange(backend.Devices);
+ }
+
+ return _devices ??= [];
+ }
+ }
+
+ ///
+ /// Gets a list denoting the attached to this context.
+ ///
+ public IList Backends => this;
+
+ ///
+ /// Raised when a device is added or removed from the list of connected .
+ ///
+ public event Action? ConnectionChanged;
+
+ ///
+ /// Polls and updates the state of the objects connected to each
+ /// attached to this context, raising appropriate events for each state change.
+ ///
+ ///
+ /// This calls for each attached to this context.
+ ///
+ public void Update()
+ {
+ foreach (var backend in Backends)
+ {
+ backend.Update(this);
+ }
+
+ _pointers?.ProcessClicks();
+ }
+
+ private void HandleBackendRemoval(IInputBackend backend)
+ {
+ var timestamp = Stopwatch.GetTimestamp();
+ foreach (var device in backend.Devices)
+ {
+ HandleDeviceConnectionChanged(new ConnectionEvent(device, timestamp, false));
+ }
+ }
+
+ private void HandleBackendAddition(IInputBackend backend)
+ {
+ var timestamp = Stopwatch.GetTimestamp();
+ foreach (var device in backend.Devices)
+ {
+ HandleDeviceConnectionChanged(new ConnectionEvent(device, timestamp, true));
+ }
+ }
+
+ private void HandleDeviceConnectionChanged(ConnectionEvent e)
+ {
+ _pointers?.HandleDeviceConnectionChanged(e);
+ _joysticks?.HandleDeviceConnectionChanged(e);
+ _gamepads?.HandleDeviceConnectionChanged(e);
+ _keyboards?.HandleDeviceConnectionChanged(e);
+
+ if (_devices is null)
+ {
+ return;
+ }
+
+ if (e.IsConnected)
+ {
+ _devices.Add(e.Device);
+ }
+ else
+ {
+ _devices.Remove(e.Device);
+ }
+ }
+
+ void IInputHandler.Handle(PointerTargetChangedEvent @event) =>
+ _pointers?.HandleTargetChanged(@event);
+
+ void IInputHandler.Handle(PointChangedEvent @event) =>
+ _pointers?.HandlePointChanged(@event);
+
+ void IInputHandler.Handle(PointerGripChangedEvent @event) =>
+ _pointers?.HandleGripChanged(@event);
+
+ void IButtonInputHandler.HandleButtonChanged(ButtonChangedEvent @event) =>
+ _joysticks?.HandleButtonChanged(@event);
+
+ void IJoystickInputHandler.HandleAxisMove(JoystickAxisMoveEvent @event) =>
+ _joysticks?.HandleAxisMove(@event);
+
+ void IJoystickInputHandler.HandleHatMove(JoystickHatMoveEvent @event) =>
+ _joysticks?.HandleHatMove(@event);
+
+ void IGamepadInputHandler.HandleThumbstickMove(GamepadThumbstickMoveEvent @event) =>
+ _gamepads?.HandleThumbstickMove(@event);
+
+ void IGamepadInputHandler.HandleTriggerMove(GamepadTriggerMoveEvent @event) =>
+ _gamepads?.HandleTriggerMove(@event);
+
+ void IButtonInputHandler.HandleButtonChanged(ButtonChangedEvent @event) =>
+ _pointers?.HandleButtonChanged(@event);
+
+ void IMouseInputHandler.HandleScroll(MouseScrollEvent @event) =>
+ _pointers?.HandleScroll(@event);
+
+ void IPointerInputHandler.HandleTargetChanged(PointerTargetChangedEvent @event) =>
+ _pointers?.HandleTargetChanged(@event);
+
+ void IPointerInputHandler.HandlePointChanged(PointChangedEvent @event) =>
+ _pointers?.HandlePointChanged(@event);
+
+ void IPointerInputHandler.HandleGripChanged(PointerGripChangedEvent @event) =>
+ _pointers?.HandleGripChanged(@event);
+
+ void IButtonInputHandler.HandleButtonChanged(ButtonChangedEvent @event) =>
+ _keyboards?.HandleButtonChanged(@event);
+
+ void IKeyboardInputHandler.HandleKeyChanged(KeyChangedEvent @event) =>
+ _keyboards?.HandleKeyChanged(@event);
+
+ void IKeyboardInputHandler.HandleKeyChar(KeyCharEvent @event) =>
+ _keyboards?.HandleKeyChar(@event);
+
+ void IInputHandler.HandleDeviceConnectionChanged(ConnectionEvent @event)
+ {
+ HandleDeviceConnectionChanged(@event);
+ ConnectionChanged?.Invoke(@event);
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() =>
+ _backends.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => _backends.GetEnumerator();
+
+ void ICollection.Add(IInputBackend item)
+ {
+ HandleBackendAddition(item);
+ _backends.Add(item);
+ }
+
+ void ICollection.Clear()
+ {
+ foreach (var backend in Backends)
+ {
+ HandleBackendRemoval(backend);
+ }
+ }
+
+ bool ICollection.Contains(IInputBackend item) => _backends.Contains(item);
+
+ void ICollection.CopyTo(IInputBackend[] array, int arrayIndex) =>
+ _backends.CopyTo(array, arrayIndex);
+
+ bool ICollection.Remove(IInputBackend item)
+ {
+ HandleBackendRemoval(item);
+ return _backends.Remove(item);
+ }
+
+ int ICollection.Count => _backends.Count;
+
+ bool ICollection.IsReadOnly => false;
+
+ int IList.IndexOf(IInputBackend item) => _backends.IndexOf(item);
+
+ void IList.Insert(int index, IInputBackend item)
+ {
+ HandleBackendAddition(item);
+ _backends.Insert(index, item);
+ }
+
+ void IList.RemoveAt(int index)
+ {
+ var backend = _backends[index];
+ HandleBackendRemoval(backend);
+ _backends.RemoveAt(index);
+ }
+
+ IInputBackend IList.this[int index]
+ {
+ get => _backends[index];
+ set
+ {
+ ArgumentNullException.ThrowIfNull(value);
+
+ var existing = _backends[index];
+ if(existing == value)
+ {
+ return;
+ }
+
+ HandleBackendRemoval(existing);
+ HandleBackendAddition(value);
+ _backends[index] = value;
+ }
+ }
+
+ int IReadOnlyCollection.Count => _backends.Count;
+
+ ///
+ /// Returns the at the specified index.
+ ///
+ ///
+ public IInputBackend this[int index] => _backends[index];
+}
diff --git a/sources/Input/Input/Handlers/Joysticks.cs b/sources/Input/Input/Handlers/Joysticks.cs
new file mode 100644
index 0000000000..f576e6f85f
--- /dev/null
+++ b/sources/Input/Input/Handlers/Joysticks.cs
@@ -0,0 +1,41 @@
+namespace Silk.NET.Input;
+
+///
+/// Represents a collection of s from which input events can be received.
+///
+public sealed class Joysticks : InputContextDeviceList, IJoystickInputHandler
+{
+ internal Joysticks(InputContext ctx)
+ : base(ctx) { }
+
+ ///
+ /// Raised when state pertaining to a pushable button on the joystick changes (e.g. button up, button down).
+ ///
+ public event Action>? ButtonChanged;
+
+ ///
+ /// Raised when a movable axis on the joystick changes position.
+ ///
+ public event Action? AxisMove;
+
+ ///
+ /// Raised when a joystick hat moves.
+ ///
+ public event Action? HatMove;
+
+ internal void HandleButtonChanged(ButtonChangedEvent @event) =>
+ ButtonChanged?.Invoke(@event);
+
+ void IButtonInputHandler.HandleButtonChanged(
+ ButtonChangedEvent @event
+ ) => HandleButtonChanged(@event);
+
+ internal void HandleAxisMove(JoystickAxisMoveEvent @event) => AxisMove?.Invoke(@event);
+
+ void IJoystickInputHandler.HandleAxisMove(JoystickAxisMoveEvent @event) =>
+ HandleAxisMove(@event);
+
+ internal void HandleHatMove(JoystickHatMoveEvent @event) => HatMove?.Invoke(@event);
+
+ void IJoystickInputHandler.HandleHatMove(JoystickHatMoveEvent @event) => HandleHatMove(@event);
+}
diff --git a/sources/Input/Input/Handlers/Keyboards.cs b/sources/Input/Input/Handlers/Keyboards.cs
new file mode 100644
index 0000000000..4d1597867c
--- /dev/null
+++ b/sources/Input/Input/Handlers/Keyboards.cs
@@ -0,0 +1,35 @@
+namespace Silk.NET.Input;
+
+///
+/// Represents a collection of s from which input events can be received.
+///
+public sealed class Keyboards : InputContextDeviceList, IKeyboardInputHandler
+{
+ internal Keyboards(InputContext ctx)
+ : base(ctx)
+ {
+ }
+
+ ///
+ /// Raised when state pertaining to a pushable key on the keyboard changes (e.g. key up, key down, key repeat).
+ ///
+ public event Action? KeyChanged;
+
+ ///
+ /// Raised when the user types a character using the keyboard.
+ ///
+ public event Action? KeyChar;
+
+ internal void HandleButtonChanged(ButtonChangedEvent @event) { }
+
+ void IButtonInputHandler.HandleButtonChanged(ButtonChangedEvent @event) =>
+ HandleButtonChanged(@event);
+
+ internal void HandleKeyChanged(KeyChangedEvent @event) => KeyChanged?.Invoke(@event);
+
+ void IKeyboardInputHandler.HandleKeyChanged(KeyChangedEvent @event) => HandleKeyChanged(@event);
+
+ internal void HandleKeyChar(KeyCharEvent @event) => KeyChar?.Invoke(@event);
+
+ void IKeyboardInputHandler.HandleKeyChar(KeyCharEvent @event) => HandleKeyChar(@event);
+}
diff --git a/sources/Input/Input/Handlers/Pointers.cs b/sources/Input/Input/Handlers/Pointers.cs
new file mode 100644
index 0000000000..c0b6cc9beb
--- /dev/null
+++ b/sources/Input/Input/Handlers/Pointers.cs
@@ -0,0 +1,397 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using Silk.NET.Input.SDL3;
+
+namespace Silk.NET.Input;
+
+///
+/// Represents a collection of s from which input events can be received.
+///
+public sealed class Pointers
+ : InputContextDeviceList,
+ IMouseInputHandler,
+ IPointerInputHandler
+{
+ private long _doubleClickTime;
+ private float _doubleClickRange;
+ private List? _clicks;
+
+ internal Pointers(InputContext ctx)
+ : base(ctx) => ClickConfiguration = PointerClickConfiguration.Default;
+
+ ///
+ /// Gets or sets the configuration that denotes the behaviour of / .
+ ///
+ public PointerClickConfiguration ClickConfiguration
+ {
+ get => new((int)((double)_doubleClickTime / Stopwatch.Frequency * 1000), _doubleClickRange);
+ set =>
+ (_doubleClickTime, _doubleClickRange) = (
+ (long)((double)value.DoubleClickTime / 1000 * Stopwatch.Frequency),
+ value.DoubleClickRange
+ );
+ }
+
+ ///
+ /// Raised when state pertaining to a pushable button on the pointer device changes (e.g. button up, button down).
+ ///
+ public event Action>? ButtonChanged;
+
+ ///
+ /// Raised when one or more events indicate a single click as defined by the
+ /// .
+ ///
+ public event Action? Click;
+
+ ///
+ /// Raised when one or more events indicate a double click as defined by the
+ /// .
+ ///
+ public event Action? DoubleClick;
+
+ ///
+ /// Raised when a 's state changes (e.g. mouse move).
+ ///
+ public event Action? PointChanged;
+
+ ///
+ /// Raised when a user scrolls using a pointer device's mouse wheel.
+ ///
+ public event Action? MouseScroll;
+
+ ///
+ /// Raised when a "target" at which the user can point using a pointer device changes.
+ ///
+ public event Action? TargetChanged;
+
+ ///
+ /// Raised when the user adjusts their grip on the pointer device.
+ ///
+ public event Action? GripChanged;
+
+ void IButtonInputHandler.HandleButtonChanged(
+ ButtonChangedEvent @event
+ ) => HandleButtonChanged(@event);
+
+ internal void HandleButtonChanged(ButtonChangedEvent @event)
+ {
+ if (@event.Device is not IPointerDevice device)
+ {
+ return;
+ }
+
+ ButtonChanged?.Invoke(@event);
+ if (@event.Previous.IsDown || !@event.Button.IsDown)
+ {
+ return;
+ }
+
+ foreach (var target in device.Targets)
+ {
+ var pointCnt = target.GetPointCount(device);
+ for (var i = 0; i < pointCnt; i++)
+ {
+ HandlePointerDown(
+ device,
+ target.GetPoint(device, i),
+ @event.Button.Name,
+ @event.Timestamp
+ );
+ }
+ }
+ }
+
+ void IMouseInputHandler.HandleScroll(MouseScrollEvent @event) => HandleScroll(@event);
+
+ internal void HandleScroll(MouseScrollEvent @event) => MouseScroll?.Invoke(@event);
+
+ void IPointerInputHandler.HandleTargetChanged(PointerTargetChangedEvent @event) =>
+ HandleTargetChanged(@event);
+
+ internal void HandleTargetChanged(PointerTargetChangedEvent @event)
+ {
+ TargetChanged?.Invoke(@event);
+ if (_clicks is null || @event.IsAdded is not false)
+ {
+ return;
+ }
+
+ var clicks = CollectionsMarshal.AsSpan(_clicks);
+ for (var i = 0; i < clicks.Length; i++)
+ {
+ ref var click = ref clicks[i];
+ if (click.FirstClickPosition.Target != @event.Target)
+ {
+ continue;
+ }
+
+ // Raise a click event for posterity.
+ HandleDoubleClickExceedsParameters(ref click);
+ _clicks.RemoveAt(i--);
+
+ // SAFETY: We have to replace the span now as the RemoveAt could've in theory reallocated.
+ clicks = CollectionsMarshal.AsSpan(_clicks);
+ }
+ }
+
+ void IPointerInputHandler.HandlePointChanged(PointChangedEvent @event) =>
+ HandlePointChanged(@event);
+
+ internal void HandlePointChanged(PointChangedEvent @event)
+ {
+ try
+ {
+ PointChanged?.Invoke(@event);
+ }
+ catch (Exception e)
+ {
+ InputLog.Error($"Exception while handling point changed event: {e.Message}\n{e.StackTrace}");
+ }
+
+ if (_clicks is null || @event is not { OldPoint: not null, NewPoint: { } @new })
+ {
+ return;
+ }
+
+ var span = CollectionsMarshal.AsSpan(_clicks);
+ for (var i = 0; i < _clicks.Count; i++)
+ {
+ ref var click = ref span[i];
+ if (!click.IsMatch(@event.Pointer, in @new))
+ {
+ continue;
+ }
+
+ if (!click.HasMovedTooFar(_doubleClickRange, @new.Position))
+ {
+ return;
+ }
+
+ HandleDoubleClickExceedsParameters(ref click);
+ _clicks.RemoveAt(i);
+ return;
+ }
+ }
+
+ void IPointerInputHandler.HandleGripChanged(PointerGripChangedEvent @event) =>
+ HandleGripChanged(@event);
+
+ internal void HandleGripChanged(PointerGripChangedEvent @event) => GripChanged?.Invoke(@event);
+
+ private record struct ClickData(
+ IPointerDevice Device,
+ PointerButton? FirstClickButton,
+ TargetPoint FirstClickPosition,
+ long? FirstClickTime,
+ bool IsFirstClick
+ )
+ {
+ public bool IsMatch(IPointerDevice device, ref readonly TargetPoint point) =>
+ point.Id == FirstClickPosition.Id
+ && Device == device
+ && point.Target == FirstClickPosition.Target;
+
+ public bool HasMovedTooFar(float range, Vector3 position)
+ {
+ var fcp = FirstClickPosition.Position;
+ return MathF.Abs(position.X - fcp.X) >= range
+ && MathF.Abs(position.Y - fcp.Y) >= range
+ && MathF.Abs(position.Z - fcp.Z) >= range;
+ }
+ }
+
+ [MemberNotNull(nameof(_clicks))]
+ private ref ClickData GetClickData(
+ IPointerDevice device,
+ ref readonly TargetPoint point,
+ out int idx
+ )
+ {
+ idx = 0;
+ foreach (ref var ret in CollectionsMarshal.AsSpan(_clicks ??= []))
+ {
+ if (ret.IsMatch(device, in point))
+ {
+ return ref ret;
+ }
+
+ idx++;
+ }
+
+ _clicks.Add(
+ new ClickData(
+ device,
+ null,
+ default(TargetPoint) with
+ {
+ Target = point.Target,
+ Id = point.Id,
+ },
+ null,
+ true
+ )
+ );
+ return ref CollectionsMarshal.AsSpan(_clicks)[idx];
+ }
+
+ private void HandlePointerDown(
+ IPointerDevice device,
+ TargetPoint point,
+ PointerButton button,
+ long timestamp
+ )
+ {
+ if ((_clicks is null && DoubleClick is null && Click is null) || point.Target is null)
+ {
+ return;
+ }
+
+ ref var click = ref GetClickData(device, in point, out var idx);
+ if (click.IsFirstClick || (click.FirstClickButton is { } firstBtn && firstBtn != button))
+ {
+ // This is the first click with the given mouse button.
+ var time = click.FirstClickTime;
+ click.FirstClickTime = null;
+
+ if (
+ click is { IsFirstClick: false, FirstClickButton: { } prevBtn }
+ && time is { } clickTime
+ )
+ {
+ // Only the mouse buttons differ so treat last click as a single click.
+ InvokeClick(new PointerClickEvent(device, clickTime, click.FirstClickPosition, prevBtn));
+ }
+ }
+ else
+ {
+ // This is the second click with the same mouse button.
+ if (click.FirstClickTime is { } fct && timestamp - fct <= _doubleClickTime)
+ {
+ // Within the maximum double click time.
+ click.FirstClickTime = null;
+ if (!click.HasMovedTooFar(_doubleClickRange, point.Position))
+ {
+ // Second click was in time and in range -> double click.
+ DoubleClick?.Invoke(new PointerClickEvent(device, timestamp, point, button));
+
+ // SAFETY: Must not use the click ref from now on! Returning instantly.
+ _clicks.RemoveAt(idx);
+ return;
+ }
+
+ // Second click was in time but outside range -> single click.
+ // The second click is another "first click".
+ InvokeClick(new PointerClickEvent(device, timestamp, point, button));
+ }
+ else
+ {
+ // The double click time elapsed.
+
+ // If Update() would have detected the time elapse before,
+ // it would have set _firstClick back to true and we won't be here.
+ // Therefore Update() has not detected time elapse here and we have
+ // to handle it.
+ HandleDoubleClickExceedsParameters(ref click);
+ }
+ }
+
+ // Process the first click. We process the second click as another "first click" if:
+ // - the double click time elapsed
+ // - the pointer moved too much before doing the second click
+ ProcessFirstClick(ref click, button, point, timestamp);
+ }
+
+ private static void ProcessFirstClick(
+ ref ClickData click,
+ PointerButton button,
+ TargetPoint point,
+ long timestamp
+ )
+ {
+ click.IsFirstClick = false; // for next time...
+ click.FirstClickButton = button;
+ click.FirstClickPosition = point;
+ click.FirstClickTime = timestamp;
+ }
+
+ private void HandleDoubleClickExceedsParameters(ref ClickData click)
+ {
+ if (click is { FirstClickButton: { } fcb, FirstClickTime: { } fct })
+ {
+ InvokeClick(new PointerClickEvent(click.Device, fct, click.FirstClickPosition, fcb));
+ }
+
+ click.FirstClickTime = null;
+ click.IsFirstClick = true;
+ }
+
+ internal void ProcessClicks()
+ {
+ if (_clicks is null)
+ {
+ return;
+ }
+
+ var updateTime = Stopwatch.GetTimestamp();
+ var clicks = CollectionsMarshal.AsSpan(_clicks);
+ for (var i = 0; i < clicks.Length; i++)
+ {
+ ref var click = ref clicks[i];
+ if (click.FirstClickTime is not { } firstTime || updateTime - firstTime <= _doubleClickTime)
+ {
+ continue;
+ }
+
+ // No second click in maximum double click time.
+ HandleDoubleClickExceedsParameters(ref click);
+ _clicks.RemoveAt(i--);
+
+ // SAFETY: We have to replace the span now as the RemoveAt could've in theory reallocated.
+ clicks = CollectionsMarshal.AsSpan(_clicks);
+ }
+ }
+
+ private void InvokeClick(PointerClickEvent evt)
+ {
+ try
+ {
+ Click?.Invoke(evt);
+ }
+ catch (Exception e)
+ {
+ InputLog.Error($"Exception during click event: {e.Message}\n{e.StackTrace}");
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ void IInputHandler.Handle(PointerTargetChangedEvent @event) => HandleTargetChanged(@event);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ void IInputHandler.Handle(PointChangedEvent @event) => HandlePointChanged(@event);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ void IInputHandler.Handle(PointerGripChangedEvent @event) => HandleGripChanged(@event);
+
+ ///
+ protected internal override void HandleDeviceConnectionChanged(ConnectionEvent @event)
+ {
+ base.HandleDeviceConnectionChanged(@event);
+ if (_clicks is null || @event.IsConnected || @event.Device is not IPointerDevice)
+ {
+ return;
+ }
+
+ for (var i = 0; i < _clicks.Count; i++)
+ {
+ if (_clicks[i].Device != @event.Device)
+ {
+ continue;
+ }
+
+ _clicks.RemoveAt(i--);
+ }
+ }
+}
diff --git a/sources/Input/Input/IButtonDevice.cs b/sources/Input/Input/IButtonDevice.cs
new file mode 100644
index 0000000000..70b88af9d3
--- /dev/null
+++ b/sources/Input/Input/IButtonDevice.cs
@@ -0,0 +1,17 @@
+namespace Silk.NET.Input;
+
+///
+/// Represents an input device that has buttons.
+///
+/// The type of buttons the input device has.
+public interface IButtonDevice : IInputDevice
+ where T : unmanaged, Enum
+{
+ ///
+ /// Gets the current button state for this device.
+ ///
+ ///
+ /// Only updated when is called.
+ ///
+ ButtonReadOnlyList State { get; }
+}
diff --git a/sources/Input/Input/IButtonInputHandler.cs b/sources/Input/Input/IButtonInputHandler.cs
new file mode 100644
index 0000000000..a657ad9435
--- /dev/null
+++ b/sources/Input/Input/IButtonInputHandler.cs
@@ -0,0 +1,22 @@
+namespace Silk.NET.Input;
+
+///
+/// An that also receives events.
+///
+/// The device's button type.
+public interface IButtonInputHandler : IInputHandler>
+ where T : unmanaged, Enum
+{
+ ///
+ /// Called when a button's state changes (e.g. button down, button up).
+ ///
+ /// The event details.
+ void HandleButtonChanged(ButtonChangedEvent @event);
+
+ void IInputHandler>.Handle(ButtonChangedEvent @event) => HandleButtonChanged(@event);
+}
+
+public interface IInputHandler : IInputHandler where T : struct
+{
+ void Handle(T @event);
+}
diff --git a/sources/Input/Input/ICursorConfiguration.cs b/sources/Input/Input/ICursorConfiguration.cs
new file mode 100644
index 0000000000..0d0209d4e5
--- /dev/null
+++ b/sources/Input/Input/ICursorConfiguration.cs
@@ -0,0 +1,45 @@
+namespace Silk.NET.Input;
+
+///
+/// Configuration for the behaviour of a mouse cursor.
+///
+public interface ICursorConfiguration
+{
+ ///
+ /// Gets a bitmask denoting the supported values for .
+ ///
+ CursorModes SupportedModes { get; }
+
+ ///
+ /// Gets or sets the current cursor mode. Only one bit shall be set at a time.
+ ///
+ ///
+ /// Note that this property affects the in use, see the
+ /// documentation for more info.
+ ///
+ CursorModes Mode { get; set; }
+
+ ///
+ /// Gets a bitmask denoting the supported values for .
+ ///
+ CursorStyles SupportedStyles { get; }
+
+ ///
+ /// Gets or sets the current cursor style. Only one bit shall be set at a time.
+ /// shall use the provided.
+ ///
+ ///
+ /// When is used, the cursor ceases to exist anyway. As such, while the
+ /// property may not reflect this (as it is retained across changes to
+ /// and just ignored when is used),
+ /// can be implied as being when
+ /// is used.
+ ///
+ CursorStyles Style { get; set; }
+
+ ///
+ /// Gets or sets the current custom cursor image. This has no effect if is not
+ /// used, but the value is stored nonetheless for use when that is the case.
+ ///
+ CustomCursor Image { get; set; }
+}
\ No newline at end of file
diff --git a/sources/Input/Input/IGamepad.cs b/sources/Input/Input/IGamepad.cs
new file mode 100644
index 0000000000..1dc37823b6
--- /dev/null
+++ b/sources/Input/Input/IGamepad.cs
@@ -0,0 +1,20 @@
+namespace Silk.NET.Input;
+
+///
+/// Represents a gamepad that follows a typical layout.
+///
+public interface IGamepad : IButtonDevice
+{
+ ///
+ /// Gets the device state.
+ ///
+ ///
+ /// Only updated when is called.
+ ///
+ new GamepadState State { get; }
+ ButtonReadOnlyList IButtonDevice.State => State.Buttons;
+ ///
+ /// Gets a collection enumerating the vibration motors available to the application to enable haptics.
+ ///
+ IReadOnlyList VibrationMotors { get; }
+}
\ No newline at end of file
diff --git a/sources/Input/Input/IGamepadInputHandler.cs b/sources/Input/Input/IGamepadInputHandler.cs
new file mode 100644
index 0000000000..123be89c8e
--- /dev/null
+++ b/sources/Input/Input/IGamepadInputHandler.cs
@@ -0,0 +1,22 @@
+namespace Silk.NET.Input;
+
+///
+/// An that also receives input.
+///
+public interface IGamepadInputHandler : IButtonInputHandler, IInputHandler, IInputHandler
+{
+ ///
+ /// Called when one of the twin sticks moves.
+ ///
+ /// The event details.
+ void HandleThumbstickMove(GamepadThumbstickMoveEvent @event);
+
+ ///
+ /// Called when one of the two triggers moves.
+ ///
+ /// The event details.
+ void HandleTriggerMove(GamepadTriggerMoveEvent @event);
+
+ void IInputHandler.Handle(GamepadThumbstickMoveEvent @event) => HandleThumbstickMove(@event);
+ void IInputHandler.Handle(GamepadTriggerMoveEvent @event) => HandleTriggerMove(@event);
+}
diff --git a/sources/Input/Input/IInputBackend.cs b/sources/Input/Input/IInputBackend.cs
new file mode 100644
index 0000000000..e67ce94d0f
--- /dev/null
+++ b/sources/Input/Input/IInputBackend.cs
@@ -0,0 +1,49 @@
+namespace Silk.NET.Input;
+
+///
+/// Represents an input backend capable of receiving human input from Human Input Devices (HIDs).
+///
+///
+/// The onus is on the user to coordinate using this type across threads, as the input backend is not thread safe
+/// In addition, certain backends may have (unavoidable) restrictions on what thread can be called
+/// on - the user is responsible for respecting these threading rules as well.
+///
+public interface IInputBackend : IDisposable
+{
+ ///
+ /// Gets a rough human-readable description of the input backend. Its value is not intrinsically meaningful.
+ ///
+ string Name { get; }
+
+ ///
+ /// Gets a globally-unique integral identifier for this device.
+ ///
+ nint Id { get; }
+
+ ///
+ /// Get a list containing all the connected devices available from this input backend.
+ ///
+ ///
+ /// When a device is disconnected, its shall no longer function and will not be
+ /// enumerated by this list. When a device is connected, an with that physical device ID
+ /// shall be added to this list. In addition, upon connection any past objects previously
+ /// enumerated by this list on this instance shall also regain function if the device
+ /// being added to this list shares the same physical device ID as those previous instances. All such previous
+ /// instances shall be equatable to one another and to the instance added to this list.
+ /// An implementation is welcome to reuse old objects, but this is strictly implementation-defined. A device not
+ /// being present in the (checked using s
+ /// implementation) list is sufficient evidence that a device has been
+ /// disconnected.
+ ///
+ IReadOnlyList Devices { get; }
+
+ ///
+ /// Polls and updates the state of the objects connected using this backend, sending
+ /// input events to the given to reflect the human input received.
+ ///
+ ///
+ /// The value of the State properties on each device must not change until this method is called.
+ ///
+ /// The input handler.
+ void Update(IInputHandler? handler = null);
+}
diff --git a/sources/Input/Input/IInputDevice.cs b/sources/Input/Input/IInputDevice.cs
new file mode 100644
index 0000000000..a53a47d750
--- /dev/null
+++ b/sources/Input/Input/IInputDevice.cs
@@ -0,0 +1,36 @@
+namespace Silk.NET.Input;
+
+///
+/// Represents a connected Human Input Device (HID).
+///
+///
+/// All devices originate from a backend.
+///
+/// An object shall be equatable to any such object retrieved from the same backend where
+/// is equal.
+///
+/// objects must not store any managed state, and if there is a requirement for this in a
+/// future extension of this API then this must be defined in such a way that the state storage and lifetime is
+/// user-controlled. While objects are equatable based on s, if a physical
+/// device disconnects and reconnects the does not provide a guarantee that the same object
+/// will be returned (primarily because doing so would require the to keep track of every
+/// object it's ever created), rather a "compatible" one that acts identically to the original object. This is
+/// completely benign if the object is nothing but a wrapper to the backend anyway. If there is unmanaged state (e.g. a
+/// handle to a device that must be explicitly closed upon disconnection), then it is expected that even in the event of
+/// reconnection, old objects (e.g. created with a now-disposed handle) shall still work for the newly-reconnected
+/// device. A common way this could be implemented is storing the handles in the
+/// implementation instead in the form of a mapping of physical device IDs ( ) to those handles. This
+/// solves the object lifetime problem while also not adding undue complications to user code.
+///
+public interface IInputDevice : IEquatable
+{
+ ///
+ /// Gets a globally-unique integral identifier for this device.
+ ///
+ nint Id { get; }
+
+ ///
+ /// Gets a rough human-readable description of the input device. Its value is not intrinsically meaningful.
+ ///
+ string Name { get; }
+}
\ No newline at end of file
diff --git a/sources/Input/Input/IInputHandler.cs b/sources/Input/Input/IInputHandler.cs
new file mode 100644
index 0000000000..3a7c7bbccc
--- /dev/null
+++ b/sources/Input/Input/IInputHandler.cs
@@ -0,0 +1,15 @@
+namespace Silk.NET.Input;
+
+///
+/// Represents a handler of human input. Implementations of this type will receive a method call for each distinctive
+/// HID event received in the order they were received, to the best of the backend's ability. All visible changes to
+/// device state correspond to a method call using this interface.
+///
+public interface IInputHandler
+{
+ ///
+ /// Called when an disconnects from the application.
+ ///
+ /// The event details.
+ void HandleDeviceConnectionChanged(ConnectionEvent @event);
+}
\ No newline at end of file
diff --git a/sources/Input/Input/IJoystick.cs b/sources/Input/Input/IJoystick.cs
new file mode 100644
index 0000000000..df5bb9b3b3
--- /dev/null
+++ b/sources/Input/Input/IJoystick.cs
@@ -0,0 +1,16 @@
+namespace Silk.NET.Input;
+
+///
+/// Represents a joystick with axes, buttons, and hats.
+///
+public interface IJoystick : IButtonDevice
+{
+ ///
+ /// Gets the device state.
+ ///
+ ///
+ /// Only updated when is called.
+ ///
+ new JoystickState State { get; }
+ ButtonReadOnlyList IButtonDevice.State => State.Buttons;
+}
\ No newline at end of file
diff --git a/sources/Input/Input/IJoystickInputHandler.cs b/sources/Input/Input/IJoystickInputHandler.cs
new file mode 100644
index 0000000000..2fc3de848a
--- /dev/null
+++ b/sources/Input/Input/IJoystickInputHandler.cs
@@ -0,0 +1,22 @@
+namespace Silk.NET.Input;
+
+///
+/// An that also receives input.
+///
+public interface IJoystickInputHandler : IButtonInputHandler, IInputHandler, IInputHandler
+{
+ ///
+ /// Called when an axis on the joystick moves.
+ ///
+ /// The event details.
+ void HandleAxisMove(JoystickAxisMoveEvent @event);
+
+ ///
+ /// Called when a hat on the joystick moves.
+ ///
+ /// The event details.
+ void HandleHatMove(JoystickHatMoveEvent @event);
+
+ void IInputHandler.Handle(JoystickAxisMoveEvent @event) => HandleAxisMove(@event);
+ void IInputHandler.Handle(JoystickHatMoveEvent @event) => HandleHatMove(@event);
+}
diff --git a/sources/Input/Input/IKeyboard.cs b/sources/Input/Input/IKeyboard.cs
new file mode 100644
index 0000000000..5dcbf4896c
--- /dev/null
+++ b/sources/Input/Input/IKeyboard.cs
@@ -0,0 +1,51 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace Silk.NET.Input;
+
+///
+/// Represents a keyboard device.
+///
+public interface IKeyboard : IButtonDevice
+{
+ ///
+ /// Gets the device state.
+ ///
+ ///
+ /// Only updated when is called.
+ ///
+ new KeyboardState State { get; }
+
+ ButtonReadOnlyList IButtonDevice.State => State.Keys;
+
+ ///
+ /// Gets or sets the current text on the clipboard.
+ ///
+ string? ClipboardText { get; set; }
+
+ ///
+ /// Attempts to get a user-displayable string in the user's locale for the key at the physical position represented
+ /// by in the user's current keyboard layout.
+ ///
+ /// The physical key name. Consult documentation for more info.
+ /// The user-displayable name of the key.
+ /// Whether the name was successfully retrieved.
+ bool TryGetKeyName(KeyName key, [NotNullWhen(true)] out string? name);
+
+ ///
+ /// Begins recording keyboard input. Without / , there is no
+ /// guarantee that will be raised as this might require displaying
+ /// a concept/touchscreen keyboard on certain platforms (e.g. phones). It is recommended that you call
+ /// when you'd like to capture text input (e.g. in a text box), followed by
+ /// when you have completed collecting such input.
+ ///
+ void BeginInput();
+
+ ///
+ /// Concludes recording keyboard input. Without / , there is no
+ /// guarantee that will be raised as this might require displaying
+ /// a concept/touchscreen keyboard on certain platforms (e.g. phones). It is recommended that you call
+ /// when you'd like to capture text input (e.g. in a text box), followed by
+ /// when you have completed collecting such input.
+ ///
+ string? EndInput();
+}
diff --git a/sources/Input/Input/IKeyboardInputHandler.cs b/sources/Input/Input/IKeyboardInputHandler.cs
new file mode 100644
index 0000000000..5e89264b89
--- /dev/null
+++ b/sources/Input/Input/IKeyboardInputHandler.cs
@@ -0,0 +1,25 @@
+namespace Silk.NET.Input;
+
+///
+/// An that also receives events.
+///
+public interface IKeyboardInputHandler : IButtonInputHandler, IInputHandler
+{
+ ///
+ /// Called when a key is pressed or depressed.
+ ///
+ /// The event details.
+ void HandleKeyChanged(KeyChangedEvent @event);
+
+ ///
+ /// Called when a character is typed.
+ ///
+ ///
+ /// Ensure you have called to start receiving text, after which events will be
+ /// sent for each character until is called.
+ ///
+ /// The event details.
+ void HandleKeyChar(KeyCharEvent @event);
+
+ void IInputHandler.Handle(KeyChangedEvent @event) => HandleKeyChanged(@event);
+}
diff --git a/sources/Input/Input/IMotor.cs b/sources/Input/Input/IMotor.cs
new file mode 100644
index 0000000000..f8875e7149
--- /dev/null
+++ b/sources/Input/Input/IMotor.cs
@@ -0,0 +1,13 @@
+namespace Silk.NET.Input;
+
+///
+/// Represents a vibration motor.
+///
+public interface IMotor
+{
+ ///
+ /// Gets or sets the speed at which the motor is operating, where 0.0 represents no vibration and 1.0
+ /// represents the maximum amount of vibration.
+ ///
+ float Speed { get; set; }
+}
\ No newline at end of file
diff --git a/sources/Input/Input/IMouse.cs b/sources/Input/Input/IMouse.cs
new file mode 100644
index 0000000000..e71c8d7162
--- /dev/null
+++ b/sources/Input/Input/IMouse.cs
@@ -0,0 +1,34 @@
+using System.Numerics;
+
+namespace Silk.NET.Input;
+
+///
+/// Represents a mouse - a type of pointer device.
+///
+public interface IMouse : IPointerDevice
+{
+ ///
+ /// Gets the device state.
+ ///
+ ///
+ /// Only updated when is called.
+ ///
+ new MouseState State { get; }
+
+ PointerState IPointerDevice.State => State;
+
+ ///
+ /// Gets the cursor configuration of the mouse.
+ ///
+ ///
+ /// This will determine which points shall lie on.
+ ///
+ ICursorConfiguration Cursor { get; }
+
+ ///
+ /// Attempts to set the position of the mouse.
+ ///
+ /// The position of the mouse in window coordinates.
+ /// Whether the requested position has been applied.
+ bool TrySetPosition(Vector2 position);
+}
\ No newline at end of file
diff --git a/sources/Input/Input/IMouseInputHandler.cs b/sources/Input/Input/IMouseInputHandler.cs
new file mode 100644
index 0000000000..5e51c613c6
--- /dev/null
+++ b/sources/Input/Input/IMouseInputHandler.cs
@@ -0,0 +1,15 @@
+namespace Silk.NET.Input;
+
+///
+/// An that receives input from an .
+///
+public interface IMouseInputHandler : IButtonInputHandler, IInputHandler
+{
+ ///
+ /// Called when the user scrolls using the scroll wheel.
+ ///
+ /// The event details.
+ void HandleScroll(MouseScrollEvent @event);
+
+ void IInputHandler.Handle(MouseScrollEvent @event) => HandleScroll(@event);
+}
diff --git a/sources/Input/Input/IPointerDevice.cs b/sources/Input/Input/IPointerDevice.cs
new file mode 100644
index 0000000000..de4e803f28
--- /dev/null
+++ b/sources/Input/Input/IPointerDevice.cs
@@ -0,0 +1,20 @@
+namespace Silk.NET.Input;
+
+///
+/// Represents a device with which the user can point at a target.
+///
+public interface IPointerDevice : IButtonDevice
+{
+ ///
+ /// Gets the device state.
+ ///
+ ///
+ /// Only updated when is called.
+ ///
+ new PointerState State { get; }
+ ButtonReadOnlyList IButtonDevice.State => State.Buttons;
+ ///
+ /// Gets the targets at which the user can point with their pointer.
+ ///
+ IReadOnlyList Targets { get; }
+}
\ No newline at end of file
diff --git a/sources/Input/Input/IPointerInputHandler.cs b/sources/Input/Input/IPointerInputHandler.cs
new file mode 100644
index 0000000000..d1315a8477
--- /dev/null
+++ b/sources/Input/Input/IPointerInputHandler.cs
@@ -0,0 +1,26 @@
+namespace Silk.NET.Input;
+
+///
+/// An that also receives events.
+///
+public interface IPointerInputHandler : IButtonInputHandler, IInputHandler, IInputHandler, IInputHandler
+{
+ ///
+ /// Called when the properties of a target at which the user can point using the pointer change. This includes the
+ /// addition and removal of targets.
+ ///
+ /// The event details.
+ void HandleTargetChanged(PointerTargetChangedEvent @event);
+
+ ///
+ /// Called when the user adds, removes, or changes a point at which they're pointing at a target.
+ ///
+ /// The event details.
+ void HandlePointChanged(PointChangedEvent @event);
+
+ ///
+ /// Called when the user changes the pressure with which they're gripping the pointer device.
+ ///
+ /// The event details.
+ void HandleGripChanged(PointerGripChangedEvent @event);
+}
diff --git a/sources/Input/Input/IPointerTarget.cs b/sources/Input/Input/IPointerTarget.cs
new file mode 100644
index 0000000000..5bf3595bda
--- /dev/null
+++ b/sources/Input/Input/IPointerTarget.cs
@@ -0,0 +1,41 @@
+using Silk.NET.Maths;
+
+namespace Silk.NET.Input;
+
+///
+/// Represents a target at which the user can point using their pointer device.
+///
+public interface IPointerTarget
+{
+ ///
+ /// The boundary in which positions of points on this target shall fall. For ,
+ /// shall represent the lack of a lower bound on a particular axis. For
+ /// For , shall represent the lack of a lower bound
+ /// on a particular axis. 0 represents an unused axis that axis is 0 on both
+ /// and .
+ ///
+ Box3D Bounds { get; }
+
+ ///
+ /// Gets the number of points with which the given pointer is pointing at this target.
+ ///
+ /// The number of points.
+ ///
+ /// A single "logical" pointer device may have many points, and can optionally represent multiple physical pointers
+ /// as a single logical device - this is the case where a backend supports multiple mice to control an
+ /// cursor on its "raw mouse input" target, but combines these all to a single point on its "windowed" target. This
+ /// is also true for touch input - a touch screen is represented as a single touch device,
+ /// where each finger is its own point.
+ ///
+ int GetPointCount(IPointerDevice pointer);
+
+ ///
+ /// Gets a point with which the given pointer is pointing at this target.
+ ///
+ /// The pointer device.
+ ///
+ /// The index of the point, between 0 and the number sourced from .
+ ///
+ /// The point at the given index with which the given pointer device is pointing at the target.
+ TargetPoint GetPoint(IPointerDevice pointer, int point);
+}
\ No newline at end of file
diff --git a/sources/Input/Input/Implementations/EnumInfo.cs b/sources/Input/Input/Implementations/EnumInfo.cs
new file mode 100644
index 0000000000..95c3914a12
--- /dev/null
+++ b/sources/Input/Input/Implementations/EnumInfo.cs
@@ -0,0 +1,274 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Reflection;
+using System.Runtime.CompilerServices;
+
+namespace Silk.NET.Input;
+
+// ReSharper disable StaticMemberInGenericType
+// ^ that's the point
+///
+/// A helper class for quickly converting enum values into indexes, particularly
+/// when there is a possibility of unknown/unnamed enum values. See for an example
+/// of an appropriate implementation along with
+///
+///
+internal static class EnumInfo where T : unmanaged, Enum
+{
+ ///
+ /// All enum values sorted in increasing order (unstable sort)
+ ///
+ public static IReadOnlyList All => _all;
+
+ ///
+ /// All enum values with distinct numerical values sorted in increasing order.
+ /// In the case of multiple enum entries with the same numerical value, this makes no guarantees about
+ /// which version ends up here.
+ ///
+ public static readonly IReadOnlyList UniqueValues;
+
+ ///
+ /// The value with the highest numerical value
+ ///
+ public static readonly T MaxValue;
+
+ ///
+ /// The value with the lowest numerical value
+ ///
+ public static readonly T MinValue;
+
+ ///
+ /// The numerical type of the enum
+ ///
+ public static readonly Type UnderlyingType = typeof(T).GetEnumUnderlyingType();
+
+ private static readonly T[] _all;
+ private static readonly string[] _names;
+ private static readonly Dictionary _numericallyDistinctValues;
+ private static readonly ulong[] _allEnumValuesRaw;
+ private static readonly bool _unnamedAreIndexable;
+
+ static unsafe EnumInfo()
+ {
+ var customAttributeDatas = typeof(T).CustomAttributes;
+ var hasFlagsAttribute = false;
+ foreach (var attr in customAttributeDatas)
+ {
+ if (attr.AttributeType == typeof(FlagsAttribute))
+ {
+ hasFlagsAttribute = true;
+ }
+
+ if (attr.AttributeType == typeof(OrderedIndexUsageAttribute))
+ {
+ _unnamedAreIndexable = true;
+ }
+ }
+
+ if (hasFlagsAttribute)
+ {
+ throw new InvalidOperationException("Enums with the FlagsAttribute cannot be used with EnumInfo");
+ }
+
+ var underlyingType = UnderlyingType;
+ T[] vals;
+ T[] all;
+ if (underlyingType == typeof(int))
+ {
+ all = OrderedValues(false);
+ vals = OrderedValues(true);
+ _allEnumValuesRaw = vals.Select(x => (ulong)*(uint*)&x).ToArray();
+ }
+ else if (underlyingType == typeof(uint))
+ {
+ all = OrderedValues(false);
+ vals = OrderedValues(true);
+ _allEnumValuesRaw = vals.Select(x => (ulong)*(uint*)&x).ToArray();
+ }
+ else if (underlyingType == typeof(byte))
+ {
+ all = OrderedValues(false);
+ vals = OrderedValues(true);
+ _allEnumValuesRaw = vals.Select(x => (ulong)*(byte*)&x).ToArray();
+ }
+ else if (underlyingType == typeof(sbyte))
+ {
+ all = OrderedValues(false);
+ vals = OrderedValues(true);
+ _allEnumValuesRaw = vals.Select(x => (ulong)*(byte*)&x).ToArray();
+ }
+ else if (underlyingType == typeof(short))
+ {
+ all = OrderedValues(false);
+ vals = OrderedValues(true);
+ _allEnumValuesRaw = vals.Select(x => (ulong)*(ushort*)&x).ToArray();
+ }
+ else if (underlyingType == typeof(ushort))
+ {
+ all = OrderedValues(false);
+ vals = OrderedValues(true);
+ _allEnumValuesRaw = vals.Select(x => (ulong)*(ushort*)&x).ToArray();
+ }
+ else if (underlyingType == typeof(long))
+ {
+ all = OrderedValues(false);
+ vals = OrderedValues(true);
+ _allEnumValuesRaw = vals.Select(x => *(ulong*)&x).ToArray();
+ }
+ else if (underlyingType == typeof(ulong))
+ {
+ all = OrderedValues(false);
+ vals = OrderedValues(true);
+ _allEnumValuesRaw = vals.Select(x => *(ulong*)&x).ToArray();
+ }
+ else
+ {
+ throw new InvalidOperationException("Enum provided uses an unknown numeric base??");
+ }
+
+
+ var names = new string[all.Length];
+ for (var index = 0; index < all.Length; index++)
+ {
+ names[index] = all[index].ToString(); // todo: readable name attributes?
+ }
+
+ var dict = new Dictionary(vals.Length);
+ for (var i = 0; i < vals.Length; i++)
+ {
+ var enumVal = vals[i];
+
+ // get attribute and check for ignore
+
+ dict.Add(enumVal, i);
+ }
+
+ _names = names;
+ _all = all;
+ UniqueValues = vals;
+ _numericallyDistinctValues = dict;
+ MinValue = All[0];
+ MaxValue = All[^1];
+ }
+
+ ///
+ /// Get the ordered index of the value provided.
+ /// Values with the same numerical value will *not* return the same index, and are not guaranteed to be
+ /// stably sorted across application runs.
+ /// The index provided
+ ///
+ ///
+ /// The index of the sorted enum value
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static int NameIndexOf(T value) => Array.IndexOf(_all, value);
+
+ ///
+
+ ///
+ /// Returns the names of an enum value, pre-allocated
+ ///
+ public static string NameOf(T value) => _names[NameIndexOf(value)];
+
+ ///
+ /// Get the ordered index of the value provided.
+ /// Values with the same numerical value will return the same index
+ ///
+ ///
+ /// The index of the sorted enum numerical value, or -1 if not a named enum member.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static int ValueIndexOf(T value) => !_unnamedAreIndexable
+ ? ValueOf(value)
+ : _numericallyDistinctValues.GetValueOrDefault(value, -1);
+
+ ///
+ /// Gets the ordered index of the unnamed enum value provided. This index is calculated by:
+ /// (the number of named members in this enum type) + (the raw value of the number if unnamed)
+ ///
+ /// Negative values or values that are above the lowest enum value will return -1, as they cannot be used for indexing
+ ///
+ ///
+ ///
+ public static int ValueIndexOfUnnamed(T value)
+ {
+ if (!_unnamedAreIndexable)
+ {
+ return ValueOf(value);
+ }
+
+ if(_numericallyDistinctValues.TryGetValue(value, out var index))
+ {
+ return index;
+ }
+
+ var rawValue = ValueOf(value);
+
+ // todo - don't rely on joystickButton's unknown - find the MinValue
+ if (rawValue <= 0 || rawValue >= ValueOf(_allEnumValuesRaw[0]))
+ {
+ return -1;
+ }
+
+ return _all.Length + rawValue;
+ }
+
+ ///
+ /// Returns the numerical value of the enum value provided in a type-safe way
+ ///
+ ///
+ ///
+ ///
+ ///
+ private static unsafe TNumber ValueOf(TValue value) where TNumber : unmanaged where TValue : unmanaged
+ {
+ if (sizeof(T) == sizeof(TNumber))
+ {
+ return Unsafe.Read(&value);
+ }
+
+ var minSize = Math.Min(sizeof(TNumber), sizeof(T));
+
+ var originalValuePtr = (byte*)&value;
+
+ var valuePtr = &originalValuePtr[Math.Abs(minSize - sizeof(T))]; // does this assume little-endianness?
+ var numberPtr = stackalloc byte[sizeof(TNumber)];
+
+ // ensure block is initialized (as it isnt guaranteed?) so any missing bytes of the output will stay 0
+ // if type TNumber is a larger size than type T
+ Unsafe.InitBlock(numberPtr, 0, (uint)sizeof(TNumber));
+
+ var copyToPtr = &numberPtr[Math.Abs(minSize - sizeof(TNumber))];
+ Buffer.MemoryCopy(valuePtr, copyToPtr, sizeof(TNumber), minSize);
+ return *(TNumber*)numberPtr;
+ }
+
+ private static T[] OrderedValues(bool byNumericValue)
+ where TNumber : unmanaged, IComparable
+ {
+ // numerically distinct numbers
+ var allValues = Enum.GetValues().Where(x => !IsIgnored(x)).ToArray();
+
+ if (byNumericValue)
+ {
+ allValues = allValues.DistinctBy(ValueOf).ToArray();
+ }
+
+ // sort by increasing order
+ allValues.AsSpan().StableSort((a, b) => {
+ var aNumber = ValueOf(a);
+ var bNumber = ValueOf(b);
+ return aNumber.CompareTo(bNumber);
+ });
+
+ return allValues;
+ }
+
+ private static bool IsIgnored(T value)
+ {
+ var attr = value.GetType().GetField(value.ToString())?.GetCustomAttribute();
+ return attr is not null;
+ }
+
+ public static unsafe bool HasValue(int value) => _allEnumValuesRaw.Contains(*(uint*)&value);
+}
diff --git a/sources/Input/Input/Implementations/KeyHandling/ICharacterConverter.cs b/sources/Input/Input/Implementations/KeyHandling/ICharacterConverter.cs
new file mode 100644
index 0000000000..2ee4c27b12
--- /dev/null
+++ b/sources/Input/Input/Implementations/KeyHandling/ICharacterConverter.cs
@@ -0,0 +1,188 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+
+namespace Silk.NET.Input.KeyHandling;
+
+///
+/// A simple interface for an implementation that converts keyboard input into characters for text entry
+///
+internal interface ICharacterConverter
+{
+ public bool TryConvert(KeyName key, KeyModifiers modifiers, [NotNullWhen(true)] out char? c);
+}
+
+internal class DummyCharConverter : ICharacterConverter
+{
+ // todo - proper VK key support for various languages and layouts
+ public bool TryConvert(KeyName key, KeyModifiers modifiers, [NotNullWhen(true)] out char? c)
+ {
+ if (!key.IsChar())
+ {
+ c = null;
+ return false;
+ }
+
+ if (key is >= KeyName.A and <= KeyName.Z)
+ {
+ var diff = (int)key - (int)KeyName.A;
+ c = (char)('a' + diff);
+ if (modifiers.ShouldCapitalize())
+ {
+ c = CultureInfo.CurrentCulture.TextInfo.ToUpper(c.Value);
+ }
+
+ return true;
+ }
+
+ var isShifted = modifiers.IsShift();
+ switch (key)
+ {
+ case KeyName.Number1:
+ c = isShifted ? '!' : '1';
+ return true;
+ case KeyName.Keypad1:
+ c = '1';
+ return true;
+ case KeyName.Number2:
+ c = isShifted ? '@' : '2';
+ return true;
+ case KeyName.Keypad2:
+ c = '2';
+ return true;
+ case KeyName.Number3:
+ c = isShifted ? '#' : '3';
+ return true;
+ case KeyName.Keypad3:
+ c = '3';
+ return true;
+ case KeyName.Number4:
+ c = isShifted ? '$' : '4';
+ return true;
+ case KeyName.Keypad4:
+ c = '4';
+ return true;
+ case KeyName.Number5:
+ c = isShifted ? '%' : '5';
+ return true;
+ case KeyName.Keypad5:
+ c = '5';
+ return true;
+ case KeyName.Number6:
+ c = isShifted ? '^' : '6';
+ return true;
+ case KeyName.Keypad6:
+ c = '6';
+ return true;
+ case KeyName.Number7:
+ c = isShifted ? '&' : '7';
+ return true;
+ case KeyName.Keypad7:
+ c = '7';
+ return true;
+ case KeyName.Number8:
+ c = isShifted ? '*' : '8';
+ return true;
+ case KeyName.Keypad8:
+ c = '8';
+ return true;
+ case KeyName.Number9:
+ c = isShifted ? '(' : '9';
+ return true;
+ case KeyName.Keypad9:
+ c = '9';
+ return true;
+ case KeyName.Number0:
+ c = isShifted ? ')' : '0';
+ return true;
+ case KeyName.Keypad0:
+ c = '0';
+ return true;
+ case KeyName.Minus:
+ c = isShifted ? '_' : '-';
+ return true;
+ case KeyName.Equals:
+ c = isShifted ? '+' : '=';
+ return true;
+ case KeyName.Tab:
+ c = '\t';
+ return true;
+ case KeyName.Apostrophe:
+ c = isShifted ? '\"' : '\'';
+ return true;
+ case KeyName.Backslash:
+ c = isShifted ? '|' : '\\';
+ return true;
+ case KeyName.Semicolon:
+ c = isShifted ? ':' : ';';
+ return true;
+ case KeyName.Comma:
+ c = isShifted ? '<' : ',';
+ return true;
+ case KeyName.Period:
+ c = isShifted ? '>' : '.';
+ return true;
+ case KeyName.Slash:
+ c = isShifted ? '?' : '/';
+ return true;
+ case KeyName.Space:
+ c = ' ';
+ return true;
+ case KeyName.KeypadAmpersand:
+ c = '&';
+ return true;
+ case KeyName.KeypadPercent:
+ c = '%';
+ return true;
+ case KeyName.KeypadColon:
+ c = ':';
+ return true;
+ case KeyName.KeypadLeftParenthesis:
+ c = '(';
+ return true;
+ case KeyName.KeypadRightParenthesis:
+ c = ')';
+ return true;
+ case KeyName.KeypadPlus:
+ c = '+';
+ return true;
+ case KeyName.KeypadComma:
+ c = ',';
+ return true;
+ case KeyName.KeypadMinus:
+ c = '-';
+ return true;
+ case KeyName.KeypadPeriod:
+ c = '.';
+ return true;
+ case KeyName.KeypadDivide:
+ c = '/';
+ return true;
+ case KeyName.KeypadEquals:
+ c = '=';
+ return true;
+ case KeyName.KeypadEnter:
+ case KeyName.Return:
+ case KeyName.Return2:
+ c = '\n';
+ return true;
+ case KeyName.KeypadExclamation:
+ c = '!';
+ return true;
+ case KeyName.KeypadMultiply:
+ c = '*';
+ return true;
+ case KeyName.Grave:
+ c = '`';
+ return true;
+ case KeyName.CurrencyUnit:
+ c = RegionInfo.CurrentRegion.CurrencySymbol[0];
+ return true;
+ }
+
+ c = null;
+ return false;
+ }
+}
diff --git a/sources/Input/Input/Implementations/KeyHandling/KeyNameExtensions.cs b/sources/Input/Input/Implementations/KeyHandling/KeyNameExtensions.cs
new file mode 100644
index 0000000000..a8343fc9ff
--- /dev/null
+++ b/sources/Input/Input/Implementations/KeyHandling/KeyNameExtensions.cs
@@ -0,0 +1,111 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+
+namespace Silk.NET.Input.KeyHandling;
+
+///
+/// A series of extension methods for making sense of values
+///
+internal static class KeyNameExtensions
+{
+ ///
+ /// Returns true if the key would produce a character in common text editing scenarios. Includes whitespace.
+ ///
+ public static bool IsChar(this KeyName name) =>
+ name is >= KeyName.A and <= KeyName.Return
+ or >= KeyName.KeypadDivide and <= KeyName.KeypadPeriod
+ or >= KeyName.Tab and <= KeyName.Slash
+ or >= KeyName.KeypadMultiply and <= KeyName.KeypadEnter
+ or >= KeyName.KeypadA and <= KeyName.KeypadExclamation
+ or >= KeyName.Keypad00 and <= KeyName.KeypadTab
+ or KeyName.Return2 or KeyName.Separator or KeyName.KeypadPlusMinus
+ or KeyName.KeypadComma
+ or KeyName.KeypadEquals or KeyName.OtherKeypadEquals;
+
+ ///
+ /// Returns true if the given key would produce a deletion of one or more characters in common text
+ /// editing scenarios.
+ ///
+ public static bool IsDeletion(this KeyName name) =>
+ name is KeyName.Backspace
+ or KeyName.Delete
+ or KeyName.KeypadBackspace
+ or KeyName.Clear
+ or KeyName.KeypadClear
+ or KeyName.KeypadClearEntry
+ or KeyName.ClearAgain;
+
+ public static TextDeletionType GetDeletionType(this KeyName name)
+ {
+ Debug.Assert(name.IsDeletion());
+
+ return name switch {
+ KeyName.Backspace => TextDeletionType.Back,
+ KeyName.Delete => TextDeletionType.Forward,
+ KeyName.KeypadBackspace => TextDeletionType.Back,
+ KeyName.Clear => TextDeletionType.All,
+ KeyName.KeypadClear => TextDeletionType.All,
+ KeyName.KeypadClearEntry => TextDeletionType.All,
+ KeyName.ClearAgain => TextDeletionType.All,
+ _ => TextDeletionType.None
+ };
+ }
+
+ ///
+ /// An enum representing the type of text deletion, if any. For example,
+ /// would be , would be , etc.
+ ///
+ public enum TextDeletionType
+ {
+ ///
+ /// Key represents a deletion of one (or more) character(s) behind the cursor.
+ ///
+ Back,
+
+ ///
+ /// Key represents a deletion of one (or more) character(s) ahead of the cursor.
+ ///
+ Forward,
+
+ ///
+ /// Key represents a deletion of all characters in current text entry context
+ ///
+ All,
+
+ ///
+ /// Key does not represent a deletion.
+ ///
+ None = -1
+ }
+
+ ///
+ /// Returns true if the modifiers signify that the next character should be capitalized.
+ ///
+ public static bool ShouldCapitalize(this KeyModifiers modifiers) =>
+ ((modifiers & KeyModifiers.CapsLock) == KeyModifiers.CapsLock) ^
+ ((modifiers & KeyModifiers.ShiftLeft) == KeyModifiers.ShiftLeft
+ || (modifiers & KeyModifiers.ShiftRight) == KeyModifiers.ShiftRight);
+
+ public static bool IsControl(this KeyName name) =>
+ name is KeyName.ControlLeft or KeyName.ControlRight;
+
+ public static bool IsControl(this KeyModifiers mod) =>
+ (mod & KeyModifiers.ControlLeft) == KeyModifiers.ControlLeft ||
+ (mod & KeyModifiers.ControlRight) == KeyModifiers.ControlRight;
+
+ public static bool IsShift(this KeyName name) =>
+ name is KeyName.ShiftLeft or KeyName.ShiftRight;
+
+ public static bool IsShift(this KeyModifiers mod) =>
+ (mod & KeyModifiers.ShiftLeft) == KeyModifiers.ShiftLeft ||
+ (mod & KeyModifiers.ShiftRight) == KeyModifiers.ShiftRight;
+
+ public static bool IsAlt(this KeyName name) =>
+ name is KeyName.AltLeft or KeyName.AltRight;
+
+ public static bool IsAlt(this KeyModifiers mod) =>
+ (mod & KeyModifiers.AltLeft) == KeyModifiers.AltLeft ||
+ (mod & KeyModifiers.AltRight) == KeyModifiers.AltRight;
+}
diff --git a/sources/Input/Input/Implementations/KeyHandling/TextRecorder.cs b/sources/Input/Input/Implementations/KeyHandling/TextRecorder.cs
new file mode 100644
index 0000000000..e8c06b4006
--- /dev/null
+++ b/sources/Input/Input/Implementations/KeyHandling/TextRecorder.cs
@@ -0,0 +1,407 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+using Silk.NET.Input.SDL3;
+using Silk.NET.SDL;
+
+namespace Silk.NET.Input.KeyHandling;
+
+///
+/// A utility class for recording text input.
+/// Where possible, it may be preferable to use and
+/// instead, but this requires the use of the SDL windowing API, which may not be available in all contexts.
+/// This class is a work in progress, and not yet sufficient for full text-editor support.
+///
+internal sealed class TextRecorder
+{
+ private readonly ICharacterConverter _converter;
+
+ ///
+ /// Constructor
+ ///
+ public TextRecorder(ICharacterConverter? converter)
+ {
+ _converter = converter ?? new DummyCharConverter();
+ }
+
+ ///
+ /// The number of characters currently in the buffer.
+ ///
+ public int Count => _sb.Length;
+
+ ///
+ /// Modify the buffer and recorder state based on the input key name and the state of the keyboard.
+ ///
+ /// The keystroke to add
+ /// The keyboard whose state we are recording
+ public bool AddKeyStroke(KeyName name, IKeyboard keyboard, [NotNullWhen(true)] out char? newChar)
+ {
+ var isChar = name.IsChar();
+ var isDeletion = name.IsDeletion();
+
+ if (!isChar && !isDeletion)
+ {
+ newChar = null;
+ return false;
+ }
+
+ if (name == KeyName.Paste)
+ {
+ var clipboardText = keyboard.ClipboardText;
+ if (!string.IsNullOrEmpty(clipboardText))
+ {
+ InsertText(clipboardText);
+ }
+
+ newChar = null;
+ return false;
+ }
+
+ var state = keyboard.State;
+ var activeModifiers = state.Modifiers;
+ if (name.IsChar())
+ {
+ if (activeModifiers.IsAlt() || activeModifiers.IsControl())
+ {
+ newChar = null;
+ return false;
+ }
+
+ // insert the appropriate character
+ // first, we need the virtual key represented by the scancode (KeyName)
+
+ if (_converter.TryConvert(name, activeModifiers, out newChar))
+ {
+ InsertText(newChar.Value);
+ return true;
+ }
+
+ return false;
+ }
+
+ if (name.IsDeletion())
+ {
+ var deletionType = name.GetDeletionType();
+ Debug.Assert(deletionType != KeyNameExtensions.TextDeletionType.None);
+ switch (deletionType)
+ {
+ case KeyNameExtensions.TextDeletionType.Back:
+ if (_selectionLength > 0)
+ {
+ RemoveSelectedTextAndClearSelection();
+ }
+ else if (_cursorStart > 0)
+ {
+ // remove from behind cursor and move cursor back accordingly
+ if (activeModifiers.IsControl())
+ {
+ // find first whitespace character prior to current cursor position
+ var cursorPos = _cursorStart;
+ while (cursorPos > 0 && !char.IsWhiteSpace(_sb[cursorPos - 1]))
+ {
+ --cursorPos;
+ }
+
+ var count = Math.Min(_cursorStart - cursorPos, 1);
+ _sb.Remove(cursorPos, count);
+ _cursorStart = cursorPos;
+ }
+ else
+ {
+ _sb.Remove(--_cursorStart, 1);
+ }
+ }
+
+ break;
+ case KeyNameExtensions.TextDeletionType.Forward:
+ if (_selectionLength > 0)
+ {
+ RemoveSelectedTextAndClearSelection();
+ }
+ else if (_cursorStart < _sb.Length)
+ {
+ // remove from front of cursor
+ if (activeModifiers.IsControl())
+ {
+ // find first whitespace character after current cursor position
+ var cursorPos = _cursorStart;
+ while (cursorPos < _sb.Length && !char.IsWhiteSpace(_sb[cursorPos]))
+ {
+ ++cursorPos;
+ }
+
+ var count = Math.Min(_sb.Length - cursorPos, 1);
+ _sb.Remove(cursorPos, count);
+ }
+ else
+ {
+ _sb.Remove(_cursorStart, 1);
+ }
+ }
+ break;
+ case KeyNameExtensions.TextDeletionType.All:
+ _sb.Clear();
+ SetCursorPositionRaw(0);
+ SetSelectionLength(0);
+ break;
+ default:
+ InputLog.Error($"Unexpected text deletion type: {deletionType}");
+ break;
+ }
+ }
+
+ newChar = null;
+ return false;
+ }
+
+ ///
+ /// Removes the currently selected text and sets the current selection length to 0.
+ ///
+ private void RemoveSelectedTextAndClearSelection()
+ {
+ // remove the currently selected text
+ var selectedLength = _selectionLength;
+ SetSelectionLength(0);
+ if (selectedLength > 0 && _cursorStart < _sb.Length)
+ {
+ Debug.Assert(_cursorStart + selectedLength <= _sb.Length);
+ _sb.Remove(_cursorStart, selectedLength);
+ }
+ }
+
+ ///
+ /// Inserts the given text into the buffer at the current cursor/selection position.
+ ///
+ ///
+ public void InsertText(ReadOnlySpan str)
+ {
+ RemoveSelectedTextAndClearSelection();
+
+ if (str.Length > 0)
+ {
+ _sb.Insert(_cursorStart, str);
+ SetCursorPositionRaw(_cursorStart + str.Length);
+ }
+ }
+
+ ///
+ /// Inserts the given text into the buffer at the current cursor/selection position.
+ ///
+ ///
+ public void InsertText(char c)
+ {
+ ReadOnlySpan span = [c];
+ InsertText(span);
+ }
+
+ ///
+ /// Inserts the given text into the buffer at the given cursor position. Given cursor position is always
+ /// clamped to the bounds of the buffer. Clears any current selection if the actual cursor position is different from
+ /// the provided cursor position.
+ ///
+ ///
+ /// The cursor position in the buffer to inject
+ public void InsertTextAt(ReadOnlySpan str, int cursorStart)
+ {
+ if (_cursorStart != cursorStart)
+ {
+ SetCursorPositionRaw(cursorStart);
+ SetSelectionLength(0);
+ }
+
+ InsertText(str);
+ }
+
+ ///
+ /// Inserts the given text into the buffer at the given cursor position. Given cursor position is always
+ /// clamped to the bounds of the buffer. Clears any current selection if the actual cursor position is different from
+ /// the provided cursor position.
+ ///
+ ///
+ /// The cursor position in the buffer to inject
+ ///
+ public unsafe void InsertTextAt(sbyte* textPtrUnsafe, int cursorStart, int textLength) =>
+ InsertTextAt(textPtr: new Ptr(textPtrUnsafe), cursorStart, textLength);
+
+ ///
+ /// Inserts the given text into the buffer at the given cursor position. Given cursor position is always
+ /// clamped to the bounds of the buffer. Clears any current selection if the actual cursor position is different from
+ /// the provided cursor position.
+ ///
+ ///
+ /// The cursor position in the buffer to inject
+ public void InsertTextAt(Ptr textPtr, int cursorStart)
+ {
+ // count to end
+ const char terminator = '\0';
+ for (uint i = 0; i < int.MaxValue; ++i)
+ {
+ if (textPtr[i] == terminator)
+ {
+ InsertTextAt(textPtr, cursorStart, (int)i);
+ return;
+ }
+
+ ++i;
+ }
+ }
+
+ ///
+ /// Inserts the given text into the buffer at the given cursor position. Given cursor position is always
+ /// clamped to the bounds of the buffer. Clears any current selection if the actual cursor position is different from
+ /// the provided cursor position.
+ ///
+ ///
+ /// The cursor position in the buffer to inject
+ ///
+ public void InsertTextAt(Ptr textPtr, int cursorStart, int textLength)
+ {
+ Span textSpan = stackalloc char[textLength];
+
+ if (textPtr.TryReadToSpan(ref textSpan))
+ {
+ Debug.Assert(textSpan.Length == textLength);
+ InsertTextAt(textSpan, cursorStart);
+ }
+ else
+ {
+ InputLog.Error("Failed to read text from text editing event.");
+ // insert empty just to synchronize cursor position
+ SetSelection(cursorStart, 0);
+ }
+ }
+
+ ///
+ /// Sets the selection appropriately for the given positions. Positions can be provided in any order,
+ /// and resulting selection will be clamped to a valid range for the current buffer.
+ ///
+ ///
+ ///
+ public void SetSelectionPositions(int positionA, int positionB)
+ {
+ if (positionA > positionB)
+ {
+ SetSelection(positionB, positionA - positionB);
+ }
+ else
+ {
+ SetSelection(positionA, positionB - positionA);
+ }
+ }
+
+ ///
+ /// Set the selection to the given start and length. Position and length are clamped to the bounds of the buffer.
+ ///
+ ///
+ ///
+ public void SetSelection(int startPosition, int length)
+ {
+ SetCursorPositionRaw(startPosition);
+ SetSelectionLength(length);
+ }
+
+ ///
+ /// Moves the cursor start to the given position, and adjusts the selection length such that
+ /// the selection's end does not move
+ ///
+ ///
+ public void MoveCursorStart(int newPosition)
+ {
+ var pCursor = _cursorStart;
+ SetCursorPositionRaw(newPosition);
+ var diff = pCursor - _cursorStart;
+ SetSelectionLength(_selectionLength + diff);
+ }
+
+ ///
+ /// Adjusts the selection length based on a given end position. The end position is clamped to the bounds of the
+ /// buffer. This may modify the selection start position if the provided position is less than the current
+ /// start cursor position.
+ ///
+ ///
+ public void MoveCursorEnd(int endPosition)
+ {
+ if (endPosition < _cursorStart)
+ {
+ SetCursorPositionRaw(endPosition);
+ SetSelectionLength(0);
+ return;
+ }
+
+ var clampedEndPosition = Math.Clamp(endPosition, _cursorStart, _sb.Length);
+ SetSelectionLength(clampedEndPosition - _cursorStart);
+ }
+
+ ///
+ /// Sets the selection length to the given value. The value is clamped to the bounds of the buffer.
+ ///
+ ///
+ private void SetSelectionLength(int newLength) =>
+ _selectionLength = Math.Clamp(newLength, 0, _sb.Length - _cursorStart);
+
+ ///
+ /// Sets the value of to the given value. The value is clamped to the bounds of the buffer.
+ /// Selection length is not affected.
+ ///
+ ///
+ private void SetCursorPositionRaw(int newPosition) => _cursorStart = Math.Clamp(newPosition, 0, _sb.Length);
+
+ ///
+ /// Fills a span with current buffer contents. Will fill the given span up to the length of the contents or the
+ /// length of the span.
+ ///
+ ///
+ ///
+ public int GetCurrentBuffer(Span buffer)
+ {
+ var maxCount = Math.Min(buffer.Length, _sb.Length);
+ _sb.CopyTo(0, buffer, maxCount);
+ return maxCount;
+ }
+
+ ///
+ /// Fills a span with the currently selected text. Will fill the given span up to the length of the selection or the
+ /// length of the span.
+ ///
+ ///
+ ///
+ public int GetSelectedRegion(Span buffer)
+ {
+ var maxCount = Math.Min(buffer.Length, _selectionLength);
+ if (maxCount == 0)
+ {
+ return 0;
+ }
+
+ _sb.CopyTo(_cursorStart, buffer, maxCount);
+ return maxCount;
+ }
+
+ ///
+ /// Retrieves the current buffer contents and clears the buffer, resetting the cursor and selection positions.
+ ///
+ ///
+ public string ConsumeInput()
+ {
+ var result = _sb.ToString();
+ Clear();
+ return result;
+ }
+
+ ///
+ /// Clears the buffer and resets the cursor and selection positions.
+ ///
+ public void Clear()
+ {
+ _sb.Clear();
+ _cursorStart = 0;
+ _selectionLength = 0;
+ }
+
+
+ private int _cursorStart, _selectionLength;
+ private readonly StringBuilder _sb = new();
+}
diff --git a/sources/Input/Input/Implementations/OrderedIndexUsageAttribute.cs b/sources/Input/Input/Implementations/OrderedIndexUsageAttribute.cs
new file mode 100644
index 0000000000..9be4e953ae
--- /dev/null
+++ b/sources/Input/Input/Implementations/OrderedIndexUsageAttribute.cs
@@ -0,0 +1,10 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Silk.NET.Input;
+
+[AttributeUsage(AttributeTargets.Enum)]
+internal class OrderedIndexUsageAttribute : Attribute;
+
+[AttributeUsage(AttributeTargets.Field)]
+internal class OrderedIndexIgnoreAttribute : Attribute;
diff --git a/sources/Input/Input/Implementations/SDL3/Devices/ISdlDevice.cs b/sources/Input/Input/Implementations/SDL3/Devices/ISdlDevice.cs
new file mode 100644
index 0000000000..c751f79e6a
--- /dev/null
+++ b/sources/Input/Input/Implementations/SDL3/Devices/ISdlDevice.cs
@@ -0,0 +1,18 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Silk.NET.Input.SDL3;
+
+///
+/// An interface defining a generic constructor for managed SDL devices.
+///
+///
+internal interface ISdlDevice : IInputDevice where T : SdlDevice
+{
+ public static abstract T? CreateDevice(ulong sdlDeviceId, SdlInputBackend backend, SilkEventContext context);
+}
+
+internal interface INeedFinalizationEachFrame
+{
+ public void FinalizeUpdate();
+}
diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/IOrderedDevice.cs b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/IOrderedDevice.cs
new file mode 100644
index 0000000000..f9687a12db
--- /dev/null
+++ b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/IOrderedDevice.cs
@@ -0,0 +1,13 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Silk.NET.Input.SDL3.Devices.Joysticks;
+
+///
+/// For devices such as gamepads and joysticks, their SDL IDs are likely to change when other devices
+/// are removed.
+///
+internal interface IOrderedDevice
+{
+ public void RefreshSdlId();
+}
diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/ISdlJoystick.cs b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/ISdlJoystick.cs
new file mode 100644
index 0000000000..5b584358fa
--- /dev/null
+++ b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/ISdlJoystick.cs
@@ -0,0 +1,37 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Silk.NET.SDL;
+
+namespace Silk.NET.Input.SDL3.Devices.Joysticks;
+
+///
+/// An interface for implementing different joystick types
+///
+/// Currently, only Gamepad is explicitly supported, however this interface leaves room
+/// for extensions such as those seen in .
+///
+internal interface ISdlJoystick : IOrderedDevice
+{
+ public SdlJoystick Joystick { get; }
+ ///
+ /// Raw joystick axis input events are forwarded here
+ ///
+ /// Input axis (which axis)
+ /// Input axis value
+ public void UpdateFromJoyAxis(int axis, short joystickInput, ulong sdlTimestamp, long timestamp);
+
+ ///
+ /// Raw joystick hat input events are forwarded here
+ ///
+ /// Input hat (which hat)
+ /// Input hat value
+ public void UpdateFromJoyHat(int hatIdx, SdlJoystick.HatState hatState, ulong sdlTimestamp, long timestamp);
+
+ ///
+ /// Raw joystick button input events are forwarded here
+ ///
+ /// Input button (which button)
+ /// Button state
+ public void UpdateFromJoyButton(int buttonIdx, bool down, ulong sdlTimestamp, long timestamp);
+}
diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlGamepad.cs b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlGamepad.cs
new file mode 100644
index 0000000000..a6047784c3
--- /dev/null
+++ b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlGamepad.cs
@@ -0,0 +1,411 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using Silk.NET.SDL;
+
+namespace Silk.NET.Input.SDL3.Devices.Joysticks;
+
+///
+/// provides the IGamepad implementation for a joystick
+///
+internal sealed unsafe class SdlGamepad : SdlDevice, IGamepad, ISdlDevice, ISdlJoystick, IJoystick
+{
+ private GamepadHandle _gamepadHandle;
+
+ public SdlJoystick Joystick { get; }
+
+
+ // todo - do we want this to be an actual unique device? or should it have the same "unique id" as the joystick?
+ private SdlGamepad(SdlJoystick joystick, nint uniqueId) : base(joystick.Backend, uniqueId, joystick.SdlDeviceId)
+ {
+ Joystick = joystick;
+
+ }
+
+ private void Remap(GamepadHandle gamepadHandle)
+ {
+ _bindings.Clear();
+ _outputBindings.Clear();
+ _hatBindings.Clear();
+ var bindingsCount = 0;
+ var mappings = NativeBackend.GetGamepadBindings(gamepadHandle, &bindingsCount);
+
+ if (bindingsCount == 0)
+ {
+ if (mappings != null)
+ {
+ NativeBackend.Free(mappings);
+ }
+
+ SdlLog.Error("No gamepad mappings found.");
+ return;
+ }
+
+ for (var i = 0; i < bindingsCount; i++)
+ {
+ var binding = mappings[i];
+
+ if (binding->OutputType == GamepadBindingType.None)
+ {
+ continue;
+ }
+
+ int? id = null;
+
+ switch (binding->InputType)
+ {
+ case GamepadBindingType.Button:
+ id = binding->Input.Button << _buttonShift;
+ break;
+ case GamepadBindingType.Axis:
+ id = binding->Input.Axis.Axis << _axisShift;
+ break;
+ case GamepadBindingType.Hat:
+ id = binding->Input.Hat.Hat;
+ break;
+ }
+
+ if (id == null)
+ {
+ continue;
+ }
+
+ switch (binding->OutputType)
+ {
+ case GamepadBindingType.Axis:
+ case GamepadBindingType.Button:
+ _outputBindings.Add(*binding);
+ break;
+ }
+
+ if (binding->InputType == GamepadBindingType.Hat)
+ {
+ while (_hatBindings.Count <= id.Value)
+ {
+ _hatBindings.Add(null);
+ }
+
+ _hatBindings[id.Value] ??= [];
+ _hatBindings[id.Value]!.Add(*binding);
+ }
+ else
+ {
+ _bindings.Add(id.Value, *binding);
+ }
+ }
+
+ NativeBackend.Free(mappings);
+ }
+
+ public void Remap() => Remap(_gamepadHandle);
+
+ public override ulong SdlDeviceId => _sdlDeviceId;
+ private uint _sdlDeviceId;
+
+ public void RefreshSdlId() => _sdlDeviceId = NativeBackend.GetGamepadID(_gamepadHandle);
+
+ public override string Name => Joystick.Name;
+
+ protected internal override void Initialize()
+ {
+ var joystickHandle = Joystick.JoystickHandle;
+ var gamepadHandle = *(GamepadHandle*)&joystickHandle; //NativeBackend.OpenGamepad(sdlDeviceId);
+ _gamepadHandle = gamepadHandle;
+ Remap(gamepadHandle);
+
+ _state = new GamepadState(Joystick.RawButtonState, Joystick.RawAxisState);
+ Joystick.AddDeviceMapping(this);
+ }
+
+ protected override void Release()
+ {
+ Joystick.RemoveDeviceMapping(this);
+
+ // todo: does this close the joystick as well?
+ NativeBackend.CloseGamepad(_gamepadHandle);
+ }
+ private GamepadState _state;
+
+ #region IGamepad
+
+ GamepadState IGamepad.State => GamepadState;
+ private GamepadState GamepadState => _state;
+
+ public IReadOnlyList VibrationMotors =>
+ _rumbler ??= SdlRumble.Create(_gamepadHandle.Handle, NativeBackend, 2);
+
+ private SdlRumble? _rumbler;
+
+ #endregion
+
+ public static SdlGamepad? CreateDevice(ulong sdlDeviceId, SdlInputBackend backend, SilkEventContext context)
+ {
+ if (!backend.TryGetOrCreateDevice(sdlDeviceId, out var joystick))
+ {
+ return null;
+ }
+
+ var joystickUniqueId = joystick.Id;
+ var gpn = backend.Sdl.GetRealGamepadTypeForID((uint)sdlDeviceId);
+
+ if (backend.AttemptUniqueId(gpn, ref joystickUniqueId))
+ {
+ return new SdlGamepad(joystick, uniqueId: joystickUniqueId) {
+ _thumbstickEvents = context.GamepadThumbstickMoveSdlEvents
+ };
+ }
+
+ // manipulate the joystick id to make a unique gamepad id
+ var guid = backend.Sdl.GetGamepadGuidForID((uint)sdlDeviceId);
+ if (backend.AttemptUniqueId(guid, ref joystickUniqueId))
+ {
+ return new SdlGamepad(joystick, uniqueId: joystickUniqueId) {
+ _thumbstickEvents = context.GamepadThumbstickMoveSdlEvents
+ };
+ }
+
+ joystickUniqueId = SdlInputBackend.FallbackUniqueId(sdlDeviceId, joystickUniqueId);
+ var sdlGamepad = new SdlGamepad(joystick, uniqueId: joystickUniqueId) {
+ _thumbstickEvents = context.GamepadThumbstickMoveSdlEvents
+ };
+ return sdlGamepad;
+ }
+
+ private void UpdateGamepadAxis(GamepadAxis gAxis, int value, int min, int max, ulong sdlTimestamp, long timestamp)
+ {
+ var mappedValue = (float)(value + min) / (max - min);
+ switch (gAxis)
+ {
+ case GamepadAxis.Invalid:
+ return;
+ case GamepadAxis.Leftx or GamepadAxis.Lefty or GamepadAxis.Rightx or GamepadAxis.Righty:
+ {
+ var axis = ToJoystickAxis(gAxis);
+ var axes = GetJoystickAxis2(axis);
+ var xIndex = axes.X.Index();
+ var yIndex = axes.Y.Index();
+ var previous = Joystick.GetAxisStateByIndex2D(xIndex, yIndex);
+
+ if (Joystick.UpdateRawAxisState(axis, mappedValue, out _, sdlTimestamp, timestamp))
+ {
+ var latest = Joystick.GetAxisStateByIndex2D(xIndex, yIndex);
+ _thumbstickEvents.Enqueue(new GamepadThumbstickMoveEvent(Gamepad: this,
+ Timestamp: timestamp,
+ Value: latest,
+ Delta: latest - previous), sdlTimestamp);
+
+ ToSplitPair(axis, out var minusAxis, out var plusAxis);
+ var split = SdlJoystick.SplitValue(mappedValue);
+ Joystick.UpdateRawAxisState(minusAxis, split.X, out _, sdlTimestamp, timestamp);
+ Joystick.UpdateRawAxisState(plusAxis, split.Y, out _, sdlTimestamp, timestamp);
+ }
+
+ break;
+
+ }
+ case GamepadAxis.LeftTrigger or GamepadAxis.RightTrigger:
+ {
+ if (Joystick.UpdateRawAxisState(ToJoystickAxis(gAxis), mappedValue, out var moveEvt, sdlTimestamp, timestamp))
+ {
+ _triggerEvents.Enqueue(new GamepadTriggerMoveEvent(this, moveEvt.Timestamp, moveEvt.Axis,
+ moveEvt.Value, moveEvt.Delta));
+ }
+
+ break;
+ }
+ default:
+ throw new ArgumentOutOfRangeException(nameof(gAxis), gAxis, null);
+ }
+
+ return;
+
+ static JoystickAxis ToJoystickAxis(GamepadAxis gamepadAxis) => gamepadAxis switch {
+ GamepadAxis.Leftx => JoystickAxis.LeftX,
+ GamepadAxis.Lefty => JoystickAxis.LeftY,
+ GamepadAxis.Rightx => JoystickAxis.RightX,
+ GamepadAxis.Righty => JoystickAxis.RightY,
+ GamepadAxis.LeftTrigger => JoystickAxis.LeftTrigger,
+ GamepadAxis.RightTrigger => JoystickAxis.RightTrigger,
+ _ => throw new ArgumentOutOfRangeException(nameof(gamepadAxis), gamepadAxis, null)
+ };
+
+ static (JoystickAxis X, JoystickAxis Y) GetJoystickAxis2(JoystickAxis axis) =>
+ axis switch {
+ JoystickAxis.LeftX or JoystickAxis.LeftY => (JoystickAxis.LeftX, JoystickAxis.LeftY),
+ JoystickAxis.RightX or JoystickAxis.RightY => (JoystickAxis.RightX, JoystickAxis.RightY),
+ _ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
+ };
+ }
+
+ private static void ToSplitPair(JoystickAxis axis, out JoystickAxis splitMinus, out JoystickAxis splitPlus)
+ {
+ switch (axis)
+ {
+ case JoystickAxis.LeftX:
+ splitMinus = JoystickAxis.MinusLeftX;
+ splitPlus = JoystickAxis.PlusLeftX;
+ break;
+ case JoystickAxis.RightX:
+ splitMinus = JoystickAxis.MinusRightX;
+ splitPlus = JoystickAxis.PlusRightX;
+ break;
+ case JoystickAxis.LeftY:
+ splitMinus = JoystickAxis.MinusLeftY;
+ splitPlus = JoystickAxis.PlusLeftY;
+ break;
+ case JoystickAxis.RightY:
+ splitMinus = JoystickAxis.MinusRightY;
+ splitPlus = JoystickAxis.PlusRightY;
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(axis), axis, null);
+ }
+ }
+
+ #region ISdlJoystick
+
+ public void UpdateFromJoyButton(int buttonIdx, bool down, ulong sdlTimestamp, long timestamp)
+ {
+ if (!_bindings.TryGetValue(buttonIdx << _buttonShift, out var binding))
+ {
+ return;
+ }
+
+ Debug.Assert(binding.InputType == GamepadBindingType.Button && binding.Input.Button == buttonIdx);
+ var bindingType = binding.OutputType;
+ var output = &binding.Output;
+ switch (bindingType)
+ {
+ case GamepadBindingType.Axis:
+ var axis = output->Axis;
+ UpdateGamepadAxis(
+ gAxis: axis.Axis,
+ value: down ? axis.AxisMax : axis.AxisMin,
+ min: axis.AxisMin,
+ max: axis.AxisMax,
+ sdlTimestamp: sdlTimestamp,
+ timestamp: timestamp);
+ break;
+
+ case GamepadBindingType.Button:
+ UpdateButton(output->Button, down, sdlTimestamp, timestamp);
+ break;
+ }
+ }
+
+ public void AddButtonEvent(byte sdlButtonId, byte sdlButtonDown, ulong sdlTimestamp, long timestamp) =>
+ UpdateButton((GamepadButton)sdlButtonId, sdlButtonDown > 0, sdlTimestamp, timestamp);
+
+ public void AddAxisEvent(byte evtAxis, short evtValue, ulong sdlTimestamp, long timestamp) =>
+ UpdateGamepadAxis((GamepadAxis)evtAxis, evtValue, Sdl.JoystickAxisMin, Sdl.JoystickAxisMax, sdlTimestamp, timestamp);
+
+ public void UpdateFromJoyAxis(int axis, short joystickInput, ulong sdlTimestamp, long timestamp)
+ {
+ if (!_bindings.TryGetValue(axis << _axisShift, out var binding))
+ {
+ return;
+ }
+
+ Debug.Assert(binding.InputType == GamepadBindingType.Axis);
+
+ var output = &binding.Output;
+ var input = &binding.Input.Axis;
+
+ switch (binding.OutputType)
+ {
+ case GamepadBindingType.Axis:
+ UpdateGamepadAxis(output->Axis.Axis, joystickInput, input->AxisMin, input->AxisMax, sdlTimestamp, timestamp);
+ break;
+ case GamepadBindingType.Button:
+ UpdateButton(output->Button, joystickInput >= input->AxisMin && joystickInput <= input->AxisMax, sdlTimestamp, timestamp);
+ break;
+ }
+ }
+
+ public void UpdateFromJoyHat(int hatIdx, SdlJoystick.HatState hatState, ulong sdlTimestamp, long timestamp)
+ {
+ if (_hatBindings.Count <= hatIdx)
+ {
+ return;
+ }
+
+ var bindings = _hatBindings[index: hatIdx];
+ if (bindings is not { Count: > 0 })
+ {
+ return;
+ }
+
+ foreach (var binding in bindings)
+ {
+ Debug.Assert(condition: binding.InputType == GamepadBindingType.Hat && binding.Input.Hat.Hat == hatIdx);
+ var input = &binding.Input.Hat;
+ var mask = (SdlJoystick.HatState)input->HatMask;
+ var bindingState = hatState & mask;
+ switch (binding.OutputType)
+ {
+ case GamepadBindingType.Axis:
+ var axis = binding.Output.Axis;
+ UpdateGamepadAxis(
+ gAxis: axis.Axis,
+ value: bindingState == SdlJoystick.HatState.Centered ? axis.AxisMin : axis.AxisMax,
+ min: axis.AxisMin,
+ max: axis.AxisMax,
+ sdlTimestamp: sdlTimestamp,
+ timestamp: timestamp);
+ break;
+ case GamepadBindingType.Button:
+ var button = binding.Output.Button;
+ UpdateButton(button, bindingState != SdlJoystick.HatState.Centered, sdlTimestamp, timestamp);;
+ break;
+ }
+ }
+ }
+
+ #endregion
+
+ private void UpdateButton(GamepadButton button, bool value, ulong sdlTimestamp, long timestamp)
+ {
+ var asJoystickButton = AsJoystickButton(button);
+ Joystick.UpdateRawButtonState(asJoystickButton, value, value ? 1 : 0, sdlTimestamp, timestamp);
+ return;
+
+ static JoystickButton AsJoystickButton(GamepadButton buttonIndex) =>
+ buttonIndex switch {
+ GamepadButton.South => JoystickButton.ButtonDown,
+ GamepadButton.East => JoystickButton.ButtonRight,
+ GamepadButton.West => JoystickButton.ButtonLeft,
+ GamepadButton.North => JoystickButton.ButtonUp,
+ GamepadButton.Back => JoystickButton.Back,
+ GamepadButton.Guide => JoystickButton.Home,
+ GamepadButton.Start => JoystickButton.Start,
+ GamepadButton.LeftStick => JoystickButton.LeftStick,
+ GamepadButton.RightStick => JoystickButton.RightStick,
+ GamepadButton.LeftShoulder => JoystickButton.LeftBumper,
+ GamepadButton.RightShoulder => JoystickButton.RightBumper,
+ GamepadButton.DpadUp => JoystickButton.DPadUp,
+ GamepadButton.DpadDown => JoystickButton.DPadDown,
+ GamepadButton.DpadLeft => JoystickButton.DPadLeft,
+ GamepadButton.DpadRight => JoystickButton.DPadRight,
+ // TODO not exposed today
+ _ => (JoystickButton)buttonIndex
+ };
+ }
+
+ private readonly Dictionary _bindings = new();
+ private readonly List?> _hatBindings = [];
+ private readonly List _outputBindings = [];
+ internal required ISdlEventQueue _thumbstickEvents { get; init; }
+ private readonly Queue _triggerEvents = new();
+
+
+ // SDL indexes the 3 of these separately, but it is more convenient
+ // for us to index buttons/hats/axes as a single list.
+ // Since SDL only uses a single byte for a device index,
+ // we can safely use an integer key with a bit shift like this.
+ private const int _buttonShift = 0;
+ private const int _axisShift = 8;
+
+ JoystickState IJoystick.State => Joystick.State;
+ ButtonReadOnlyList IButtonDevice.State => GamepadState.Buttons;
+}
diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlJoystick.Extended.cs b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlJoystick.Extended.cs
new file mode 100644
index 0000000000..5c6b570740
--- /dev/null
+++ b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlJoystick.Extended.cs
@@ -0,0 +1,90 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Numerics;
+using System.Runtime.CompilerServices;
+
+namespace Silk.NET.Input.SDL3.Devices.Joysticks;
+
+// This partial class contains the logic for handling joystick-based device types such as SdlGamepad.
+internal sealed partial class SdlJoystick
+{
+ public bool TryGetDevice([NotNullWhen(true)] out T? device) where T : class, ISdlJoystick
+ {
+ foreach (var d in _devices)
+ {
+ if (d is T typedDevice)
+ {
+ device = typedDevice;
+ return true;
+ }
+ }
+
+ device = null;
+ return false;
+ }
+
+ internal IReadOnlyList RawHatState => _rawHatState;
+ internal IReadOnlyList> RawButtonState => _rawButtonState;
+ internal IReadOnlyList RawAxisState => _rawAxisState;
+ internal void AddDeviceMapping(ISdlJoystick device) => _devices.Add(device);
+ internal void RemoveDeviceMapping(ISdlJoystick device) => _devices.Remove(device);
+
+ internal void UpdateRawButtonState(JoystickButton button, bool isDown, float pressure, ulong sdlTimestamp, long timestamp)
+ {
+ var idx = button.Index();
+ if (idx < 0)
+ {
+ throw new Exception("Received an invalid SDL button??");
+ }
+
+ ref var buttonState = ref _rawButtonState[idx];
+
+ var previous = buttonState;
+
+ buttonState = new Button(button, isDown, pressure);
+
+ // ReSharper disable once CompareOfFloatsByEqualityOperator
+ if (previous.IsDown != buttonState.IsDown || previous.Pressure != buttonState.Pressure)
+ {
+ ButtonEvents.Enqueue(new ButtonChangedEvent(this, timestamp, buttonState, previous), sdlTimestamp);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal float GetAxisState(JoystickAxis axis) => _rawAxisState[axis.Index()];
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private float GetAxisStateByIndex(int index) => _rawAxisState[index];
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal Vector2 GetAxisStateByIndex2D(int xIndex, int yIndex) => new(GetAxisStateByIndex(xIndex), GetAxisStateByIndex(yIndex));
+
+ internal bool UpdateRawAxisState(JoystickAxis axis, float value, out JoystickAxisMoveEvent evt, ulong sdlTimestamp, long timestamp)
+ {
+ var index = axis.Index();
+ if (index < 0)
+ {
+ throw new Exception("Received an invalid SDL axis??");
+ }
+
+ ref var state = ref _rawAxisState[index];
+ var p = state;
+ state = value;
+
+ var delta = value - p;
+ if (delta != 0)
+ {
+ evt = new JoystickAxisMoveEvent(this, timestamp, index, value, delta);
+ AxisEvents.Enqueue(evt, sdlTimestamp);
+ return true;
+ }
+
+ evt = default;
+ return false;
+ }
+
+ private readonly List _devices = [];
+}
diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlJoystick.cs b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlJoystick.cs
new file mode 100644
index 0000000000..cd260544ad
--- /dev/null
+++ b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlJoystick.cs
@@ -0,0 +1,231 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using Silk.NET.SDL;
+
+namespace Silk.NET.Input.SDL3.Devices.Joysticks;
+
+internal sealed unsafe partial class SdlJoystick : SdlDevice, IJoystick, ISdlDevice, IOrderedDevice
+{
+ public JoystickState State { get; private set; }
+ private JoystickType _joystickType;
+ internal JoystickHandle JoystickHandle { get; private set; }
+
+ public static SdlJoystick CreateDevice(ulong sdlDeviceId, SdlInputBackend backend, SilkEventContext silkEvents)
+ {
+ nint uniqueId = 0;
+
+ var guid = backend.Sdl.GetJoystickGuidForID((uint)sdlDeviceId);
+ if (backend.AttemptUniqueId(new ReadOnlySpan(&guid, 16), ref uniqueId))
+ {
+ return CreatePls(backend, uniqueId, sdlDeviceId, silkEvents);
+ }
+
+ var pathPtr = backend.Sdl.GetJoystickPathForID((uint)sdlDeviceId);
+ if (backend.AttemptUniqueId(pathPtr, ref uniqueId))
+ {
+ return CreatePls(backend, uniqueId, sdlDeviceId, silkEvents);
+ }
+
+ var name = backend.Sdl.GetJoystickNameForID((uint)sdlDeviceId);
+ if (backend.AttemptUniqueId(name, ref uniqueId))
+ {
+ return CreatePls(backend, uniqueId, sdlDeviceId, silkEvents);
+ }
+
+ var type = backend.Sdl.GetJoystickTypeForID((uint)sdlDeviceId);
+ if (backend.AttemptUniqueId(type, ref uniqueId))
+ {
+ return CreatePls(backend, uniqueId, sdlDeviceId, silkEvents);
+ }
+
+ uniqueId = SdlInputBackend.FallbackUniqueId(sdlDeviceId, uniqueId);
+ return CreatePls(backend, uniqueId, sdlDeviceId, silkEvents);
+
+ static SdlJoystick CreatePls(SdlInputBackend sdlInputBackend, nint uniqueId, ulong sdlDeviceId,
+ SilkEventContext context)
+ {
+ return new SdlJoystick(sdlDeviceId, uniqueId, sdlInputBackend) {
+ ButtonEvents = context.ButtonChangedSdlEvents,
+ AxisEvents = context.JoystickAxisMoveSdlEvents,
+ HatEvents = context.JoystickHatMoveSdlEvents
+ };
+ }
+ }
+
+
+ public override string Name => NativeBackend.GetJoystickNameForID((uint)SdlDeviceId).ReadToString();
+
+ public override ulong SdlDeviceId
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ get => SdlDeviceId;
+ }
+
+
+ private SdlJoystick(ulong sdlDeviceId, nint uniqueId, SdlInputBackend backend) : base(backend, uniqueId,
+ sdlDeviceId)
+ {
+ _sdlDeviceId = sdlDeviceId;
+ }
+
+
+ [Flags]
+ internal enum HatState : byte
+ {
+ Up = (byte)Sdl.HatUp,
+ Right = (byte)Sdl.HatRight,
+ Down = (byte)Sdl.HatDown,
+ Left = (byte)Sdl.HatLeft,
+ Centered = (byte)Sdl.HatCentered,
+ LeftUp = (byte)Sdl.HatLeftup,
+ RightUp = (byte)Sdl.HatRightup,
+ LeftDown = (byte)Sdl.HatLeftdown,
+ RightDown = (byte)Sdl.HatRightdown
+ }
+
+ #region Sdl Events
+
+ public void AddHatEvent(int hatIdx, byte hatInput, ulong sdlTimestamp, long timestamp)
+ {
+ var hatState = (HatState)hatInput;
+ var left = (hatState & HatState.Left) == HatState.Left;
+ var right = (hatState & HatState.Right) == HatState.Right;
+
+ var x = (float)(*(byte*)&right - *(byte*)&left);
+ var up = (hatState & HatState.Up) == HatState.Up;
+ var down = (hatState & HatState.Down) == HatState.Down;
+ var y = (float)(*(byte*)&up - *(byte*)&down);
+
+ ref var hatStateRef = ref _rawHatState[hatIdx];
+ var previous = hatStateRef;
+ hatStateRef = new Vector2(x, y);
+
+ foreach (var device in _devices)
+ {
+ device.UpdateFromJoyHat(hatIdx, hatState, sdlTimestamp, timestamp);
+ }
+
+ var delta = hatStateRef - previous;
+ if (delta != Vector2.Zero)
+ {
+ HatEvents.Enqueue(new JoystickHatMoveEvent(this, timestamp, hatIdx, hatStateRef, delta), sdlTimestamp);
+ }
+ }
+
+ public void AddAxisEvent(int axis, short joystickInput, ulong sdlTimestamp, long timestamp)
+ {
+ _rawAxisState[axis] = (float)(joystickInput + short.MaxValue) / ushort.MaxValue;
+ foreach (var device in _devices)
+ {
+ device.UpdateFromJoyAxis(axis, joystickInput, sdlTimestamp, timestamp);
+ }
+ }
+
+ public void AddButtonEvent(byte sdlButtonId, byte sdlButtonDown, ulong sdlTimestamp, long timestamp)
+ {
+ var down = sdlButtonDown > 0;
+ _rawButtonState[sdlButtonId] = new Button((JoystickButton)sdlButtonId, down, down ? 1 : 0);
+ foreach (var device in _devices)
+ {
+ device.UpdateFromJoyButton(sdlButtonId, down, sdlTimestamp, timestamp);
+ }
+ }
+
+ #endregion
+
+ ///
+ /// Returns a "split" axis vector, taking what would be a single axis value (e.g. Left Thumbstick X) from (0, 1)
+ /// and splitting it into two separate axes from (0, 1)
+ ///
+ /// The axis value on a scale of 0 to 1
+ ///
+ /// A vector representing the split axis values with
+ /// X as the 'minus' component and
+ /// Y as the 'plus' component
+ ///
+ ///
+ /// Todo: the gamepad api demands that joystick axes are (-1, 1)
+ ///
+ internal static Vector2 SplitValue(float value)
+ {
+ value = (float)((value - 0.5d) * 2d);
+ return value > 0 ? new Vector2(0, value) : new Vector2(value, 0);
+ }
+
+ protected internal override void Initialize()
+ {
+ var joystickHandle = NativeBackend.OpenJoystick((uint)SdlDeviceId);
+ if (joystickHandle.Handle == null)
+ {
+ var error = NativeBackend.GetError();
+ string? errorStr = null;
+ if (error.Native != null)
+ {
+ errorStr = error.ReadToString();
+ NativeBackend.Free(error.Native);
+ }
+
+ throw new Exception($"Failed to open joystick: {errorStr ?? "Unknown error."}");
+ }
+
+ // init current joystick state
+ var buttonCount = NativeBackend.GetNumJoystickButtons(joystickHandle);
+ var nowTimestamp = System.Diagnostics.Stopwatch.GetTimestamp();
+ var nowSdlTimestamp = NativeBackend.GetTicks();
+ for (byte i = 0; i < buttonCount; i++)
+ {
+ var joystickInput = NativeBackend.GetJoystickButtonRaw(JoystickHandle, i);
+ AddButtonEvent(i, joystickInput, nowSdlTimestamp, nowTimestamp);
+ }
+
+ var axisCount = NativeBackend.GetNumJoystickAxes(joystickHandle);
+ for (var i = 0; i < axisCount; i++)
+ {
+ var joystickInput = NativeBackend.GetJoystickAxis(JoystickHandle, i);
+ if (joystickInput == 0)
+ {
+ // this indicates an sdl error, so just set our internal axis to 0
+ joystickInput = short.MinValue;
+ }
+
+ AddAxisEvent(i, joystickInput, nowSdlTimestamp, nowTimestamp);
+ }
+
+ var hatCount = NativeBackend.GetNumJoystickHats(joystickHandle);
+ for (var i = 0; i < hatCount; ++i)
+ {
+ var hatInput = NativeBackend.GetJoystickHat(joystickHandle, i);
+ AddHatEvent(i, hatInput, nowSdlTimestamp, nowTimestamp);
+ }
+
+ JoystickHandle = joystickHandle;
+ _joystickType = NativeBackend.GetJoystickType(joystickHandle);
+ _rawAxisState = new float[EnumInfo.UniqueValues.Count + axisCount];
+ _rawButtonState = new Button[EnumInfo.UniqueValues.Count + buttonCount];
+ State = new JoystickState(_rawAxisState, _rawButtonState, _rawHatState);
+ }
+
+ protected override void Release() => NativeBackend.CloseJoystick(JoystickHandle);
+
+
+ public void RefreshSdlId() => _sdlDeviceId = NativeBackend.GetJoystickID(JoystickHandle);
+ private ulong _sdlDeviceId;
+
+ // State
+ private Button[] _rawButtonState;
+ private float[] _rawAxisState;
+ private Vector2[] _rawHatState = [];
+
+ // Constants
+ internal const short DigitalThreshold = short.MaxValue / 8;
+
+ // events
+ internal required ISdlEventQueue> ButtonEvents { get; init; }
+ internal required ISdlEventQueue AxisEvents { get; init; }
+ internal required ISdlEventQueue HatEvents { get; init; }
+
+ ButtonReadOnlyList IButtonDevice.State => State.Buttons;
+}
diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlRumble.cs b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlRumble.cs
new file mode 100644
index 0000000000..c3fcee7a76
--- /dev/null
+++ b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlRumble.cs
@@ -0,0 +1,136 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections;
+using System.Runtime.CompilerServices;
+using Silk.NET.SDL;
+
+namespace Silk.NET.Input.SDL3.Devices.Joysticks;
+
+internal unsafe class SdlRumble : IReadOnlyList
+{
+ public IMotor this[int index] => _motors[index];
+ public int Count => _motors.Length;
+
+ public static SdlRumble Create(void* handle, ISdl sdl, int count) where T : unmanaged
+ {
+ SetRumbleDelegate setRumble;
+ if (typeof(T) == typeof(GamepadHandle))
+ {
+ setRumble = SetGamepadRumble;
+ }
+ else if (typeof(T) == typeof(JoystickHandle))
+ {
+ setRumble = SetJoystickRumble;
+ }
+ else
+ {
+ throw new InvalidOperationException("Invalid device type");
+ }
+
+ return new SdlRumble(handle, sdl, count, setRumble);
+ }
+
+ private SdlRumble(void* handle, ISdl nativeBackend, int count, SetRumbleDelegate setRumble)
+ {
+ _setRumble = setRumble;
+ _handle = handle;
+ _motors = new IMotor[count];
+ _motorFrequencies = new ushort[count];
+ _nativeBackend = nativeBackend;
+ CreateMotors(_motors);
+ }
+
+ private void CreateMotors(IMotor[] motors)
+ {
+ for (var i = 0; i < motors.Length; i++)
+ {
+ motors[i] = new Motor(this, i);
+ }
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => (IEnumerator)_motors.GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() => _motors.GetEnumerator();
+
+ private float GetRumble01(int motor) => _motorFrequencies[motor] * _toFloat;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void SetRumble01(int motor, float value) =>
+ SetRumble01(motor, (ushort)(value * ushort.MaxValue), _motorFrequencies);
+
+ private void SetRumble01(int motor, ushort value, ushort[] motorFrequencies)
+ {
+ // todo - use Haptics API instead?
+ // todo - dispatch this to the correct input thread
+
+ // TODO this entire API needs to be redesigned as right now this is literally only ever going to be useful if it's
+ // just left or right. The original intention was that this would be useful for things like 3D haptics, but what did
+ // I know. The SDL people seem to have done a good job with their haptic API, let's see what we can do with it.
+ // For now, this has the same implementation as it always has.
+ var valueShort = value;
+ motorFrequencies[motor] = valueShort;
+ var left = motorFrequencies[0];
+ var right = motorFrequencies[1];
+ _setRumble(_nativeBackend, _handle, left, right);
+ }
+
+ private static void SetJoystickRumble(ISdl backend, void* handle, ushort left, ushort right)
+ {
+ var average = (ushort)((left + right) >> 2);
+ var joystickHandle = *(JoystickHandle*)&handle;
+ if (!backend.RumbleJoystick(joystickHandle, average, average, _durationMs))
+ {
+ backend.ThrowError();
+ }
+
+ if (!backend.RumbleJoystickTriggers(joystickHandle, left, right, _durationMs))
+ {
+ backend.ThrowError();
+ }
+ }
+
+ private static void SetGamepadRumble(ISdl backend, void* handle, ushort left, ushort right)
+ {
+ var average = (ushort)((left + right) >> 2);
+ var gamepadHandle = *(GamepadHandle*)&handle;
+ if (!backend.RumbleGamepad(gamepadHandle, average, average, _durationMs))
+ {
+ backend.ThrowError();
+ }
+
+ if (!backend.RumbleGamepadTriggers(gamepadHandle, left, right, _durationMs))
+ {
+ backend.ThrowError();
+ }
+ }
+
+ private readonly SetRumbleDelegate _setRumble;
+ private readonly void* _handle;
+ private readonly IMotor[] _motors;
+ private readonly ushort[] _motorFrequencies;
+ private readonly ISdl _nativeBackend;
+ private const float _toFloat = 1f / ushort.MaxValue;
+ private const uint _durationMs = uint.MaxValue;
+
+
+ private delegate void SetRumbleDelegate(ISdl nativeBackend, void* handle, ushort left, ushort right);
+
+ private class Motor : IMotor
+ {
+ private readonly int _freqIndex;
+ private readonly SdlRumble _rumbler;
+
+ public Motor(SdlRumble rumbler, int freqIdx)
+ {
+ _freqIndex = freqIdx;
+ _rumbler = rumbler;
+ }
+
+ public float Speed
+ {
+ get => _rumbler.GetRumble01(_freqIndex);
+ set => _rumbler.SetRumble01(_freqIndex, value);
+ }
+ }
+}
+
diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlCursor.cs b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlCursor.cs
new file mode 100644
index 0000000000..486b016409
--- /dev/null
+++ b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlCursor.cs
@@ -0,0 +1,295 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Frozen;
+using System.Runtime.InteropServices;
+using Silk.NET.SDL;
+
+namespace Silk.NET.Input.SDL3.Devices.Pointers;
+
+internal unsafe class SdlCursor : ICursorConfiguration, IDisposable
+{
+ private readonly ISdl _sdl;
+
+ private CursorHandle _handle;
+
+ ///
+ /// Internal style of the current cursor handle - may differ from the property,
+ ///
+ private CursorStyles _handleStyle = _noStyle;
+ private const CursorStyles _noStyle = (CursorStyles)(-1);
+
+ private static readonly FrozenDictionary _cursorStyles =
+ new Dictionary {
+ [CursorStyles.Default] = SystemCursor.Default,
+ [CursorStyles.Arrow] = SystemCursor.Default,
+ [CursorStyles.IBeam] = SystemCursor.Text,
+ [CursorStyles.Crosshair] = SystemCursor.Crosshair,
+ [CursorStyles.Hand] = SystemCursor.Pointer,
+ [CursorStyles.HResize] = SystemCursor.EwResize,
+ [CursorStyles.VResize] = SystemCursor.NsResize
+ }.ToFrozenDictionary();
+
+
+ public SdlCursor(ISdl sdl)
+ {
+ _sdl = sdl;
+ Mode = CursorModes.Normal;
+ SupportedStyles = TestCursorCompatibility(sdl);
+ Style = CursorStyles.Arrow;
+ }
+
+ private bool SetCursorStyle(CursorStyles style)
+ {
+ CursorHandle handle;
+ if (style == CursorStyles.Custom)
+ {
+ if (_customCursorImage == null || _customCursorSurface == null)
+ {
+ return false;
+ }
+
+ var canReuseCurrentCursorHandle = _handleStyle == CursorStyles.Custom; // todo - compare cursor hotspot
+ if (canReuseCurrentCursorHandle)
+ {
+ return true;
+ }
+
+ // todo: cursor hotspot, not supported by sdl?
+ handle = _sdl.CreateColorCursor(_customCursorSurface, 0, 0);
+ }
+ else if (style == _handleStyle)
+ {
+ return true;
+ }
+ else
+ {
+ handle = _sdl.CreateSystemCursor(_cursorStyles[style]);
+ }
+
+ if (handle.Handle == null)
+ {
+ SdlLog.Error("Failed to create cursor");
+ return false;
+ }
+
+ if (_handle != handle)
+ {
+ FreeCurrentCursor();
+ }
+
+ _handle = handle;
+ _handleStyle = style;
+
+ if (_sdl.SetCursor(_handle))
+ {
+ return true;
+ }
+
+ SdlLog.Error("Failed to set cursor");
+ return false;
+ }
+
+ public void Dispose()
+ {
+ FreeCurrentCursor();
+ DisposeCursorSurface(ref _customCursorSurface);
+ }
+
+ private void FreeCurrentCursor()
+ {
+ if (_handle == default)
+ {
+ return;
+ }
+
+ _sdl.DestroyCursor(_handle);
+ _handle = default;
+
+ if (_handleStyle == CursorStyles.Custom)
+ {
+ DisposeCursorSurface(ref _customCursorSurface);
+ }
+
+ _handleStyle = _noStyle;
+ }
+
+ private void DisposeCursorSurface(ref Surface* surface)
+ {
+ if(surface != null)
+ {
+ _sdl.DestroySurface(surface);
+ _customCursorSurface = null;
+ }
+ }
+
+ private static CursorStyles TestCursorCompatibility(ISdl sdl)
+ {
+ // check cursor style availability
+ ReadOnlySpan mainStyles = [
+ CursorStyles.Arrow, CursorStyles.IBeam, CursorStyles.Crosshair, CursorStyles.Hand, CursorStyles.HResize,
+ CursorStyles.VResize
+ ];
+
+ // todo: is it necessary to check for the Default style? can some platforms just not support any cursor?
+ // if so, the result of this evaluation will still report that "Default" is available..
+ // lest we make it nullable... nah i'll leave it to the Sdl gods for now
+ var successfulStyles = CursorStyles.Default;
+ for (var i = 0; i < mainStyles.Length; i++)
+ {
+ var cursorStyle = mainStyles[i];
+ var sdlStyle = _cursorStyles[cursorStyle];
+ var cursor = sdl.CreateSystemCursor(sdlStyle);
+ if (cursor.Handle == null)
+ {
+ SdlLog.Debug($"System cursor style {sdlStyle} unavailable");
+ }
+ else
+ {
+ successfulStyles |= cursorStyle;
+ sdl.Free(cursor.Handle);
+ }
+ }
+
+ return successfulStyles;
+ }
+
+ public CursorModes SupportedModes =>
+ CursorModes.Normal | CursorModes.Confined | CursorModes.Unbounded;
+
+ public CursorModes Mode
+ {
+ get;
+ set
+ {
+ field = value;
+ try
+ {
+ ModeChanged?.Invoke(this, value);
+ }
+ catch (Exception e)
+ {
+ InputLog.Error(e.ToString());
+ }
+ }
+ }
+
+ public event EventHandler? ModeChanged;
+
+ public CursorStyles SupportedStyles { get; }
+
+ public CursorStyles Style
+ {
+ get;
+ set
+ {
+ if (value == CursorStyles.Hidden && field != CursorStyles.Hidden)
+ {
+ SetCursorVisibility(false);
+ return;
+ }
+
+ SetCursorStyle(value);
+ if(field == CursorStyles.Hidden)
+ {
+ SetCursorVisibility(true);
+ }
+
+ field = value;
+ }
+ }
+
+ private void SetCursorVisibility(bool visible)
+ {
+ if (_handle == default)
+ {
+ return;
+ }
+
+ if (visible ? _sdl.HideCursor() : _sdl.ShowCursor())
+ {
+ return;
+ }
+
+ SdlLog.Error("Failed to hide cursor");
+ }
+
+ public CustomCursor Image
+ {
+ get
+ {
+ var byteCount = _customCursorWidth * _customCursorHeight * 4;
+ var myBytes = _customCursorImage.AsSpan(..byteCount);
+ var asInts = MemoryMarshal.Cast(myBytes);
+ return new CustomCursor { Width = _customCursorWidth, Height = _customCursorHeight, Data = asInts };
+ }
+ set
+ {
+ var necessaryLength = value.Width * value.Height;
+ if(value.Data.Length < necessaryLength)
+ {
+ throw new ArgumentException($"Custom cursor image of size ({value.Width}, {value.Height}) " +
+ $"must be at least {value.Width * value.Height * 4} bytes long, " +
+ $"got {value.Data.Length} bytes instead");
+ }
+
+ // ensure we have a fixed byte array to work with so updates would automatically apply to sdl
+ _customCursorHeight = value.Height;
+ _customCursorWidth = value.Width;
+ var byteCount = necessaryLength * 4;
+ if (_customCursorImage is null || _customCursorImage.Length < byteCount)
+ {
+ _customCursorImage = GC.AllocateUninitializedArray(byteCount, pinned: true);
+ }
+
+ // copy the user data to our fixed array
+ var myBytes = _customCursorImage.AsSpan(..byteCount);
+ var providedBytes = MemoryMarshal.Cast(value.Data);
+ providedBytes.CopyTo(myBytes);
+
+ ApplyToCursorSurface(ref _customCursorSurface, value);
+
+ if (Style == CursorStyles.Custom && _handleStyle != CursorStyles.Custom)
+ {
+ SetCursorStyle(CursorStyles.Custom);
+ }
+
+ return;
+
+ void ApplyToCursorSurface(ref Surface* customCursorSurface, in CustomCursor val)
+ {
+ // create a new sdl surface if necessary
+ if(customCursorSurface != null)
+ {
+ if (customCursorSurface->H != val.Height || customCursorSurface->W != val.Width)
+ {
+ DisposeCursorSurface(ref customCursorSurface);
+ customCursorSurface = CreateSurface(val);
+ }
+ }
+ else
+ {
+ customCursorSurface = _sdl.CreateSurface(val.Width, val.Height, PixelFormat.Argb8888);
+ }
+
+ // ensure the surface's pixel data is our fixed array
+ fixed (byte* ptr = _customCursorImage)
+ {
+ customCursorSurface->Pixels = ptr;
+ }
+
+ return;
+
+ Ptr CreateSurface(CustomCursor customCursor)
+ {
+ return _sdl.CreateSurface(customCursor.Width, customCursor.Height, PixelFormat.Argb8888);
+ }
+ }
+ }
+ }
+
+ private Surface* _customCursorSurface;
+ private int _customCursorHeight, _customCursorWidth;
+ private byte[]? _customCursorImage;
+
+}
diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlMouse.cs b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlMouse.cs
new file mode 100644
index 0000000000..432b91ff7d
--- /dev/null
+++ b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlMouse.cs
@@ -0,0 +1,233 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using Silk.NET.SDL;
+
+namespace Silk.NET.Input.SDL3.Devices.Pointers;
+
+internal sealed class SdlMouse : SdlPointerDevice, IMouse, ISdlDevice
+{
+ public override PointerState State => _state;
+ public ICursorConfiguration Cursor { get; }
+
+ private readonly MouseState _state;
+
+ // the mouse is always considered "down" - there is no up/down state for the mouse pointer itself - only its buttons.
+ private const bool DownState = true;
+
+ private SdlMouse(ulong sdlDeviceId, nint uniqueId, SdlInputBackend backend, IPointerTarget unboundedPointerTarget,
+ ICursorConfiguration cursor)
+ : base(backend, uniqueId, sdlDeviceId, unboundedPointerTarget)
+ {
+ _state = new MouseState(Buttons, Points, Vector2.Zero);
+ Cursor = cursor;
+ }
+
+ protected internal override void Initialize()
+ {
+ float x = 0, y = 0;
+ var nowSdl = NativeBackend.GetTicks();
+ var now = Stopwatch.GetTimestamp();
+ var mouseInputFlags = GetMouseState(ref x, ref y);
+ ApplyMouseButtonState(mouseInputFlags, nowSdl, now);
+
+ var window = NativeBackend.GetMouseFocus();
+ uint windowId;
+ if (window == nullptr)
+ {
+ windowId = 0;
+ }
+ else
+ {
+ windowId = NativeBackend.GetWindowID(window);
+ if (windowId == 0)
+ {
+ SdlLog.Error("Mouse has no window");
+ }
+ }
+
+
+ // var pressure = _state.Buttons[PointerButton.Primary].Pressure;
+ AddOrUpdatePoint(null, windowId, new Vector3(x, y, 0), null, DownState, null, true, nowSdl, now);
+ // var point = _unboundedPointerTarget.GetPoint(this, 0);
+ }
+
+
+ private void ApplyMouseButtonState(SdlMouseInputFlags mouseState, ulong nowSdl, long now)
+ {
+ foreach (var pointerButtonName in EnumInfo.UniqueValues)
+ {
+ if (mouseState.Has(pointerButtonName))
+ {
+ AddButtonEvent(pointerButtonName, now, nowSdl, true);
+ }
+ }
+ }
+
+ private unsafe SdlMouseInputFlags GetMouseState(ref float x, ref float y) =>
+ (SdlMouseInputFlags)NativeBackend.GetMouseState((float*)Unsafe.AsPointer(ref x),
+ (float*)Unsafe.AsPointer(ref y));
+
+ public static SdlMouse CreateDevice(ulong sdlDeviceId, SdlInputBackend backend, SilkEventContext silkEvents)
+ {
+ var deviceName = backend.Sdl.GetMouseNameForID((uint)sdlDeviceId);
+ nint uniqueId = 0;
+ if (!backend.AttemptUniqueId(deviceName, ref uniqueId))
+ {
+ uniqueId = SdlInputBackend.FallbackUniqueId(sdlDeviceId, uniqueId);
+ }
+
+ var mouse =
+ new SdlMouse(sdlDeviceId, uniqueId, backend, backend.UnboundedPointerTarget, backend.CursorConfiguration) {
+ ScrollEvents = silkEvents.MouseScrollSdlEvents,
+ PointEvents = silkEvents.PointChangedSdlEvents,
+ ClickEvents = silkEvents.PointerClickSdlEvents,
+ ButtonEvents = silkEvents.PointerButtonSdlEvents,
+ GripEvents = silkEvents.PointerGripChangedSdlEvents,
+ TargetEvents = silkEvents.PointerTargetChangedSdlEvents
+ };
+
+ return mouse;
+ }
+
+ public override string Name => NativeBackend.GetMouseNameForID((uint)SdlDeviceId).ReadToString();
+
+ protected override void Release()
+ {
+ }
+
+ MouseState IMouse.State => _state;
+
+ // todo (maybe): pair with simulated touch device if simulated touch events occur
+ // though, we can probably only pair if there is *one* mouse - multiple mice
+ // would make it difficult to pair, as simulated touch input does not specify the mouse
+ // it comes from
+ public int? TouchId { get; private set; }
+
+ private bool IsMouseRelative
+ {
+ get
+ {
+ //var focus = NativeBackend.GetMouseFocus();
+ if (!Backend.TryGetWindowHandles(out var windows))
+ {
+ return false;
+ }
+
+ var isRelative = false;
+
+ for (var i = 0; i < windows.Count; i++)
+ {
+ isRelative |= NativeBackend.GetWindowRelativeMouseMode(windows[i]);
+ }
+
+ windows.Dispose();
+ return isRelative;
+ }
+ }
+
+ protected override bool OnePointOnly => true;
+ private bool _hintsAsEvents = false;
+
+
+ ///
+ ///
+ ///
+ /// The window-relative position of the mouse
+ /// True if success, but see the below remarks
+ ///
+ /// todo: this is the most straightforward way to do window-relative movement,
+ /// but we don't actually get any information about whether or not
+ /// it succeeds. The SDL documentation seems to suggest that it "just works" as opposed to their global mouse warp
+ /// method, which provides a success result. instead of translating this "global" position to a window-specific one,
+ /// we're gonna use SDL's implementation of window-relative movement to keep things simple.
+ /// As a result, we always return true, though the documentation suggests that it does not work specifically for
+ /// Microsoft Remote Desktop. not sure how to detect that at the moment.
+ /// Another consequence of doing it this way is that we need to wait for SDL to push these events through the event
+ /// queue, so while this may return 'true', this mouse object will not necessarily be in the correct position until
+ /// the next time pumped events are processed.
+ /// We could just create a mouse motion event and push it through our internal event queues, bypassing SDL's event
+ /// queue, but that could result in a situation where we set our internal state to represent the
+ /// movement, even though SDL did not meaningfully succeed.
+ /// The best way around this would probably be to try global mouse warping with translated coordinates,
+ /// and if that fails, fall back to window-relative movement.
+ ///
+ public bool TrySetPosition(Vector2 position)
+ {
+ // make sure we get pumped mouse events for setting the position this way
+ if (!_hintsAsEvents)
+ {
+ var currentHintVal = NativeBackend.GetHintBoolean(Sdl.HintMouseRelativeWarpMotion, new MaybeBool(0));
+ if (currentHintVal == 0)
+ {
+ sbyte hintVal = 1;
+ if (NativeBackend.SetHint(Sdl.HintMouseRelativeWarpMotion, new Ref(ref hintVal)))
+ {
+ _hintsAsEvents = true;
+ }
+ }
+ else
+ {
+ _hintsAsEvents = true;
+ }
+ }
+
+ // providing a null window handle means that SDL will use the latest position
+ NativeBackend.WarpMouseInWindow(default, position.X, position.Y);
+ NeedsPump = true; // we should "pump" events immediately, but this isn't a good place to do it.
+ return true;
+ }
+
+ ///
+ /// A user has attempted to modify the hardware mouse position - we need a pump to process these events.
+ ///
+ public bool NeedsPump { get; private set; }
+
+ public void AddMotion(in MouseMotionEvent evtMotion, long timestamp) =>
+ AddOrUpdatePoint(null, evtMotion.WindowID, new Vector3(evtMotion.X, evtMotion.Y, 0), 1, null, null,
+ evtMotion.WindowID != 0, evtMotion.Timestamp, timestamp);
+
+
+ public void AddButtonEvent(in MouseButtonEvent evtButton, long timestamp)
+ {
+ var button = evtButton.Button switch {
+ 1 => PointerButton.Primary,
+ 2 => PointerButton.MiddleButton,
+ 3 => PointerButton.Secondary,
+ 4 => PointerButton.Button4,
+ 5 => PointerButton.Button5,
+ _ => throw new ArgumentOutOfRangeException(nameof(evtButton.Button), evtButton.Button, null)
+ };
+ //var button = PointerButton.Primary + (evtButton.Button - 1);
+ const float mult = 1 / 255f;
+ AddButtonEvent(button, timestamp, evtButton.Timestamp, evtButton.Down > 0, evtButton.Down * mult);
+ }
+
+ public void AddWheelEvent(in MouseWheelEvent evtWheel, long timestamp)
+ {
+ var pWheelPosition = _state.WheelPosition;
+ const float max = 100f;
+ var delta = new Vector2(evtWheel.X, evtWheel.Y);
+ if (delta.X != 0 && pWheelPosition.X is > max or < -max)
+ {
+ pWheelPosition.X = 0;
+ }
+
+ if (delta.Y != 0 && pWheelPosition.Y is > max or < -max)
+ {
+ pWheelPosition.Y = 0;
+ }
+
+ AddMouseScrollEvent(
+ scrollWheelPosition: _state.WheelPosition = pWheelPosition + delta,
+ scrollWheelDelta: delta,
+ windowId: evtWheel.WindowID,
+ sdlTimestamp: evtWheel.Timestamp,
+ mousePos: new Vector3(evtWheel.X, evtWheel.Y, 0),
+ timestamp: timestamp);
+ }
+
+}
diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlPen.cs b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlPen.cs
new file mode 100644
index 0000000000..7ffdf1e3c5
--- /dev/null
+++ b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlPen.cs
@@ -0,0 +1,184 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using Silk.NET.SDL;
+
+namespace Silk.NET.Input.SDL3.Devices.Pointers;
+
+internal class SdlPen : SdlPointerDevice, ISdlDevice
+{
+ public SdlPen(SdlInputBackend backend, nint silkId, ulong sdlDeviceId, string name, IPointerTarget unbounded) :
+ base(backend, silkId, sdlDeviceId, unbounded)
+ {
+ Name = name;
+ State = new PointerState(Buttons, Points);
+ }
+
+ public static SdlPen CreateDevice(ulong sdlDeviceId, SdlInputBackend backend, SilkEventContext silkEvents)
+ {
+ nint uniqueId = 0;
+
+ var name = backend.Sdl.GetTouchDeviceName(sdlDeviceId);
+ if (name == nullptr)
+ {
+ SdlLog.Error("Failed to get pen name");
+ }
+
+ if (backend.AttemptUniqueId(name, ref uniqueId))
+ {
+ return Create();
+ }
+
+ if (backend.AttemptUniqueId(name, ref uniqueId))
+ {
+ return Create();
+ }
+
+ if (backend.AttemptUniqueId(sdlDeviceId, ref uniqueId))
+ {
+ return Create();
+ }
+
+ uniqueId = SdlInputBackend.FallbackUniqueId