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 17850fdc6f..364d408733 100644 --- a/Silk.NET.sln +++ b/Silk.NET.sln @@ -102,6 +102,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Windowing", "Windowing", "{ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Silk.NET.Windowing", "sources\Windowing\Windowing\Silk.NET.Windowing.csproj", "{EF07CBB5-D253-4CA9-A5DA-8B3DF2B0DF8E}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Input", "Input", "{33ED9765-8C36-4A9D-95E8-AF037FE104B3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Silk.NET.Input", "sources\Input\Input\Silk.NET.Input.csproj", "{49A42CE3-94C5-4239-B0FC-F1FF8D7AAADA}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Input", "Input", "{4E0EF53A-76BC-4729-8E3B-4768E86E357E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Silk.NET.Input.UnitTests", "tests\Input\Input\Silk.NET.Input.UnitTests.csproj", "{00B9B6E6-776E-480C-B3ED-D6420C5B4E8E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -168,6 +176,14 @@ Global {EF07CBB5-D253-4CA9-A5DA-8B3DF2B0DF8E}.Debug|Any CPU.Build.0 = Debug|Any CPU {EF07CBB5-D253-4CA9-A5DA-8B3DF2B0DF8E}.Release|Any CPU.ActiveCfg = Release|Any CPU {EF07CBB5-D253-4CA9-A5DA-8B3DF2B0DF8E}.Release|Any CPU.Build.0 = Release|Any CPU + {49A42CE3-94C5-4239-B0FC-F1FF8D7AAADA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49A42CE3-94C5-4239-B0FC-F1FF8D7AAADA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49A42CE3-94C5-4239-B0FC-F1FF8D7AAADA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49A42CE3-94C5-4239-B0FC-F1FF8D7AAADA}.Release|Any CPU.Build.0 = Release|Any CPU + {00B9B6E6-776E-480C-B3ED-D6420C5B4E8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00B9B6E6-776E-480C-B3ED-D6420C5B4E8E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00B9B6E6-776E-480C-B3ED-D6420C5B4E8E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00B9B6E6-776E-480C-B3ED-D6420C5B4E8E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -200,6 +216,10 @@ Global {F16C0AB9-DE7E-4C09-9EE9-DAA8B8E935A6} = {EC4D7B06-D277-4411-BD7B-71A6D37683F0} {FE4414F8-5370-445D-9F24-C3AD3223F299} = {DD29EA8F-B1A6-45AA-8D2E-B38DA56D9EF6} {EF07CBB5-D253-4CA9-A5DA-8B3DF2B0DF8E} = {FE4414F8-5370-445D-9F24-C3AD3223F299} + {33ED9765-8C36-4A9D-95E8-AF037FE104B3} = {DD29EA8F-B1A6-45AA-8D2E-B38DA56D9EF6} + {49A42CE3-94C5-4239-B0FC-F1FF8D7AAADA} = {33ED9765-8C36-4A9D-95E8-AF037FE104B3} + {4E0EF53A-76BC-4729-8E3B-4768E86E357E} = {A5578D12-9E77-4647-8C22-0DBD17760BFF} + {00B9B6E6-776E-480C-B3ED-D6420C5B4E8E} = {4E0EF53A-76BC-4729-8E3B-4768E86E357E} 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 1f3ae0857e..2a1f1a1c28 100644 --- a/eng/build/Silk.NET.NUKE.csproj +++ b/eng/build/Silk.NET.NUKE.csproj @@ -18,4 +18,10 @@ + + + + Directory.Build\tests\Input\Input\Silk.NET.Input.UnitTests.csproj + + 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/net8.0/PublicAPI.Unshipped.txt b/sources/Core/Core/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 14eb48880a..2daf4e53ee 100644 --- a/sources/Core/Core/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/sources/Core/Core/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -414,6 +414,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..b030762606 --- /dev/null +++ b/sources/Input/Input/ButtonChangedEvent.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; + +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 +) + 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..2da787cc70 --- /dev/null +++ b/sources/Input/Input/ConnectionEvent.cs @@ -0,0 +1,13 @@ +using System.Diagnostics; + +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); \ No newline at end of file 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..b82b9d0337 --- /dev/null +++ b/sources/Input/Input/CustomCursor.cs @@ -0,0 +1,22 @@ +namespace Silk.NET.Input; + +/// +/// Represents a custom image for a mouse cursor. +/// +public readonly ref struct CustomCursor +{ + /// + /// 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 +} 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..b2ffeef3a3 --- /dev/null +++ b/sources/Input/Input/GamepadThumbstickMoveEvent.cs @@ -0,0 +1,17 @@ +using System.Diagnostics; +using System.Numerics; + +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. +public readonly record struct GamepadThumbstickMoveEvent(IGamepad Gamepad, long Timestamp, Vector2 Value, Vector2 Delta); \ No newline at end of file diff --git a/sources/Input/Input/GamepadTriggerMoveEvent.cs b/sources/Input/Input/GamepadTriggerMoveEvent.cs new file mode 100644 index 0000000000..0cbca61581 --- /dev/null +++ b/sources/Input/Input/GamepadTriggerMoveEvent.cs @@ -0,0 +1,17 @@ +using System.Diagnostics; + +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); \ No newline at end of file diff --git a/sources/Input/Input/Gamepads.cs b/sources/Input/Input/Gamepads.cs new file mode 100644 index 0000000000..ce56d20079 --- /dev/null +++ b/sources/Input/Input/Gamepads.cs @@ -0,0 +1,43 @@ +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/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..0d02d1d675 --- /dev/null +++ b/sources/Input/Input/IButtonInputHandler.cs @@ -0,0 +1,15 @@ +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); +} 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..b1bf488d8d --- /dev/null +++ b/sources/Input/Input/IGamepadInputHandler.cs @@ -0,0 +1,19 @@ +namespace Silk.NET.Input; + +/// +/// An that also receives input. +/// +public interface IGamepadInputHandler : IButtonInputHandler +{ + /// + /// 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); +} \ No newline at end of file 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..5dca7202d1 --- /dev/null +++ b/sources/Input/Input/IJoystickInputHandler.cs @@ -0,0 +1,19 @@ +namespace Silk.NET.Input; + +/// +/// An that also receives input. +/// +public interface IJoystickInputHandler : IButtonInputHandler +{ + /// + /// 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); +} \ No newline at end of file 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..6ca22ec632 --- /dev/null +++ b/sources/Input/Input/IKeyboardInputHandler.cs @@ -0,0 +1,23 @@ +namespace Silk.NET.Input; + +/// +/// An that also receives events. +/// +public interface IKeyboardInputHandler : IButtonInputHandler +{ + /// + /// 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); +} \ No newline at end of file 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..71d02ad1c6 --- /dev/null +++ b/sources/Input/Input/IMouseInputHandler.cs @@ -0,0 +1,13 @@ +namespace Silk.NET.Input; + +/// +/// An that receives input from an . +/// +public interface IMouseInputHandler : IButtonInputHandler +{ + /// + /// Called when the user scrolls using the scroll wheel. + /// + /// The event details. + void HandleScroll(MouseScrollEvent @event); +} \ No newline at end of file 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..f4bd67c0fc --- /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 +{ + /// + /// 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); +} \ No newline at end of file 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..76144706eb --- /dev/null +++ b/sources/Input/Input/Implementations/EnumInfo.cs @@ -0,0 +1,267 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +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 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]; + 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(); + + if (byNumericValue) + { + allValues = allValues.DistinctBy(ValueOf).ToArray(); + } + + // sort by increasing order + Array.Sort(allValues, (a, b) => { + var aNumber = ValueOf(a); + var bNumber = ValueOf(b); + return aNumber.CompareTo(bNumber); + }); + + return allValues; + } + + public static unsafe bool HasValue(int value) => _allEnumValuesRaw.Contains(*(uint*)&value); +} + +[AttributeUsage(AttributeTargets.Enum)] +internal class OrderedIndexUsageAttribute : Attribute; 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..7ee140e867 --- /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 +/// +public 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..8df78933bf --- /dev/null +++ b/sources/Input/Input/Implementations/KeyHandling/TextRecorder.cs @@ -0,0 +1,398 @@ +// 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.Text; +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 void AddKeyStroke(KeyName name, IKeyboard keyboard) + { + var isChar = name.IsChar(); + var isDeletion = name.IsDeletion(); + + if (!isChar && !isDeletion) + { + return; + } + + if (name == KeyName.Paste) + { + var clipboardText = keyboard.ClipboardText; + if (!string.IsNullOrEmpty(clipboardText)) + { + InsertText(clipboardText); + } + + return; + } + + var state = keyboard.State; + var activeModifiers = state.Modifiers; + if (name.IsChar()) + { + if (activeModifiers.IsAlt() || activeModifiers.IsControl()) + { + return; + } + + // insert the appropriate character + // first, we need the virtual key represented by the scancode (KeyName) + + if (_converter.TryConvert(name, activeModifiers, out var c)) + { + InsertText(c.Value); + } + + return; + } + + 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: + Console.Error.WriteLine("Unexpected text deletion type"); + break; + } + } + } + + /// + /// 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 + { + Console.Error.WriteLine("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/SDL3/BackendExtensions.cs b/sources/Input/Input/Implementations/SDL3/BackendExtensions.cs new file mode 100644 index 0000000000..25ecd6804e --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/BackendExtensions.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using System.Runtime.CompilerServices; + +namespace Silk.NET.Input.SDL3; + +internal static unsafe class BackendExtensions +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IntPtr FallbackUniqueId(this SdlInputBackend _, uint sdlDeviceId, nint uniqueId) + { + Console.Error.WriteLine("Failed to create a deterministically unique identifier for joystick"); + return uniqueId ^ ((nint)sdlDeviceId | ((nint)sdlDeviceId << 16)); + } + + public static bool AttemptUniqueId(this SdlInputBackend sdlInputBackend, Ptr ptr, ref nint uniqueId1) + { + if (ptr.Native == null) + return false; + + var name = ptr.ReadToString(); + var bytes = Encoding.Default.GetBytes(name); + return AttemptUniqueId(sdlInputBackend, bytes, ref uniqueId1); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool AttemptUniqueId(this SdlInputBackend sdlInputBackend, T ptr, ref nint uniqueId1) + where T : unmanaged => + AttemptUniqueId(sdlInputBackend, new ReadOnlySpan(&ptr, sizeof(T)), ref uniqueId1); + + public static bool AttemptUniqueId(this SdlInputBackend sdlInputBackend, ReadOnlySpan bytes, ref nint uniqueId1) + { + uniqueId1 = Modify(uniqueId1, bytes); + return sdlInputBackend.DeviceRegistry.Add(uniqueId1); + static nint Modify(nint original, ReadOnlySpan withBytes) + { + if (sizeof(nint) == 4) + { + var hash = new HashCode(); + foreach(var b in withBytes) + { + hash.Add(b); + } + + var hashCode = hash.ToHashCode(); + return original ^ *(nint*)(&hashCode); + } + + var hash64Bytes = (byte*)&original; + + for (int i = 0; i < withBytes.Length; i += 8) + { + hash64Bytes[i % 8] ^= withBytes[i]; + } + + return *(nint*)hash64Bytes; + } + + } +} 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..f9a38697d5 --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/Devices/ISdlDevice.cs @@ -0,0 +1,14 @@ +// 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(uint sdlDeviceId, SdlInputBackend backend); + +} 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..07d5598a38 --- /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.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..3a1e81b181 --- /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.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); + + /// + /// Raw joystick hat input events are forwarded here + /// + /// Input hat (which hat) + /// Input hat value + public void UpdateFromJoyHat(int hatIdx, SdlJoystick.HatState hatState); + + /// + /// Raw joystick button input events are forwarded here + /// + /// Input button (which button) + /// Button state + public void UpdateFromJoyButton(int buttonIdx, bool down); +} 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..bc279c79d6 --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlGamepad.cs @@ -0,0 +1,368 @@ +// 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.Joysticks; + +/// +/// provides the IGamepad implementation for a joystick +/// +internal sealed unsafe class SdlGamepad : SdlDevice, IGamepad, ISdlDevice, ISdlJoystick, IJoystick +{ + private readonly 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; + + var joystickHandle = joystick.JoystickHandle; + var gamepadHandle = *(GamepadHandle*)&joystickHandle; //NativeBackend.OpenGamepad(sdlDeviceId); + _gamepadHandle = gamepadHandle; + Remap(gamepadHandle); + + GamepadState = new GamepadState(joystick.RawButtonState, joystick.RawAxisState); + Joystick.AddDeviceMapping(this); + } + + 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) + { + var error = NativeBackend.GetError(); + if (error.Native != null) + { + Console.Error.WriteLine(error.ReadToString()); + NativeBackend.Free(error.Native); + } + } + else + { + NativeBackend.Free(mappings); + } + + Console.Error.WriteLine("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 uint SdlDeviceId => _sdlDeviceId; + private uint _sdlDeviceId; + + public void RefreshSdlId() => _sdlDeviceId = NativeBackend.GetGamepadID(_gamepadHandle); + + public override string Name => Joystick.Name; + + protected override void Release() + { + Joystick.RemoveDeviceMapping(this); + + // todo: does this close the joystick as well? + NativeBackend.CloseGamepad(_gamepadHandle); + } + + #region IGamepad + + GamepadState IGamepad.State => GamepadState; + private GamepadState GamepadState { get; } + + public IReadOnlyList VibrationMotors => _rumbler ??= SdlRumble.Create(_gamepadHandle.Handle, NativeBackend, 2); + private SdlRumble? _rumbler; + + + #endregion + + public static SdlGamepad? CreateDevice(uint sdlDeviceId, SdlInputBackend backend) + { + if (!backend.TryGetOrCreateDevice(sdlDeviceId, out var joystick)) + { + return null; + } + + var joystickUniqueId = joystick.Id; + var gpn = backend.Sdl.GetRealGamepadTypeForID(sdlDeviceId); + + if (backend.AttemptUniqueId(gpn, ref joystickUniqueId)) + { + return new SdlGamepad(joystick, uniqueId: joystickUniqueId); + } + + // manipulate the joystick id to make a unique gamepad id + var guid = backend.Sdl.GetGamepadGuidForID(sdlDeviceId); + if (backend.AttemptUniqueId(guid, ref joystickUniqueId)) + { + return new SdlGamepad(joystick, uniqueId: joystickUniqueId); + } + + joystickUniqueId = backend.FallbackUniqueId(sdlDeviceId, joystickUniqueId); + return new SdlGamepad(joystick, uniqueId: joystickUniqueId); + } + + private void UpdateGamepadAxis(GamepadAxis axis, int value, int min, int max) + { + var mappedValue = (float)(value + min) / (max - min); + switch (axis) + { + case GamepadAxis.Invalid: + return; + case GamepadAxis.Leftx: + { + Joystick.UpdateRawAxisState(JoystickAxis.LeftX, mappedValue); + + var split = SdlJoystick.SplitValue(mappedValue); + Joystick.UpdateRawAxisState(JoystickAxis.MinusLeftX, split.minus); + Joystick.UpdateRawAxisState(JoystickAxis.PlusLeftX, split.plus); + break; + } + case GamepadAxis.Lefty: + { + Joystick.UpdateRawAxisState(JoystickAxis.LeftY, mappedValue); + + var split = SdlJoystick.SplitValue(mappedValue); + Joystick.UpdateRawAxisState(JoystickAxis.MinusLeftY, split.minus); + Joystick.UpdateRawAxisState(JoystickAxis.PlusLeftY, split.plus); + break; + } + case GamepadAxis.Rightx: + { + Joystick.UpdateRawAxisState(JoystickAxis.RightX, mappedValue); + + var split = SdlJoystick.SplitValue(mappedValue); + Joystick.UpdateRawAxisState(JoystickAxis.MinusRightX, split.minus); + Joystick.UpdateRawAxisState(JoystickAxis.PlusRightX, split.plus); + break; + } + case GamepadAxis.Righty: + { + Joystick.UpdateRawAxisState(JoystickAxis.RightY, mappedValue); + + var split = SdlJoystick.SplitValue(mappedValue); + Joystick.UpdateRawAxisState(JoystickAxis.MinusRightY, split.minus); + Joystick.UpdateRawAxisState(JoystickAxis.PlusRightY, split.plus); + break; + } + case GamepadAxis.LeftTrigger: + { + Joystick.UpdateRawAxisState(JoystickAxis.LeftTrigger, mappedValue); + break; + } + case GamepadAxis.RightTrigger: + { + Joystick.UpdateRawAxisState(JoystickAxis.RightTrigger, mappedValue); + break; + } + default: + throw new ArgumentOutOfRangeException(nameof(axis), axis, null); + } + } + + #region ISdlJoystick + + public void UpdateFromJoyButton(int buttonIdx, bool down) + { + 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( + axis: axis.Axis, + value: down ? axis.AxisMax : axis.AxisMin, + min: axis.AxisMin, + max: axis.AxisMax); + break; + + case GamepadBindingType.Button: + UpdateButtonBinding(output->Button, down); + break; + } + } + + public void AddButtonEvent(byte sdlButtonId, byte sdlButtonDown) => + UpdateButtonBinding((GamepadButton)sdlButtonId, sdlButtonDown > 0); + + public void AddAxisEvent(byte evtAxis, short evtValue) => + UpdateGamepadAxis((GamepadAxis)evtAxis, evtValue, Sdl.JoystickAxisMin, Sdl.JoystickAxisMax); + + public void UpdateFromJoyAxis(int axis, short joystickInput) + { + 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); + break; + case GamepadBindingType.Button: + UpdateButtonBinding(output->Button, joystickInput >= input->AxisMin && joystickInput <= input->AxisMax); + break; + } + } + + public void UpdateFromJoyHat(int hatIdx, SdlJoystick.HatState hatState) + { + 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( + axis: axis.Axis, + value: bindingState == SdlJoystick.HatState.Centered ? axis.AxisMin : axis.AxisMax, + min: axis.AxisMin, + max: axis.AxisMax); + break; + case GamepadBindingType.Button: + var button = binding.Output.Button; + UpdateButtonBinding(button, bindingState != SdlJoystick.HatState.Centered); + break; + } + } + } + + #endregion + + private void UpdateButtonBinding(GamepadButton button, bool value) + { + var asJoystickButton = AsJoystickButton(button); + Joystick.UpdateRawButtonState(asJoystickButton, value, value ? 1 : 0); + 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 = []; + + + // 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..146dc37f0d --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlJoystick.Extended.cs @@ -0,0 +1,56 @@ +// 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.Numerics; + +namespace Silk.NET.Input.SDL3.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) + { + var idx = button.Index(); + if (idx < 0) + { + throw new Exception("Received an invalid SDL button??"); + } + + _rawButtonState[idx] = new Button(button, isDown, pressure); + } + + internal void UpdateRawAxisState(JoystickAxis axis, float value) + { + var index = axis.Index(); + if (index < 0) + { + throw new Exception("Received an invalid SDL axis??"); + } + + _rawAxisState[axis.Index()] = value; + } + + 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..e0676310bf --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/Devices/Joysticks/SdlJoystick.cs @@ -0,0 +1,189 @@ +// 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 Silk.NET.SDL; + +namespace Silk.NET.Input.SDL3.Joysticks; + +internal sealed unsafe partial class SdlJoystick : SdlDevice, IJoystick, ISdlDevice, IOrderedDevice +{ + public JoystickState State { get; } + internal readonly JoystickType JoystickType; + internal JoystickHandle JoystickHandle { get; } + + public static SdlJoystick CreateDevice(uint sdlDeviceId, SdlInputBackend backend) + { + nint uniqueId = 0; + + var guid = backend.Sdl.GetJoystickGuidForID(sdlDeviceId); + if (backend.AttemptUniqueId(new ReadOnlySpan(&guid, 16), ref uniqueId)) + { + return new SdlJoystick(sdlDeviceId, uniqueId, backend); + } + + var pathPtr = backend.Sdl.GetJoystickPathForID(sdlDeviceId); + if (backend.AttemptUniqueId(pathPtr, ref uniqueId)) + { + return new SdlJoystick(sdlDeviceId, uniqueId, backend); + } + + var name = backend.Sdl.GetJoystickNameForID(sdlDeviceId); + if (backend.AttemptUniqueId(name, ref uniqueId)) + { + return new SdlJoystick(sdlDeviceId, uniqueId, backend); + } + + var type = backend.Sdl.GetJoystickTypeForID(sdlDeviceId); + if (backend.AttemptUniqueId(type, ref uniqueId)) + { + return new SdlJoystick(sdlDeviceId, uniqueId, backend); + } + + uniqueId = backend.FallbackUniqueId(sdlDeviceId, uniqueId); + return new SdlJoystick(sdlDeviceId, uniqueId, backend); + } + + + public override string Name => NativeBackend.GetJoystickNameForID(SdlDeviceId).ReadToString(); + + public override uint SdlDeviceId => _sdlDeviceId; + + + + private SdlJoystick(uint sdlDeviceId, nint uniqueId, SdlInputBackend backend) : base(backend, uniqueId, sdlDeviceId) + { + var joystickHandle = NativeBackend.OpenJoystick(sdlDeviceId); + _sdlDeviceId = 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."}"); + } + + JoystickHandle = joystickHandle; + JoystickType = NativeBackend.GetJoystickType(joystickHandle); + + + // init current joystick state + var buttonCount = NativeBackend.GetNumJoystickButtons(joystickHandle); + for (byte i = 0; i < buttonCount; i++) + { + var joystickInput = NativeBackend.GetJoystickButtonRaw(JoystickHandle, i); + AddButtonEvent(i, joystickInput); + } + + 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); + } + + var hatCount = NativeBackend.GetNumJoystickHats(joystickHandle); + for (var i = 0; i < hatCount; ++i) + { + var hatInput = NativeBackend.GetJoystickHat(joystickHandle, i); + AddHatEvent(i, hatInput); + } + + _rawAxisState = new float[EnumInfo.UniqueValues.Count + axisCount]; + _rawButtonState = new Button[EnumInfo.UniqueValues.Count + buttonCount]; + + State = new JoystickState(_rawAxisState, _rawButtonState, _rawHatState); + } + + + [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) + { + 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); + + _rawHatState[hatIdx] = new Vector2(x, y); + + foreach(var device in _devices) + { + device.UpdateFromJoyHat(hatIdx, hatState); + } + } + + public void AddAxisEvent(int axis, short joystickInput) + { + _rawAxisState[axis] = (float)(joystickInput + short.MaxValue) / ushort.MaxValue; + foreach (var device in _devices) + { + device.UpdateFromJoyAxis(axis, joystickInput); + } + } + + public void AddButtonEvent(byte sdlButtonId, byte sdlButtonDown) + { + var down = sdlButtonDown > 0; + _rawButtonState[sdlButtonId] = new Button((JoystickButton)sdlButtonId, down, down ? 1 : 0); + foreach (var device in _devices) + { + device.UpdateFromJoyButton(sdlButtonId, down); + } + } + + #endregion + + internal static (float minus, float plus) SplitValue(float mappedValue) + { + mappedValue = (float)((mappedValue - 0.5d) * 2d); + return mappedValue > 0 ? (0, mappedValue) : (mappedValue, 0); + } + + + protected override void Release() => NativeBackend.CloseJoystick(JoystickHandle); + + public void RefreshSdlId() => _sdlDeviceId = NativeBackend.GetJoystickID(JoystickHandle); + private uint _sdlDeviceId; + + // State + private readonly Button[] _rawButtonState; + private readonly float[] _rawAxisState; + private readonly Vector2[] _rawHatState = []; + + // Constants + internal const short DigitalThreshold = short.MaxValue / 8; + + 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..ed99d82212 --- /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.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/SdlBoundedPointerDevice.cs b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlBoundedPointerDevice.cs new file mode 100644 index 0000000000..e101d768ae --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlBoundedPointerDevice.cs @@ -0,0 +1,28 @@ +// 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; + +namespace Silk.NET.Input.SDL3.Pointers; + +/// +/// A base class for SDL input devices that operate in terms of a window's or DWMs bounds. +/// +internal abstract class SdlBoundedPointerDevice : SdlDevice, IPointerDevice +{ + protected SdlBoundedPointerDevice(SdlInputBackend backend, nint silkId, + uint sdlDeviceId) : base(backend, silkId, sdlDeviceId) + { + } + + public abstract PointerState State { get; } + + public abstract IReadOnlyList Targets { get; } + + /// + /// Determines whether the should interpret + /// as being bounded points. For all devices supported by this backend, only one target is supported at a time + /// today. + /// + public bool IsBounded => true; +} diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlBoundedPointerTarget.cs b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlBoundedPointerTarget.cs new file mode 100644 index 0000000000..cb7e353f4f --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlBoundedPointerTarget.cs @@ -0,0 +1,94 @@ +// 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.Maths; +using Silk.NET.SDL; + +namespace Silk.NET.Input.SDL3.Pointers; + +internal class SdlBoundedPointerTarget(SdlInputBackend backend) : IPointerTarget +{ + internal SdlInputBackend Backend { get; } = backend; + private Box2D Bounds2D { get; set; } + + public Box3D Bounds => + new(new Vector3D(Bounds2D.Min, 0), new Vector3D(Bounds2D.Max, 1)); + + public static Box2D CalculateBounds(ISdl sdl) + { + var minX = float.PositiveInfinity; + var minY = float.PositiveInfinity; + var maxX = float.NegativeInfinity; + var maxY = float.NegativeInfinity; + var displayCount = 0; + var displays = sdl.GetDisplays(displayCount.AsRef()); + if (displays == nullptr) + { + // Looks like we can't support windowed mouse input. + sdl.ClearError(); + return default; + } + + if (displayCount == 0) // ??? + { + sdl.Free((Ref)displays); + return default; + } + + for (var i = 0; i < displayCount; i++) + { + Rect rect = default; + if (!sdl.GetDisplayBounds(displays[(nuint)i], rect.AsRef())) + { + return default; + } + + minX = float.Min(minX, rect.X); + minY = float.Min(minY, rect.Y); + maxX = float.Max(maxX, rect.X + rect.W); + maxY = float.Max(maxY, rect.Y + rect.H); + } + + sdl.Free((Ref)displays); + if (minX <= maxX && minY <= maxY) + { + return new Box2D(minX, minY, maxX, maxY); + } + + return default; + } + + public int GetPointCount(IPointerDevice pointer) + { + if (pointer is not SdlBoundedPointerDevice { IsBounded: true } device) + { + return 0; + } + + if (device.Backend == Backend) + { + return Bounds != default ? device.BoundedPoints.List.Count : 0; + } + + return device.Backend.BoundedPointerTarget.GetPointCount(pointer); + } + + public TargetPoint GetPoint(IPointerDevice pointer, int point) + { + if ( + pointer is not SdlBoundedPointerDevice { IsBounded: true } device + || point < 0 + || point >= device.BoundedPoints.List.Count + ) + { + return default; + } + + if (device.Backend != Backend) + { + return device.Backend.BoundedPointerTarget.GetPoint(pointer, point); + } + + return Bounds != default ? device.BoundedPoints.List[point] : default; + } +} 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..acbacce0ea --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlPen.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Guid = Silk.NET.SDL.Guid; + +namespace Silk.NET.Input.SDL3.Pointers; + +internal class SdlPen : SdlBoundedPointerDevice, ISdlDevice +{ + public static SdlPen CreateDevice(uint sdlDeviceId, SdlInputBackend backend) + { + throw new NotImplementedException(); + } + + public override PointerState State => throw new NotImplementedException(); + + public SdlPen(uint sdlDeviceId, SdlInputBackend backend, IReadOnlyList targets, InputMarshal.ListOwner boundedPoints) : base(sdlDeviceId, backend, targets, boundedPoints) + { + } + +} diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlSharedMouse.cs b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlSharedMouse.cs new file mode 100644 index 0000000000..e5f970c910 --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlSharedMouse.cs @@ -0,0 +1,211 @@ +// 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 Silk.NET.SDL; + +namespace Silk.NET.Input.SDL3.Pointers; + +internal class SdlSharedMouse : SdlBoundedPointerDevice, IMouse, ISdlDevice +{ + private readonly MouseState _state; + + public unsafe SdlSharedMouse(uint sdlDeviceId, nint uniqueId, SdlInputBackend backend) + : base(backend, uniqueId, sdlDeviceId) + { + IPointerTarget unboundedPointerTarget = backend.UnboundedPointerTarget; + _state = new MouseState(new ButtonReadOnlyList(_buttons), + new InputReadOnlyList(_points), Vector2.Zero); + float x = 0, y = 0; + var buttonMask = NativeBackend.GetMouseState(x.AsRef(), y.AsRef()); + var pos = new Vector2(x, y); + + for (var i = 0; i < EnumInfo.UniqueValues.Count; i++) + { + var button = EnumInfo.UniqueValues[i]; + var pressed = IsPointerButtonPressedSdl(button, buttonMask); + _buttons.Add(new Button(button, pressed, pressed ? 1.0f : 0.0f)); + } + + var window = NativeBackend.GetMouseFocus(); + var pressure = _state.Buttons[PointerButton.Primary].Pressure; + AddTargetPoint(window, pos, pressure); + + // add unbounded target + // var point = _unboundedPointerTarget.GetPoint(this, 0); + _targetListNoWindow = [unboundedPointerTarget]; + _targetListWithWindow = [null!, unboundedPointerTarget]; + } + + private void AddTargetPoint(WindowHandle window, Vector2 pos, float pressure) + { + if (!Backend.TryGetPointerTargetForWindow(window, out var windowTarget)) + { + AddUnboundedPoint(pos, pressure); + } + else + { + AddWindowPoint(pos, pressure, windowTarget); + } + } + + private void AddTargetPoint(uint windowId, Vector2 pos, float pressure) + { + if (!Backend.TryGetPointerTargetForWindow(windowId, out var windowTarget)) + { + AddUnboundedPoint(pos, pressure); + } + else + { + AddWindowPoint(pos, pressure, windowTarget); + } + } + + private void AddUnboundedPoint(Vector2 pos, float pressure) => + // add raw position (likely just 0, but that's ok for now) + _points.Add( + new TargetPoint(0, // todo: use a unique id + Flags: TargetPointFlags.NotPointingAtTarget, + Position: new Vector3(pos, 0), + NormalizedPosition: default, + Pointer: default, + Pressure: pressure, + Target: null + ) + ); + + private void AddWindowPoint(Vector2 pos, float pressure, IPointerTarget windowTarget) + { + var bounds = windowTarget.Bounds; + var min = new Vector2(bounds.Min.X, bounds.Min.Y); + var max = new Vector2(bounds.Max.X, bounds.Max.Y); + + _points.Add( + new TargetPoint( + Id: 0, // todo - use a unique id + Flags: TargetPointFlags.PointingAtTarget, + Position: new Vector3(pos, 0), + NormalizedPosition: new Vector3((pos - min) / (max - min), 0), + Pointer: default, + Pressure: pressure, + Target: windowTarget + )); + } + + public static unsafe SdlSharedMouse CreateDevice(uint sdlDeviceId, SdlInputBackend backend) + { + var deviceName = backend.Sdl.GetMouseNameForID(sdlDeviceId); + nint uniqueId = 0; + if (!backend.AttemptUniqueId(deviceName, ref uniqueId)) + { + uniqueId = backend.FallbackUniqueId(sdlDeviceId, uniqueId); + } + + backend.Sdl.Free(deviceName); + return new SdlSharedMouse(sdlDeviceId, uniqueId, backend); + } + + public override string Name => $"{Backend.Name}: Shared/Global Mouse"; + + protected override void Release() + { + } + + MouseState IMouse.State => _state; + + public override unsafe IReadOnlyList Targets + { + get + { + if (_mouseWindowId == 0) + { + return _targetListNoWindow; + } + + if (!Backend.TryGetPointerTargetForWindow(_mouseWindowId, out var target)) + { + return _targetListNoWindow; + } + + _targetListWithWindow[0] = target; + return _targetListWithWindow; + } + } + + public ICursorConfiguration Cursor => Backend; + + public bool TrySetPosition(Vector2 position) + { + if (NativeBackend.WarpMouseGlobal(position.X, position.Y)) + { + return true; + } + + NativeBackend.ClearError(); + return false; + } + + public override PointerState State => _state; + + public void AddMotion(in MouseMotionEvent evtMotion) + { + _mouseWindowId = evtMotion.WindowID; + var movementRelative = new Vector2(evtMotion.Xrel, evtMotion.Yrel); + _accumulatedMotion += movementRelative; + + // add clear old point, add new point + _points.Clear(); + AddTargetPoint(_mouseWindowId, _accumulatedMotion, 0); + } + + public void AddButtonEvent(in MouseButtonEvent evtButton) + { + var button = PointerButton.Primary + evtButton.Button; + var idx = EnumInfo.ValueIndexOfUnnamed(button); + const float mult = 1 / 255f; + _buttons[idx] = new Button(button, evtButton.Down > 0, evtButton.Down * mult); + } + + public void AddWheelEvent(in MouseWheelEvent evtWheel) + { + _mouseScroll[0] += evtWheel.X; + _mouseScroll[1] += evtWheel.Y; + + var hMagnitude = MathF.Abs(_mouseScroll[0]); + var vMagnitude = MathF.Abs(_mouseScroll[1]); + + if (hMagnitude >= 1) + { + // horizontal scroll "tick" + _mouseScroll.X = 0; + } + + if (vMagnitude >= 1) + { + // vertical scroll "tick" + _mouseScroll.Y = 0; + } + + // todo - actually do stuff + throw new NotImplementedException(); + } + + private static bool IsPointerButtonPressedSdl(PointerButton button, uint state) + { + var index = EnumInfo.ValueIndexOf(button); + if (index < 0 || index >= 32) + { + return false; + } + + return (state & (1 << index)) != 0; + } + + private uint _mouseWindowId; + private Vector2 _mouseScroll = default; + private Vector2 _accumulatedMotion = default; + private readonly List> _buttons = []; + private readonly List _points = new(); + private readonly IPointerTarget[] _targetListNoWindow; + private readonly IPointerTarget[] _targetListWithWindow; +} diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlTouchScreen.cs b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlTouchScreen.cs new file mode 100644 index 0000000000..859fa76eca --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlTouchScreen.cs @@ -0,0 +1,49 @@ +// 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.Pointers; + +internal class SdlTouchScreen : SdlDevice, ISdlDevice, IPointerDevice +{ + public static SdlTouchScreen CreateDevice(uint sdlDeviceId, SdlInputBackend backend) + { + } + + public bool Equals(IInputDevice? other) + { + throw new NotImplementedException(); + } + + public override string Name + { + get + { + throw new NotImplementedException(); + } + } + + protected override void Release() + { + throw new NotImplementedException(); + } + + public PointerState State + { + get + { + throw new NotImplementedException(); + } + } + + public IReadOnlyList Targets + { + get + { + throw new NotImplementedException(); + } + } + + public SdlTouchScreen(uint sdlDeviceId, nint uniqueId, SdlInputBackend backend) : base(sdlDeviceId, uniqueId, backend) + { + } +} diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlUnboundedMouse.cs b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlUnboundedMouse.cs new file mode 100644 index 0000000000..a48289bd19 --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlUnboundedMouse.cs @@ -0,0 +1,33 @@ +// 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; + +namespace Silk.NET.Input.SDL3.Pointers; + +internal class SdlUnboundedMouse : SdlDevice, IMouse, ISdlDevice +{ + private SdlUnboundedMouse(uint sdlDeviceId, nint silkId, SdlInputBackend backend) : base(backend, silkId, sdlDeviceId) + { + } + + + public MouseState State => throw new NotImplementedException(); + public static SdlUnboundedMouse? CreateDevice(uint sdlDeviceId, SdlInputBackend backend) + { + + } + + public override string Name => NativeBackend.GetMouseNameForID(SdlDeviceId).ReadToString(); + + public ICursorConfiguration Cursor => Backend; + + public bool TrySetPosition(Vector2 position) => throw new NotImplementedException(); + + public IReadOnlyList Targets => throw new NotImplementedException(); + + protected override void Release() + { + // nothing? + } +} diff --git a/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlUnboundedPointerTarget.cs b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlUnboundedPointerTarget.cs new file mode 100644 index 0000000000..1cee560d8e --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/Devices/Pointers/SdlUnboundedPointerTarget.cs @@ -0,0 +1,50 @@ +// 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.Maths; + +namespace Silk.NET.Input.SDL3.Pointers; + +internal class SdlUnboundedPointerTarget(SdlInputBackend backend) : IPointerTarget +{ + private static readonly Box3D _bounds = new( + float.MinValue, + float.MinValue, + float.MinValue, + float.MaxValue, + float.MaxValue, + float.MaxValue + ); + + public Box3D Bounds => _bounds; + + public int GetPointCount(IPointerDevice pointer) => IsValidDevice(pointer) ? pointer.State.Points.Count : 0; + + public TargetPoint GetPoint(IPointerDevice pointer, int pointIdx) + { + var point = pointer.State.Points[pointIdx]; + var valid = IsValidDevice(pointer); + return new TargetPoint( + Id: point.Id, // todo : follow spec with unique ids + Flags: valid ? TargetPointFlags.PointingAtTarget : TargetPointFlags.NotPointingAtTarget, + Position: point.Position, + NormalizedPosition: default, + Pointer: new Ray3D(), + Pressure: point.Pressure is > 1 or < 0 + ? point.Pressure is 0 + ? 0 + : 1 + : point.Pressure, + Target: this + ); + } + + private bool IsValidDevice(IPointerDevice pointer) + { + // todo - do we really want to limit this to SDL devices? or to our specific sdl backend? + // i dont think so, but.... technically... + + var device = pointer as SdlDevice; + return device is not null && device.Backend == backend && (device.Backend.Mode & CursorModes.Unbounded) != 0; + } +} diff --git a/sources/Input/Input/Implementations/SDL3/Devices/SdlDevice.cs b/sources/Input/Input/Implementations/SDL3/Devices/SdlDevice.cs new file mode 100644 index 0000000000..acdd557ad4 --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/Devices/SdlDevice.cs @@ -0,0 +1,66 @@ +// 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.Input.SDL3.Pointers; +using Silk.NET.SDL; + +namespace Silk.NET.Input.SDL3; + +/// +/// A base class for all SDL input devices. +/// +internal abstract class SdlDevice : IInputDevice, IDisposable +{ + bool IEquatable.Equals(IInputDevice? other) => + other?.GetType() == GetType() + && other.Id == Id + && other is SdlBoundedPointerDevice dev + && dev.NativeBackend == NativeBackend; + + public nint Id { get; } + + public virtual uint SdlDeviceId { get; } + + public SdlInputBackend Backend { get; } + + /// + /// For readability and refactorability - provides the SDL interface instance. + /// + protected ISdl NativeBackend => Backend.Sdl; + + public abstract string Name { get; } + + protected SdlDevice(SdlInputBackend backend, nint uniqueId, uint sdlDeviceId) + { + Backend = backend; + Id = uniqueId; + SdlDeviceId = sdlDeviceId; + } + + protected abstract void Release(); + + public void Dispose() + { + ObjectDisposedException.ThrowIf(_isDisposed, GetType()); + _isDisposed = true; + Release(); + #if DEBUG + if (!Backend.DeviceRegistry.Remove(Id)) + { + Console.Error.WriteLine($"Failed to remove device {Id} from registry"); + } + #else + Backend.DeviceRegistry.Remove(Id); + #endif + + GC.SuppressFinalize(this); + } + + ~SdlDevice() + { + _isDisposed = true; + Release(); + } + + private bool _isDisposed; +} diff --git a/sources/Input/Input/Implementations/SDL3/Devices/SdlKeyboard.cs b/sources/Input/Input/Implementations/SDL3/Devices/SdlKeyboard.cs new file mode 100644 index 0000000000..7e4f3f84ac --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/Devices/SdlKeyboard.cs @@ -0,0 +1,263 @@ +// 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.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Silk.NET.Input.KeyHandling; +using Silk.NET.SDL; + +namespace Silk.NET.Input.SDL3; + +internal class SdlKeyboard : SdlDevice, IKeyboard, ISdlDevice +{ + public KeyboardState State { get; } + public override string Name => NativeBackend.GetKeyboardNameForID(SdlDeviceId).ReadToString(); + public string? ClipboardText + { + get => NativeBackend.HasClipboardText() ? NativeBackend.GetClipboardText().ReadToString() : null; + set => NativeBackend.SetClipboardText(value); + } + + public static SdlKeyboard CreateDevice(uint sdlDeviceId, SdlInputBackend backend) + { + var namePtr = backend.Sdl.GetKeyboardNameForID(sdlDeviceId); + nint uniqueId = 0; + if (backend.AttemptUniqueId(namePtr, ref uniqueId)) + { + return new SdlKeyboard(sdlDeviceId, uniqueId, backend); + } + + uniqueId = backend.FallbackUniqueId(sdlDeviceId, uniqueId); + return new SdlKeyboard(sdlDeviceId, uniqueId, backend); + } + + private SdlKeyboard(uint sdlDeviceId, nint uniqueId, SdlInputBackend backend) : base(backend, uniqueId, sdlDeviceId) + { + _modState = NativeBackend.GetModState(); + _keyStates = new ButtonStates(); + + State = new KeyboardState( + keys: _keyStates, + capsLockActive: () => (_modState & Sdl.KmodCaps) == Sdl.KmodCaps, + numLockActive: () => (_modState & Sdl.KmodNum) == Sdl.KmodNum); + } + + + protected override void Release() + { + } + + public bool TryGetKeyName(KeyName key, [NotNullWhen(true)] out string? name) + { + // todo: should 'asKeyEvent' be true? + var sdlKey = SdlKeyConversions.KeyNameToSdl(key, NativeBackend, true, _modState); + var namePtr = NativeBackend.GetKeyName(sdlKey); + name = namePtr.ReadToString(); + return !string.IsNullOrWhiteSpace(name); + } + + + // todo - there should be a backend-independent way to do this text input handling via KeyboardState? + public void BeginInput() + { + var sdlWindow = Backend.FocusedWindow; + if (sdlWindow != null && NativeBackend.StartTextInput(sdlWindow.Value)) + { + BeginRecordingSdl(sdlWindow.Value); + } + else + { + _textIsRecording = TextRecorderState.RecordingNoSdl; + } + } + + private void BeginRecordingSdl(WindowHandle sdlWindow) + { + _textIsRecording = TextRecorderState.RecordingSdl; + _textEntryWindow = sdlWindow; + } + + public unsafe string? EndInput() + { + switch (_textIsRecording) + { + case TextRecorderState.None: + return null; + case TextRecorderState.RecordingNoSdl: + _textIsRecording = TextRecorderState.None; + break; + case TextRecorderState.RecordingSdl: + _textIsRecording = TextRecorderState.None; + var sdlWindow = _textEntryWindow; + if (sdlWindow != null) + { + NativeBackend.StopTextInput(sdlWindow.Value); + } + break; + } + _textIsRecording = TextRecorderState.None; + return _textRecorder?.ConsumeInput(); + } + + /// + /// Updates the internal modifier state. + /// + /// + /// This should be called every frame the keyboard is updated in . + /// This mod state is purely used for sdl-related calls and modifiers that are independent of key state (e.g. numlock, caps lock) + /// - otherwise, we handle the modifier states with our standard key handling logic + /// + public void UpdateModState() => _modState = NativeBackend.GetModState(); + + public void AddKeyEvent(in KeyboardEvent key) + { + var keyName = SdlKeyConversions.ScancodeToKeyName(key.Scancode); // SdlToKeyName(key.Which); + + if (ButtonStates.IsDefined(keyName)) + { + var isDown = key.Down != 0; + var button = _keyStates[keyName]; + var stateChanged = button.IsDown != isDown; + _keyStates.SetKeyState(keyName, key.Down); + + var shouldRecord = _textIsRecording == TextRecorderState.RecordingNoSdl + && ((stateChanged && isDown) || (!stateChanged && key.Repeat != 0)); + if (shouldRecord) + { + _textRecorder ??= new TextRecorder(null); + _textRecorder.AddKeyStroke(keyName, this); + } + } + } + + public unsafe void AddTextEditingEvent(in TextEditingEvent evt) + { + if (_textEntryWindow == null) + { + var windowHandle = NativeBackend.GetWindowFromID(evt.WindowID); + if (windowHandle.Handle != null) + { + Console.Out.WriteLine("Unexpected text editing event"); + BeginRecordingSdl(windowHandle); + } + } + else if (evt.WindowID != NativeBackend.GetWindowID(_textEntryWindow.Value)) + { + Console.Error.WriteLine("Received text editing event for a different window than the " + + "one we're recording text for."); + } + + _textRecorder ??= new TextRecorder(null); + + if (evt.Length == 0) + { + _textRecorder.SetSelection(evt.Start, 0); + } + else + { + if (evt.Text == null) + { + return; + } + + _textRecorder.InsertTextAt(evt.Text, evt.Start, evt.Length); + } + } + + public unsafe void AddTextCandidatesEvent(in TextEditingCandidatesEvent evt) + { + if (evt.SelectedCandidate == -1 || evt.NumCandidates == 0) + { + return; + } + + Debug.Assert(evt.NumCandidates > evt.SelectedCandidate); + + var candidate = new Ptr(evt.Candidates[evt.SelectedCandidate]); + var str = candidate.ReadToString(); + _textRecorder ??= new TextRecorder(null); + _textRecorder.InsertText(str); + } + + public unsafe void AddTextInputEvent(in TextInputEvent evt) + { + if (_textEntryWindow == null) + { + var windowHandle = NativeBackend.GetWindowFromID(evt.WindowID); + if (windowHandle.Handle != null) + { + Console.Out.WriteLine("Unexpected text input event"); + BeginRecordingSdl(windowHandle); + } + } + else if (evt.WindowID != NativeBackend.GetWindowID(_textEntryWindow.Value)) + { + Console.Error.WriteLine("Received text input event for a different window than the " + + "one we're recording text for."); + } + + + var str = evt.Text == null ? "" : new Ptr(evt.Text).ReadToString(); + + _textRecorder ??= new TextRecorder(null); + _textRecorder.InsertText(str); + } + + + private WindowHandle? _textEntryWindow; + private TextRecorder? _textRecorder; + private enum TextRecorderState {None, RecordingNoSdl, RecordingSdl} + private TextRecorderState _textIsRecording; + private ushort _modState; + private const float _pressureMultiplier = 1f / 255f; + private readonly ButtonStates _keyStates; + + private class ButtonStates : IReadOnlyList> + { + private static readonly int _keyCount; + private readonly byte[] _keyPressures = new byte[_keyCount]; + private static readonly int[] _indices; + + static ButtonStates() + { + _indices = new int[512]; + for (var i = 0; i < 512; i++) + { + _indices[i] = Enum.IsDefined((KeyName)i) ? _keyCount++ : -1; + } + } + + public int SetKeyState(KeyName key, byte pressure) => _keyPressures[_indices[(int)key]] = pressure; + + public IEnumerator> GetEnumerator() + { + for (var i = 0; i < _keyCount; i++) + { + var index = _indices[i]; + if(index != -1) + { + yield return GetButton(index); + } + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public int Count => _keyCount; + + public Button this[int index] => GetButton(index); + public Button this[KeyName key] => GetButton((int)key); + + private Button GetButton(KeyName key) => GetButton((int)key); + private Button GetButton(int key) + { + var keyIdx = _indices[key]; + return CreateButton((KeyName)key, _keyPressures[keyIdx]); + } + + private Button CreateButton(KeyName key, byte pressure) => new(key, pressure > 0, pressure * _pressureMultiplier); + + public static bool IsDefined(KeyName keyName) => _indices[(int)keyName] >= 0; + } +} diff --git a/sources/Input/Input/Implementations/SDL3/InputWindowExtensions.cs b/sources/Input/Input/Implementations/SDL3/InputWindowExtensions.cs new file mode 100644 index 0000000000..bd515dc85e --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/InputWindowExtensions.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// ReSharper disable CheckNamespace + +using Silk.NET.Input.SDL3; +using Silk.NET.SDL; + +namespace Silk.NET.Input; + +/// +/// Contains extensions for creating input backends and contexts from s. +/// +public static partial class InputWindowExtensions +{ + public static partial IInputBackend CreateInputBackend(this INativeWindow window) + { + if (!window.TryGetPlatformInfo(out SdlPlatformInfo info)) + { + throw new ArgumentException( + "When using the Silk.NET.Input reference implementation, a native window compatible with that " + + "implementation (such as those sourced from Silk.NET.Windowing) must be used." + ); + } + + return new SdlInputBackend(info); + } +} diff --git a/sources/Input/Input/Implementations/SDL3/SdlInputBackend.cs b/sources/Input/Input/Implementations/SDL3/SdlInputBackend.cs new file mode 100644 index 0000000000..170278129f --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/SdlInputBackend.cs @@ -0,0 +1,591 @@ +// 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.Runtime.CompilerServices; +using Silk.NET.Input.SDL3.Joysticks; +using Silk.NET.Input.SDL3.Pointers; +using Silk.NET.Maths; +using Silk.NET.SDL; + +namespace Silk.NET.Input.SDL3; + +internal class SdlInputBackend : IInputBackend, ICursorConfiguration +{ + private static readonly double _ticksPerNanosecond = Stopwatch.Frequency / 10e9d; + + private bool _pumped; + private long _epoch; + private List _devices = []; + private readonly EventQueue _pumpedEvents = new(); + private WindowHandle _focusedWindow; + private ISdl _sdl; + + public unsafe WindowHandle? FocusedWindow => _focusedWindow.Handle == null ? null : _focusedWindow; + + public unsafe SdlInputBackend(SdlPlatformInfo info) + { + ArgumentNullException.ThrowIfNull(info.Sdl); + ArgumentNullException.ThrowIfNull(info.Window.Handle); + var ptr = new EventFilter(OnEvent); + _sdl = info.Sdl; + _focusedWindow = info.Window; + // TODO overload resolution priority? + if (!Sdl.AddEventWatch(ptr, (Ref)nullptr)) + { + Sdl.ThrowError(); + } + + Id = (nint)ptr.Handle; + + // The epoch deals in nanoseconds, so we take multiple measurements for the most accurate timestamps. + const byte epochMeasurements = 3; + var epoch = 0L; + for (byte i = 0; i < epochMeasurements; i++) + { + // We know the ticks per nanosecond, so to get the epoch timestamp we multiply the TicksNS by the ticks per + // nanosecond to get the number of ticks relative to SDL's epoch, and then subtract that from the timestamp + // now to get the timestamp of SDL's epoch. From there, when we receive an event we can just report the + // timestamp as _epoch + (timestamp * _ticksPerNanosecond). + var nowTimestamp = Stopwatch.GetTimestamp(); + var nowTicks = Sdl.GetTicksNS(); + epoch += unchecked(nowTimestamp - (long)(nowTicks * _ticksPerNanosecond)); + } + + _epoch = epoch / epochMeasurements; + + // =============================================================================================== + // === If we ever need to share common state across window-specific "backends", use the below: === + // =============================================================================================== + // // Get the root surface - our windowing backend assumes there is only one root surface. If this is not the + // // case then this is undefined behaviour. + // var rootSurface = info.Window; + // var parent = rootSurface; + // while ((parent = Sdl.GetWindowParent(rootSurface)) != nullptr) + // { + // rootSurface = parent; + // } + // // Get the surface properties. + // var props = Sdl.GetWindowProperties(rootSurface); + // if (props == 0) + // { + // Sdl.ThrowError(); + // } + // // Get or create the root object. + // Ref pname = "org.dotnetfoundation.silkdotnet.inputroot"; + // var root = (nint)Sdl.GetPointerProperty(props, pname, nullptr); + // if (root != 0) + // { + // Root = + // GCHandle.FromIntPtr(root).Target as SdlBackendRoot + // ?? throw new InvalidOperationException( + // "The global input data for this ancestry of SDL windows was not in an expected format." + // ); + // } + // else + // { + // Root = new SdlBackendRoot(); + // var newHandle = GCHandle.Alloc(Root); + // if ( + // Sdl.SetPointerPropertyWithCleanup( + // props, + // pname, + // (Ptr)GCHandle.ToIntPtr(newHandle), + // new CleanupPropertyCallback(&CleanupRoot), + // nullptr + // ) + // ) + // { + // return; + // } + // newHandle.Free(); + // Sdl.ThrowError(); + // } + // // Register ourselves with the root. + // Root.Backends.Add(this, null); + // Id = (nint)Root.EventFilter.Handle + Root.Backends.Count() - 1; + } + + // [UnmanagedCallersOnly] + // private static unsafe void CleanupRoot(void* _, void* value) + // { + // var gch = GCHandle.FromIntPtr((nint)value); + // (gch.Target as SdlBackendRoot)?.Dispose(); + // gch.Free(); + // } + // public SdlBackendRoot Root { get; } + + // NOTE: Be careful where these are used! + public SdlPlatformInfo Info { get; } + + [field: MaybeNull] + public SdlUnboundedPointerTarget UnboundedPointerTarget => + field ??= new SdlUnboundedPointerTarget(this); + + public ISdl Sdl => Info.Sdl ?? SDL.Sdl.Instance; + + public string Name => + $"Silk.NET.Input Reference Implementation using SDL3 ({Sdl.GetPlatform().ReadToString()})"; + + public nint Id { get; } + + public IReadOnlyList Devices => _devices; + + // TODO we can't query support for these modes, but should we try-it-and-see to be accurate? + public CursorModes SupportedModes => + CursorModes.Normal | CursorModes.Confined | CursorModes.Unbounded; + + // TODO if you're using one input context for all windows, there is no way to specify a window for grabbed cursor mode + + public CursorModes Mode + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public CursorStyles SupportedStyles => throw new NotImplementedException(); + + public CursorStyles Style + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public CustomCursor Image + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public HashSet DeviceRegistry { get; } = []; + + // This is complicated, as the input proposal mandates that nothing happens until Update is called (so the events + // can be received on the given actor) but to also track logical events that happen between calls (i.e. from a + // timestamp perspective). Compound this with the fact that the user might do something silly like make multiple + // input backends (which is feasible for multiple windows I guess), or not be running anything other than input + // (having obviously created a window beforehand but not actually polling events I guess) + public void Update(IInputHandler? handler = null) + { + if (!_pumped) + { + Sdl.PumpEvents(); + } + + _pumped = false; + if (handler == null) + { + _pumpedEvents.Clear(); + return; + } + + // process all events that have been queued? + + while (_pumpedEvents.TryDequeue(out var evt)) + { + ProcessEvent(in evt, handler); + } + + foreach (var device in _devices) + { + if (device is SdlKeyboard keyboard) + { + keyboard.UpdateModState(); + } + } + } + + private enum QueuedEventType : byte + { + /// + /// The mouse has exited the window and the shared point should be marked inactive until proven otherwise by + /// further mouse motion (indicating it has entered another window). + /// + /// + /// We do not track the mouse enter events as this would cause us to fire twice for a mouse entering a window: + /// once for the entering, and once for new position. + /// + MouseExitedWindow, + + /// + /// The display bounds have been changed, meaning that 's + /// will have changed. + /// + BoundedPointerTargetUpdate, + } + + private ulong GetTimestamp(in Event @event) => + unchecked((ulong)(_epoch + (@event.Common.Timestamp * _ticksPerNanosecond))); + + private unsafe byte OnEvent(void* arg0, Event* arg1) + { + _pumped = true; + _pumpedEvents.Add(ref *arg1); + return 1; + } + + private void ProcessEvent(in Event evt, IInputHandler handler) + { + var timestamp = GetTimestamp(in evt); + Debug.Assert(timestamp >= _previousTimestamp, "Events out of order"); + _previousTimestamp = timestamp; + + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + var type = (EventType)evt.Common.Type; + + switch (type) + { + case EventType.GamepadRemoved: + RemoveDevice(_devices, evt.Gdevice.Which); + return; + case EventType.JoystickRemoved: + RemoveDevice(_devices, evt.Jdevice.Which); + return; + case EventType.KeyboardRemoved: + RemoveDevice(_devices, evt.Kdevice.Which); + return; + case EventType.MouseRemoved: + RemoveDevice(_devices, evt.Mdevice.Which); + RemoveDevice(_devices, evt.Mdevice.Which); + return; + case >= EventType.KeyDown and <= EventType.TextEditingCandidates: + { + if (!TryGetOrCreateDevice(evt.Kdevice.Which, out var keyboard)) + { + return; + } + + if (type == EventType.KeyboardAdded) + { + return; + } + + switch (type) + { + case EventType.KeyDown: + case EventType.KeyUp: + keyboard.AddKeyEvent(evt.Key); + break; + case EventType.TextEditing: + keyboard.AddTextEditingEvent(evt.Edit); + break; + case EventType.TextEditingCandidates: + keyboard.AddTextCandidatesEvent(evt.EditCandidates); + break; + case EventType.TextInput: + keyboard.AddTextInputEvent(evt.Text); + break; + } + + break; + } + case >= EventType.GamepadAxisMotion and <= EventType.GamepadSteamHandleUpdated: + { + if (!TryGetOrCreateDevice(evt.Gdevice.Which, out var gamepad)) + { + return; + } + + if (type is EventType.GamepadAdded) + { + return; + } + + switch (type) + { + case EventType.GamepadAxisMotion: + gamepad.AddAxisEvent(evt.Gaxis.Axis, evt.Gaxis.Value); + break; + case EventType.GamepadButtonDown: + case EventType.GamepadButtonUp: + gamepad.AddButtonEvent(evt.Gbutton.Button, evt.Gbutton.Down); + break; + case EventType.GamepadRemapped: + gamepad.Remap(); + break; + case EventType.GamepadTouchpadDown: + break; + case EventType.GamepadTouchpadMotion: + break; + case EventType.GamepadTouchpadUp: + break; + case EventType.GamepadSensorUpdate: + break; + case EventType.GamepadUpdateComplete: + break; + case EventType.GamepadSteamHandleUpdated: + break; + } + + break; + } + case >= EventType.JoystickAxisMotion and <= EventType.JoystickUpdateComplete: + { + if (!TryGetOrCreateDevice(evt.Jdevice.Which, out var joystick)) + { + return; + } + + if (type is EventType.JoystickAdded) + { + // already done + return; + } + + switch (type) + { + case EventType.JoystickAxisMotion: + joystick.AddAxisEvent(evt.Jaxis.Axis, evt.Jaxis.Value); + break; + case EventType.JoystickBallMotion: + break; + case EventType.JoystickHatMotion: + joystick.AddHatEvent(evt.Jhat.Hat, evt.Jhat.Value); + break; + case EventType.JoystickButtonDown: + case EventType.JoystickButtonUp: + joystick.AddButtonEvent(evt.Jbutton.Button, evt.Jbutton.Down); + break; + case EventType.JoystickBatteryUpdated: + break; + case EventType.JoystickUpdateComplete: + break; + } + break; + } + case >= EventType.MouseMotion and <= EventType.MouseAdded: + { + if(!TryGetOrCreateDevice(evt.Mdevice.Which, out var mouse)) + { + return; + } + + if (type is EventType.MouseAdded) + { + return; + } + + switch (type) + { + case EventType.MouseMotion: + mouse.AddMotion(evt.Motion); + break; + case EventType.MouseButtonDown: + case EventType.MouseButtonUp: + mouse.AddButtonEvent(evt.Button); + break; + case EventType.MouseWheel: + mouse.AddWheelEvent(evt.Wheel); + break; + } + + break; + } + } + + switch (type) + { + // Input events ---------------------------------------------------------- + + // sensor? for what? + case EventType.SensorUpdate: + break; + + // ----- Pointer events + + // mouse + case EventType.MouseMotion: + break; + case EventType.MouseButtonDown: + break; + case EventType.MouseButtonUp: + break; + case EventType.MouseWheel: + break; + + // touch + case EventType.FingerDown: + break; + case EventType.FingerUp: + break; + case EventType.FingerMotion: + break; + case EventType.FingerCanceled: + break; + + // pen + case EventType.PenProximityIn: + break; + case EventType.PenProximityOut: + break; + case EventType.PenDown: + break; + case EventType.PenUp: + break; + case EventType.PenButtonDown: + break; + case EventType.PenButtonUp: + break; + case EventType.PenMotion: + break; + case EventType.PenAxis: + break; + + // Display & window (pointer target) events ---------------------------- + case EventType.DisplayOrientation: + case EventType.DisplayAdded: + case EventType.DisplayRemoved: + case EventType.DisplayMoved: + case EventType.DisplayDesktopModeChanged: + case EventType.DisplayCurrentModeChanged: + case EventType.DisplayContentScaleChanged: + { + var bounds = SdlBoundedPointerTarget.CalculateBounds(Sdl); + var x = (QueuedEventType.BoundedPointerTargetUpdate, + timestamp, + bounds.Min.ToSystem(), + bounds.Max.ToSystem() + ); + break; + } + case EventType.WindowMouseLeave + : // do we need to do anything? we should probably track the current window of the pointer + { + var x = (QueuedEventType.MouseExitedWindow, timestamp); + break; + } + + // Text input events ------------------------------------------- + // todo: attribute this to a keyboard device? or something else? + case EventType.TextEditing: + break; + case EventType.TextInput: + break; + case EventType.TextEditingCandidates: + break; + case EventType.ClipboardUpdate: + break; + } + } + + internal bool TryGetOrCreateDevice(uint id, [NotNullWhen(true)] out T? device) where T : SdlDevice, ISdlDevice + { + // If we already have a device with this ID, return it. + for (var i = 0; i < _devices.Count; i++) + { + if (_devices[i] is T typedDevice && typedDevice.SdlDeviceId == id) + { + device = typedDevice; + return true; + } + } + + try + { + device = T.CreateDevice(id, this); + } + catch (Exception e) + { + Console.Error.WriteLine($"Failed to create device {nameof(T)} with id '{id}': {e}"); + device = null; + return false; + } + + if (device is null) + { + Console.Error.WriteLine($"Failed to create device {nameof(T)} with id '{id}'"); + return false; + } + + + _devices.Add(device); + Console.WriteLine($"Gamepad added: (sdl ID: {id})"); + return true; + } + + private static bool RemoveDevice(List devices, uint id) + { + var deviceIdx = devices.FindIndex(x => x is T && x.SdlDeviceId == id); + + if (deviceIdx == -1) + { + // we never used this device to begin with, so just ignore its removal + return false; + } + + var device = devices[deviceIdx]; + device.Dispose(); + devices.RemoveAt(deviceIdx); + + // device IDs may have changed when a device was removed, so we need to refresh them + RefreshDeviceIds(devices); + return true; + } + + private static void RefreshDeviceIds(List devices) + { + for (var i = 0; i < devices.Count; i++) + { + if (devices[i] is IOrderedDevice d) + { + d.RefreshSdlId(); + } + } + } + + private ulong _previousTimestamp = ulong.MinValue; + + private unsafe void ReleaseUnmanagedResources() + { + Sdl.RemoveEventWatch( + new EventFilter((delegate* unmanaged)(void*)Id), + nullptr + ); + SilkMarshal.Free((Ptr)Id); + } + + public void Dispose() + { + ReleaseUnmanagedResources(); + GC.SuppressFinalize(this); + } + + ~SdlInputBackend() => ReleaseUnmanagedResources(); + + private class EventQueue + { + private readonly Queue _events = new(1024); + public void Add(ref Event p0) => _events.Enqueue(p0); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryDequeue(out Event p0) => _events.TryDequeue(out p0); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Clear() => _events.Clear(); + } + + internal unsafe bool TryGetPointerTargetForWindow(WindowHandle window, [NotNullWhen(true)] out IPointerTarget? target) + { + if (window.Handle == null) + { + target = null; + return false; + } + + var id = _sdl.GetWindowID(window); + return TryGetPointerTargetForWindow(id, out target); + } + + internal bool TryGetPointerTargetForWindow(uint id, [NotNullWhen(true)] out IPointerTarget? target) + { + if (id == 0) + { + target = null; + return false; + } + + // todo : get the SDL window (or other window) with the given ID + target = null; + throw new NotImplementedException(); + } +} diff --git a/sources/Input/Input/Implementations/SDL3/SdlKeyConversions.cs b/sources/Input/Input/Implementations/SDL3/SdlKeyConversions.cs new file mode 100644 index 0000000000..fdcf6cb689 --- /dev/null +++ b/sources/Input/Input/Implementations/SDL3/SdlKeyConversions.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; +using Silk.NET.SDL; + +namespace Silk.NET.Input.SDL3; + +internal static class SdlKeyConversions +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static KeyName ScancodeToKeyName(uint scancode) => (KeyName)scancode; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static KeyName ScancodeToKeyName(Scancode scancode) => ScancodeToKeyName((uint)scancode); + + public static unsafe KeyName SdlToKeyName(uint key, ISdl sdl, ushort? modState = null) + { + modState ??= sdl.GetModState(); + var modStateVal = modState.Value; + return (KeyName)sdl.GetScancodeFromKey(key, &modStateVal); + } + + /// + /// Maps an SDL key id to a without a reference to an SDL backend instance. + /// + /// The sdl key id + /// The associated key name + public static KeyName SdlToKeyName(uint key) => + // * indicates a shifted key + key switch { + Sdl.KApplication => KeyName.Application, + + >= Sdl.K1 and <= Sdl.K9 => (KeyName)(key - _numKeyDiff), + >= Sdl.Ka and <= Sdl.Kz => (KeyName)(key - _letterKeyDiff), + >= Sdl.KCapslock and <= Sdl.KKpEqualsas400 => (KeyName)(key - _systemAndKeypadDiff), + >= Sdl.KCancel and <= Sdl.KRgui => (KeyName)(key - _systemAndKeypadDiff), + >= Sdl.KMode and <= Sdl.KAcBookmarks => (KeyName)(key - _systemAndKeypadDiff), + >= Sdl.KSoftleft and <= Sdl.KEndcall => (KeyName)(key - _systemNonHidKeyDiff), + + Sdl.KDelete => KeyName.Delete, + Sdl.KUnknown => KeyName.Unknown, + Sdl.KReturn => KeyName.Return, + Sdl.KEscape => KeyName.Escape, + Sdl.KBackspace => KeyName.Backspace, + Sdl.KTab => KeyName.Tab, + Sdl.KSpace => KeyName.Space, + Sdl.KExclaim => KeyName.Number1, // * + Sdl.KDblapostrophe => KeyName.Apostrophe, // * + Sdl.KHash => KeyName.Number3, // * + Sdl.KDollar => KeyName.Number4, // * + Sdl.KPercent => KeyName.Number5, // * + Sdl.KAmpersand => KeyName.KeypadAmpersand, + Sdl.KApostrophe => KeyName.Apostrophe, + Sdl.KLeftparen => KeyName.KeypadLeftParenthesis, + Sdl.KRightparen => KeyName.KeypadRightParenthesis, + Sdl.KAsterisk => KeyName.KeypadAmpersand, + Sdl.KPlus => KeyName.Equals, // * + Sdl.KComma => KeyName.Comma, + Sdl.KMinus => KeyName.Minus, + Sdl.KPeriod => KeyName.Period, + Sdl.KSlash => KeyName.Slash, + Sdl.K0 => KeyName.Number0, + Sdl.KColon => KeyName.Semicolon, // * + Sdl.KSemicolon => KeyName.Semicolon, + Sdl.KLess => KeyName.Comma, // * + Sdl.KEquals => KeyName.Equals, + Sdl.KGreater => KeyName.Period, // * + Sdl.KQuestion => KeyName.Slash, // * + Sdl.KAt => KeyName.Number2, // * + Sdl.KLeftbracket => KeyName.LeftBracket, + Sdl.KBackslash => KeyName.Backslash, + Sdl.KRightbracket => KeyName.RightBracket, + Sdl.KCaret => KeyName.Number6, // * + Sdl.KUnderscore => KeyName.Minus, // * + Sdl.KGrave => KeyName.Grave, + Sdl.KLeftbrace => KeyName.LeftBracket, // * + Sdl.KPipe => KeyName.Backslash, // * + Sdl.KRightbrace => KeyName.RightBracket, // * + Sdl.KTilde => KeyName.Grave, // * + _ => (KeyName)key + }; + + /// + /// Converts a to an SDL key id. + /// + /// The name of the key you would like to get an Sdl key id for + /// Sdl backend instance + /// Will this key be used in a key event? + /// The current modifier key state + /// The sdl key id + public static uint KeyNameToSdl(KeyName key, ISdl sdl, bool asKeyEvent, ushort? modState = null) + { + modState ??= sdl.GetModState(); + var scanCode = (uint)key; + var asKeyEventByte = asKeyEvent ? (byte)1 : (byte)0; + return sdl.GetKeyFromScancode((Scancode)scanCode, modState.Value, asKeyEventByte); + } + + private const uint _letterKeyDiff = Sdl.Ka - (uint)KeyName.A; + private const uint _numKeyDiff = Sdl.K1 - (uint)KeyName.Number1; + private const uint _systemAndKeypadDiff = Sdl.KPrintscreen - (uint)KeyName.PrintScreen; + private const uint _systemNonHidKeyDiff = Sdl.KSoftleft - (uint)KeyName.SoftLeft; +} diff --git a/sources/Input/Input/InputContext.cs b/sources/Input/Input/InputContext.cs new file mode 100644 index 0000000000..402735e702 --- /dev/null +++ b/sources/Input/Input/InputContext.cs @@ -0,0 +1,241 @@ +using System.Collections; + +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 +{ + // 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 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; + } + + 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?.HandleUpdate(); + } + + private void HandleBackendRemoval(IInputBackend backend) + { + foreach (var device in backend.Devices) + { + HandleDeviceConnectionChanged(new ConnectionEvent(device, 0, false)); + } + } + + private void HandleBackendAddition(IInputBackend backend) + { + foreach (var device in backend.Devices) + { + HandleDeviceConnectionChanged(new ConnectionEvent(device, 0, 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 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 => _backends[index] = value; + } +} diff --git a/sources/Input/Input/InputContextDeviceList.cs b/sources/Input/Input/InputContextDeviceList.cs new file mode 100644 index 0000000000..1ae312526b --- /dev/null +++ b/sources/Input/Input/InputContextDeviceList.cs @@ -0,0 +1,61 @@ +// 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.Diagnostics.CodeAnalysis; + +namespace Silk.NET.Input; + +/// +/// An internal class that represents a list of that are assignable to +/// . The backing list is lazily initialized. +/// +/// The device type. +/// +/// This type is not intended for public consumption and has no API/ABI stability guarantees. +/// +[Experimental( + "ST0005", + UrlFormat = "https://dotnet.github.io/Silk.NET/docs/v3/silk.net/diagnostics/{0}" +)] +public abstract class InputContextDeviceList : IReadOnlyList, IInputHandler +{ + private readonly InputContext _ctx; + private List? _list; + + internal InputContextDeviceList(InputContext ctx) => _ctx = ctx; + + private List List => _list ??= _ctx.Devices.OfType().ToList(); + + /// + public IEnumerator GetEnumerator() => throw new NotImplementedException(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + public int Count => List.Count; + + /// + public T this[int index] => List[index]; + + void IInputHandler.HandleDeviceConnectionChanged(ConnectionEvent @event) => + HandleDeviceConnectionChanged(@event); + + /// + protected internal virtual void HandleDeviceConnectionChanged(ConnectionEvent @event) + { + if (_list is null || @event.Device is not T t) + { + return; + } + + if (@event.IsConnected) + { + _list.Add(t); + } + else + { + _list.Remove(t); + } + } +} diff --git a/sources/Input/Input/InputMarshal.cs b/sources/Input/Input/InputMarshal.cs new file mode 100644 index 0000000000..19c7d156c5 --- /dev/null +++ b/sources/Input/Input/InputMarshal.cs @@ -0,0 +1,685 @@ +// 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.Diagnostics; +using System.Numerics; +using System.Runtime.CompilerServices; + +namespace Silk.NET.Input; + +/// +/// Contains utilities for creating and manipulating s. This is a very unsafe set of +/// APIs that are extremely prone to misuse, and therefore is not recommended to be consumed by anyone other than input +/// backends. +/// +/// +/// This class is ABI/API stable, but new APIs that obsolete the old ones may be added at any time as more efficient +/// implementations are discovered. +/// +// NOTE: Not experimental so that we don't eliminate the prospects of third-party implementations. +public static class InputMarshal +{ + /// + /// A wrapper class denoting ownership of a . This is used to attempt to stop + /// misuse of these methods, but of course it's fairly trivial to work around this for a user determined to do + /// terrible things. + /// + /// The list element type. + public struct ListOwner + { + internal ListOwner(InputReadOnlyList list) => List = list; + + /// + /// Gets the list owned by this owner. + /// + public InputReadOnlyList List { get; } + } + + internal class ButtonList(uint[] binary, Dictionary>? other) + : IReadOnlyList> + where T : unmanaged, Enum + { + private Dictionary>? _other = other; + + public ButtonList() + : this(new uint[(GetButtonListCount() + 32 - 1) / 32], null) { } + + public ButtonList Clone() => + new([.. binary], _other is null ? null : new Dictionary>(_other)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] // <-- generic specialisation + public IEnumerator> GetEnumerator() => + typeof(T) == typeof(KeyName) ? GetKeyNameEnumerator() : GetButtonEnumerator(); + + private IEnumerator> GetKeyNameEnumerator() + { + var idx = 0; + var bit = 0; + // To determine the gaps, run the GetButtonCount unit test. The equality check is the LHS from the output + + // 1, and the assignment is the RHS - 1. Example output below: + // 0 (Unknown), 4 (A) + // 129 (VolumeDown), 133 (KeypadComma) + // 164 (ExtendSelect), 176 (Keypad00) + // 221 (KeypadHexadecimal), 224 (ControlLeft) + // 231 (SuperRight), 257 (Mode) + // 286 (ApplicationBookmarks), 501 (SoftLeft) + for (var cur = (int)KeyName.A; cur <= (int)KeyName.EndCall; cur++) + { + switch (cur) + { + case (int)KeyName.VolumeDown + 1: + cur = (int)KeyName.KeypadComma - 1; + continue; + case (int)KeyName.ExtendSelect + 1: + cur = (int)KeyName.Keypad00 - 1; + continue; + case (int)KeyName.KeypadHexadecimal + 1: + cur = (int)KeyName.ControlLeft - 1; + continue; + case (int)KeyName.SuperRight + 1: + cur = (int)KeyName.Mode - 1; + continue; + case (int)KeyName.ApplicationBookmarks + 1: + cur = (int)KeyName.SoftLeft - 1; + continue; + } + + var ret = ElementAt((T)(object)(KeyName)cur, idx, bit); + (idx, bit) = BitIterate(idx, bit); + yield return ret; + } + } + + private IEnumerator> GetButtonEnumerator() + { + var max = GetButtonListCount(); + int idx = 0, + bit = 0; + for (var i = 1; i <= max; i++) + { + var ret = ElementAt(SilkMarshal.ConstCast(i), idx, bit); + (idx, bit) = BitIterate(idx, bit); + yield return ret; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Button ElementAt(T name, int idx, int bit) + { + var ret = new Button(name, false, 0); + var isBinaryDown = BitOperations.PopCount(binary[idx] & (1U << (7 - bit))) > 0; + if (isBinaryDown) + { + ret = ret with { IsDown = true, Pressure = 1 }; + } + else + { + _other?.TryGetValue(name, out ret); + } + + return ret; + } + + [MethodImpl( + MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization + )] + private static (int, int) BitIterate(int idx, int bit) => + ++bit == 32 ? (++idx, 0) : (idx, bit); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public int Count => GetButtonListCount(); + + [MethodImpl( + MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization + )] + internal static T IndexName(int index) + { + var name = index; + if (typeof(T) == typeof(KeyName)) + { + // To determine the gaps, run the GetButtonCount unit test. The condition is to check whether name + // is greater than the LHS, and if so add the RHS less the LHS less 1. Example output: + // 0 (Unknown), 4 (A) + // 129 (VolumeDown), 133 (KeypadComma) + // 164 (ExtendSelect), 176 (Keypad00) + // 221 (KeypadHexadecimal), 224 (ControlLeft) + // 231 (SuperRight), 257 (Mode) + // 286 (ApplicationBookmarks), 501 (SoftLeft) + name += 4; + if (name > 129) + { + name += 133 - 129 - 1; + } + + if (name > 164) + { + name += 176 - 164 - 1; + } + + if (name > 221) + { + name += 224 - 221 - 1; + } + + if (name > 231) + { + name += 257 - 231 - 1; + } + + if (name > 286) + { + name += 501 - 286 - 1; + } + } + else + { + // To account for Unknown = 0. + name++; + } + + return SilkMarshal.ConstCast(name); + } + + [MethodImpl( + MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization + )] + internal static int NameIndex(T name) + { + var index = SilkMarshal.ConstCast(name); + if (typeof(T) == typeof(KeyName)) + { + // To determine the gaps, run the GetButtonCount unit test. The condition is to check whether name + // is greater than the LHS, and if so subtract the RHS less the LHS less 1. Note that the conditions + // should be in reverse order i.e. from the last output line to the first output line. Example output: + // 0 (Unknown), 4 (A) + // 129 (VolumeDown), 133 (KeypadComma) + // 164 (ExtendSelect), 176 (Keypad00) + // 221 (KeypadHexadecimal), 224 (ControlLeft) + // 231 (SuperRight), 257 (Mode) + // 286 (ApplicationBookmarks), 501 (SoftLeft) + if (index > 286) + { + index -= 501 - 286 - 1; + } + + if (index > 231) + { + index -= 257 - 231 - 1; + } + + if (index > 221) + { + index -= 224 - 221 - 1; + } + + if (index > 164) + { + index -= 176 - 164 - 1; + } + + if (index > 129) + { + index -= 133 - 129 - 1; + } + index -= 4; + } + else + { + // To account for Unknown = 0. + index--; + } + + return index; + } + + public Button this[int index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + ArgumentOutOfRangeException.ThrowIfGreaterThan(index, Count); + return ElementAt( + IndexName(index), + Math.DivRem(index, 32, out var remainder), + remainder + ); + } + } + + public Button this[T name] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + var index = NameIndex(name); + if (index >= 0 && index < GetButtonListCount()) + { + return ElementAt(name, Math.DivRem(index, 32, out var remainder), remainder); + } + + Throw(); + return default; + [StackTraceHidden] + static void Throw() => throw new ArgumentOutOfRangeException(nameof(name)); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Set(Button btn, bool isBinary) + { + if (btn.IsDown && isBinary) + { + binary[Math.DivRem(NameIndex(btn.Name), 32, out var bit)] |= 1U << (7 - bit); + _other?.Remove(btn.Name); + } + else + { + binary[Math.DivRem(NameIndex(btn.Name), 32, out var bit)] &= ~(1U << (7 - bit)); + } + + if (!isBinary) + { + (_other ??= [])[btn.Name] = btn; + } + } + } + + /// + /// Gets the reported by s created with + /// for the given . + /// + /// The button name type. + /// + /// The number of buttons that will be in a button list created with , or -1 if + /// is not a supported button name type. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public static int GetButtonListCount() + { + if (typeof(T) == typeof(JoystickButton)) + { + return EnumInfo.UniqueValues.Count; + } + + if (typeof(T) == typeof(PointerButton)) + { + return EnumInfo.UniqueValues.Count; + } + + if (typeof(T) == typeof(KeyName)) + { + // To determine the ranges, run the GetButtonCount unit test. The RHS of the subtraction statements below + // are the RHS of the line output, and the LHS is the LHS of the following line in its output. There is a + // final addition that is the number of preceding additions to account for the boundary values. Example + // output from that test: + // 0 (Unknown), 4 (A) + // 129 (VolumeDown), 133 (KeypadComma) + // 164 (ExtendSelect), 176 (Keypad00) + // 221 (KeypadHexadecimal), 224 (ControlLeft) + // 231 (SuperRight), 257 (Mode) + // 286 (ApplicationBookmarks), 501 (SoftLeft) + // ReSharper disable once ArrangeRedundantParentheses <-- stylistic choice + return ((int)KeyName.VolumeDown - (int)KeyName.A) + + ((int)KeyName.ExtendSelect - (int)KeyName.KeypadComma) + + ((int)KeyName.KeypadHexadecimal - (int)KeyName.Keypad00) + + ((int)KeyName.SuperRight - (int)KeyName.ControlLeft) + + ((int)KeyName.ApplicationBookmarks - (int)KeyName.Mode) + + ((int)KeyName.EndCall - (int)KeyName.SoftLeft) + + 6; + } + + return -1; + } + + /// + /// Creates a wrapping the given button . + /// + /// The list. + /// The button name type. + /// The button list. + [MethodImpl(MethodImplOptions.AggressiveInlining)] // <-- generic specialisation + public static ButtonReadOnlyList AsButtonList(this InputReadOnlyList> list) + where T : unmanaged, Enum => new(list); + + /// + /// Creates a wrapping the given . + /// + /// The list. + /// The button name type. + /// The button list. + public static InputReadOnlyList> AsInputList(this ButtonReadOnlyList list) + where T : unmanaged, Enum => new(list); + + /// + /// Creates a new for the given , optionally with the + /// given where is applicable for this + /// . + /// + /// The capacity. + /// The element type. + /// The list. + [MethodImpl(MethodImplOptions.AggressiveInlining)] // <-- generic specialisation + public static ListOwner CreateList(int capacity = 0) + { + if (typeof(T) == typeof(Button)) + { + return (ListOwner) + (object) + new ListOwner>( + new InputReadOnlyList>((object)new ButtonList()) + ); + } + + if (typeof(T) == typeof(Button)) + { + return (ListOwner) + (object) + new ListOwner>( + new InputReadOnlyList>( + (object)new ButtonList() + ) + ); + } + + if (typeof(T) == typeof(Button)) + { + return (ListOwner) + (object) + new ListOwner>( + new InputReadOnlyList>( + (object)new ButtonList() + ) + ); + } + + return new ListOwner(new InputReadOnlyList((object)new List(capacity))); + } + + /// + /// Creates a new from the given . This is + /// equivalent to , but returns a + /// instead. + /// + /// The elements to populate the list with. + /// + /// + public static ListOwner Clone(IReadOnlyList other) + { + // ReSharper disable once InvertIf <-- starting to really dislike this as it duplicates code + if (other is InputReadOnlyList irl) + { + if (typeof(T) == typeof(Button)) + { + return new ListOwner( + new InputReadOnlyList(Unsafe.As>(irl.Data).Clone()) + ); + } + + if (typeof(T) == typeof(Button)) + { + return new ListOwner( + new InputReadOnlyList(Unsafe.As>(irl.Data).Clone()) + ); + } + + if (typeof(T) == typeof(Button)) + { + return new ListOwner( + new InputReadOnlyList( + Unsafe.As>(irl.Data).Clone() + ) + ); + } + } + + if (typeof(T) == typeof(Button)) + { + return new ListOwner( + new InputReadOnlyList(CloneButtonList((IReadOnlyList>)other)) + ); + } + + if (typeof(T) == typeof(Button)) + { + return new ListOwner( + new InputReadOnlyList( + CloneButtonList((IReadOnlyList>)other) + ) + ); + } + + // ReSharper disable once ConvertIfStatementToReturnStatement <-- stylistic choice + if (typeof(T) == typeof(Button)) + { + return new ListOwner( + new InputReadOnlyList( + CloneButtonList((IReadOnlyList>)other) + ) + ); + } + + return new ListOwner(new InputReadOnlyList((object)new List(other))); + static ButtonList CloneButtonList(IReadOnlyList> list) + where TEnum : unmanaged, Enum + { + var ret = new ButtonList(); + foreach (var button in list) + { + ret.Set( + button, + (button.IsDown && button.Pressure >= 1.0) + || (!button.IsDown && button.Pressure <= 0.0) + ); + } + + return ret; + } + } + + /// + /// Sets the button state in the given button list. + /// + /// The list to update. + /// The new state of the button. + /// + /// Whether the of can only be 1.0 when + /// is true, and 0.0 when is + /// false. + /// + /// The button type. + [MethodImpl(MethodImplOptions.AggressiveInlining)] // <-- generic specialisation + public static void SetButtonState(ListOwner> list, Button value, bool isBinary) + where T : unmanaged, Enum + { + if ( + typeof(T) == typeof(KeyName) + || typeof(T) == typeof(JoystickButton) + || typeof(T) == typeof(PointerButton) + ) + { + Unsafe.As>(list.List.Data).Set(value, isBinary); + return; + } + + var underlying = GetUnderlyingList(list)!; + for (var i = 0; i < underlying.Count; i++) + { + // ReSharper disable once InvertIf <-- this literally results in more lines of code!!!!! + if (underlying[i].Name.Equals(value.Name)) + { + underlying[i] = value; + return; + } + } + + underlying.Add(value); + } + + /// + /// Attempts to retrieve the underlying implementation, provided that + /// for the given is implemented as a sequential list + /// with individually addressable and a variable number of elements. + /// + /// The list. + /// The list element type. + /// + /// The list, or null if the optimised implementation of cannot be + /// expressed as an . + /// + /// + /// Currently, this can be assumed to not null except for the following types: + /// + /// where T is + /// where T is + /// where T is + /// + /// It is a breaking change to change the underlying implementation of the list such that this method returns + /// null where it previously did not return null, therefore Silk.NET will only do this in a + /// major release. As a result, it is safe to use the ! operator for code targeting a specific major + /// release. Ideally, this is also the case for major releases, but the Silk.NET team cannot guarantee this at this + /// time. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] // <-- generic specialisation + public static IList? GetUnderlyingList(this ListOwner list) => + typeof(T) == typeof(Button) + || typeof(T) == typeof(Button) + || typeof(T) == typeof(Button) + ? null + : Unsafe.As>(list.List.Data); + + /// + /// Unsafely creates a for the given list. Note that you should really only do this if + /// you are actually the owner of the list and are for some reason not storing the , using + /// this API to gain mutable access to an input list is almost always breaking assumptions throughout the input API. + /// + /// The list. + /// The type of the elements in the list. + /// The list owner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ListOwner AsOwned(InputReadOnlyList list) => new(list); + + /// + /// Given an owned mutable , swaps out the value of . + /// Note that this should not be used unexpectedly, and if there are any doubts about ownership or usage then create + /// a new instance of for safe mutation. + /// + /// The . + /// The new triggers. + public static void SetTriggers(GamepadState state, DualReadOnlyList triggers) => + state.Triggers = triggers; + + /// + /// Given an owned mutable , swaps out the value of + /// . Note that this should not be used unexpectedly, and if there are any + /// doubts about ownership or usage then create a new instance of for safe mutation. + /// + /// The . + /// The new thumbsticks. + public static void SetThumbsticks(GamepadState state, DualReadOnlyList thumbsticks) => + state.Thumbsticks = thumbsticks; + + /// + /// Given an owned mutable , swaps out the value of + /// . Note that this should not be used unexpectedly, and if there are any + /// doubts about ownership or usage then create a new instance of for safe mutation. + /// + /// The . + /// The new grip pressure. + public static void SetGripPressure(PointerState state, float gripPressure) => + state.GripPressure = gripPressure; + + // These are APIs defined on InputReadOnlyList or ButtonReadOnlyList but are implemented here to keep the + // implementation of the backing list in one file, the hope being that this decreases the likelihood of bugs. + + [MethodImpl(MethodImplOptions.AggressiveInlining)] // <-- generic specialisation + internal static Button GetButtonState(InputReadOnlyList> list, T name) + where T : unmanaged, Enum + { + if ( + typeof(T) == typeof(KeyName) + || typeof(T) == typeof(JoystickButton) + || typeof(T) == typeof(PointerButton) + ) + { + return Unsafe.As>(list.Data)[name]; + } + + var underlying = Unsafe.As>>(list.Data); + foreach (var t in underlying) + { + // ReSharper disable once InvertIf <-- this literally results in more lines of code!!!!! + if (t.Name.Equals(name)) + { + return t; + } + } + + return new Button(name, false, 0); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int GetListCount(InputReadOnlyList list) + { + if (typeof(T) == typeof(Button)) + { + return GetButtonListCount(); + } + + if (typeof(T) == typeof(Button)) + { + return GetButtonListCount(); + } + + if (typeof(T) == typeof(Button)) + { + return GetButtonListCount(); + } + + return Unsafe.As>(list.Data).Count; + } + + // ReSharper disable NotDisposedResourceIsReturned - Nope, sorry, not adding a reference to JetBrains.Annotations. + internal static IEnumerator EnumerateList(InputReadOnlyList list) + { + if (typeof(T) == typeof(Button)) + { + return (IEnumerator)Unsafe.As>(list.Data).GetEnumerator(); + } + + if (typeof(T) == typeof(Button)) + { + return (IEnumerator)Unsafe.As>(list.Data).GetEnumerator(); + } + + if (typeof(T) == typeof(Button)) + { + return (IEnumerator)Unsafe.As>(list.Data).GetEnumerator(); + } + + return Unsafe.As>(list.Data).GetEnumerator(); + } // ReSharper restore NotDisposedResourceIsReturned + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static T ElementAt(InputReadOnlyList list, int index) + { + if (typeof(T) == typeof(Button)) + { + return (T)(object)Unsafe.As>(list.Data)[index]; + } + + if (typeof(T) == typeof(Button)) + { + return (T)(object)Unsafe.As>(list.Data)[index]; + } + + if (typeof(T) == typeof(Button)) + { + return (T)(object)Unsafe.As>(list.Data)[index]; + } + + return Unsafe.As>(list.Data)[index]; + } +} diff --git a/sources/Input/Input/InputReadOnlyList.cs b/sources/Input/Input/InputReadOnlyList.cs new file mode 100644 index 0000000000..aa176623a4 --- /dev/null +++ b/sources/Input/Input/InputReadOnlyList.cs @@ -0,0 +1,32 @@ +using System.Collections; + +namespace Silk.NET.Input; + +/// +/// An opaque implementation of that is optimised for storing a Silk.NET.Input +/// type specified by using the most memory-efficient mechanism available. +/// +/// The Silk.NET.Input type to store. +public readonly struct InputReadOnlyList : IReadOnlyList +{ + internal object Data => _list; + + /// + /// Creates an from a . + /// + /// The list to copy. + public InputReadOnlyList(IReadOnlyList other) => _list = other; + + /// + public IEnumerator GetEnumerator() => _list.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + public int Count => _list.Count; + + /// + public T this[int index] => _list[index]; + + private readonly IReadOnlyList _list; +} diff --git a/sources/Input/Input/InputWindowExtensions.cs b/sources/Input/Input/InputWindowExtensions.cs new file mode 100644 index 0000000000..7eb3201841 --- /dev/null +++ b/sources/Input/Input/InputWindowExtensions.cs @@ -0,0 +1,58 @@ +// 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.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using Silk.NET.Maths; + +namespace Silk.NET.Input; + +/// +/// Contains extensions for creating input backends and contexts from s. +/// +public static partial class InputWindowExtensions +{ + /// + /// Creates an instance of the "reference implementation" of for the given + /// , provided that this was also sourced from the "reference implementation" of the + /// windowing API. The returned backend will capture input for the provided and all + /// descendants of that i.e. spawned children do not need their own backend. + /// + /// + /// Regarding the threading rules documented on , + /// must only be called on the "main thread," i.e. the same thread that windowing operates on. + /// + /// The window to create an input backend from. + /// The input backend. + /// + /// If the given is not compatible with the reference implementation for this platform. + /// + public static partial IInputBackend CreateInputBackend(this INativeWindow window); + + /// + /// Creates an that uses the "reference implementation" of + /// for the given as its only backend, provided that the was + /// also sourced from the "reference implementation" of the windowing API. The returned backend will capture input + /// for the provided and all descendants of that i.e. + /// spawned children do not need their own backend. + /// + /// + /// Regarding the threading rules documented on , + /// must only be called on the "main thread," i.e. the same thread that windowing operates on. + /// + /// The window to create an input backend from. + /// + /// The created with the instantiated input backend as its only backend. + /// + /// + /// If the given is not compatible with the reference implementation for this platform. + /// + public static InputContext CreateInput(this INativeWindow window) + { + var ret = new InputContext(); + ret.Backends.Add(window.CreateInputBackend()); + return ret; + } +} diff --git a/sources/Input/Input/JoystickAxis.cs b/sources/Input/Input/JoystickAxis.cs new file mode 100644 index 0000000000..4c9e45148a --- /dev/null +++ b/sources/Input/Input/JoystickAxis.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; + +internal enum JoystickAxis +{ + Unknown = int.MaxValue - ushort.MaxValue, + MinusLeftX, PlusLeftX, MinusLeftY, PlusRightY, + MinusRightX, PlusRightX, MinusRightY, PlusLeftY, + LeftX, LeftY, RightX, RightY, + LeftTrigger, RightTrigger, +} diff --git a/sources/Input/Input/JoystickAxisMoveEvent.cs b/sources/Input/Input/JoystickAxisMoveEvent.cs new file mode 100644 index 0000000000..18e8a1f72c --- /dev/null +++ b/sources/Input/Input/JoystickAxisMoveEvent.cs @@ -0,0 +1,15 @@ +using System.Diagnostics; + +namespace Silk.NET.Input; + +/// +/// Contains information pertaining to the movement of a joystick axis. +/// +/// The joystick on which the axis being moved resides. +/// +/// The timestamp (as retrieved from ) at which the event occurred. +/// +/// The index of the axis being moved. +/// The new value of the axis, typically between 0.0 and 1.0. +/// The change in as a result of this event. +public readonly record struct JoystickAxisMoveEvent(IJoystick Joystick, long Timestamp, int Axis, float Value, float Delta); \ No newline at end of file diff --git a/sources/Input/Input/JoystickButton.cs b/sources/Input/Input/JoystickButton.cs new file mode 100644 index 0000000000..60bced499e --- /dev/null +++ b/sources/Input/Input/JoystickButton.cs @@ -0,0 +1,130 @@ +namespace Silk.NET.Input; + +/// +/// Enumerates the buttons of a joystick. +/// +[OrderedIndexUsage] +public enum JoystickButton // todo : should we include XInput, PSX, and Nintendo button names here? +{ + /// + /// The button was not recognised. + /// + /// This is defined as such a large number such that unknown buttons can still be a JoystickButton, + /// and we can define up to predefined unique joystick buttons. + /// + Unknown = JoystickAxis.Unknown, + + /// + /// The down-most button of the primary button cluster. + /// + ButtonDown, + + /// + /// The "A" button on Xbox (and similar) controllers. Equivalent to . + /// + A = ButtonDown, + + /// + /// The rightmost button of the primary button cluster. + /// + ButtonRight, + + /// + /// The "B" button on Xbox (and similar) controllers. Equivalent to . + /// + B = ButtonRight, + + /// + /// The leftmost button of the primary button cluster. + /// + ButtonLeft, + + /// + /// The "X" button on Xbox (and similar) controllers. Equivalent to . + /// + X = ButtonLeft, + + /// + /// The upmost button of the primary button cluster. + /// + ButtonUp, + + /// + /// The "Y" button on Xbox (and similar) controllers. Equivalent to . + /// + Y = ButtonUp, + + /// + /// The leftmost bumper/shoulder button. + /// + LeftBumper, + + /// + /// The rightmost bumper/shoulder button. + /// + RightBumper, + + /// + /// The "back" button. + /// + Back, + + /// + /// The "start" button. + /// + Start, + + /// + /// The "home" button. + /// + Home, + + /// + /// The leftmost thumbstick. This button represents the stick being pressed down. + /// + LeftStick, + + /// + /// The rightmost thumbstick. This button represents the stick being pressed down. + /// + RightStick, + + /// + /// The upmost button of the D-Pad button cluster. + /// + DPadUp, + + /// + /// The rightmost button of the D-Pad button cluster. + /// + DPadRight, + + /// + /// The down-most button of the D-Pad button cluster. + /// + DPadDown, + + /// + /// The leftmost button of the D-Pad button cluster. + /// + DPadLeft, + + // BEFORE ADDING A NEW ITEM MAKE SURE YOU CHANGE LastJoystickButton IN InputMarshal +} + +/// +/// Additional functions for making sense of s +/// +public static class JoystickButtonExtensions +{ + /// + /// Returns true if we have identified this button as a known button + /// + /// + /// + public static bool IsIdentified(this JoystickButton button) => button > JoystickButton.Unknown; + + /// + public static int Index(this T value) where T : unmanaged, Enum => EnumInfo.ValueIndexOfUnnamed(value); +} + diff --git a/sources/Input/Input/JoystickHatMoveEvent.cs b/sources/Input/Input/JoystickHatMoveEvent.cs new file mode 100644 index 0000000000..67b3defd95 --- /dev/null +++ b/sources/Input/Input/JoystickHatMoveEvent.cs @@ -0,0 +1,15 @@ +using System.Diagnostics; +using System.Numerics; + +namespace Silk.NET.Input; + +/// +/// Contains information pertaining to the movement of a joystick hat. +/// +/// The joystick on which the hat being moved resides. +/// +/// The timestamp (as retrieved from ) at which the event occurred. +/// +/// The position of the hat after this event. +/// The change in as a result of this event. +public readonly record struct JoystickHatMoveEvent(IJoystick Joystick, long Timestamp, Vector2 Value, Vector2 Delta); \ No newline at end of file diff --git a/sources/Input/Input/JoystickState.cs b/sources/Input/Input/JoystickState.cs new file mode 100644 index 0000000000..290d5b1f61 --- /dev/null +++ b/sources/Input/Input/JoystickState.cs @@ -0,0 +1,38 @@ +using System.Numerics; + +namespace Silk.NET.Input; + +/// +/// Contains user input received from an . +/// +public class JoystickState +{ + /// + /// Gets the state of the joystick axes between -1.0 and 1.0 + /// + public InputReadOnlyList Axes { get; } + + /// + /// Gets the joystick button state, denoting which buttons are pressed/depressed. + /// + public ButtonReadOnlyList Buttons { get; } + + /// + /// Gets the state of the joystick hats as vectors between -1.0 and 1.0. + /// + public InputReadOnlyList Hats { get; } + + /// + /// + /// + /// + /// + /// + public JoystickState(IReadOnlyList axes, IReadOnlyList> buttons, IReadOnlyList hats) + { + Axes = new InputReadOnlyList(axes); + Buttons = new ButtonReadOnlyList(buttons); + Hats = new InputReadOnlyList(hats); + + } +} diff --git a/sources/Input/Input/Joysticks.cs b/sources/Input/Input/Joysticks.cs new file mode 100644 index 0000000000..f576e6f85f --- /dev/null +++ b/sources/Input/Input/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/KeyChangedEvent.cs b/sources/Input/Input/KeyChangedEvent.cs new file mode 100644 index 0000000000..3868ceaccd --- /dev/null +++ b/sources/Input/Input/KeyChangedEvent.cs @@ -0,0 +1,16 @@ +using System.Diagnostics; + +namespace Silk.NET.Input; + +/// +/// Contains information pertaining to a key press state change. +/// +/// The keyboard on which the key being pressed or depressed resides. +/// +/// The timestamp (as retrieved from ) at which the event occurred. +/// +/// The new state of the key being pressed or depressed. +/// The previous state of the key. +/// Whether this is an event that has been repeated at an implementation-defined rate. +/// The active key modifiers at the time the event was raised. +public readonly record struct KeyChangedEvent(IKeyboard Keyboard, long Timestamp, Button Key, Button Previous, bool IsRepeat, KeyModifiers Modifiers); \ No newline at end of file diff --git a/sources/Input/Input/KeyCharEvent.cs b/sources/Input/Input/KeyCharEvent.cs new file mode 100644 index 0000000000..8e557d06df --- /dev/null +++ b/sources/Input/Input/KeyCharEvent.cs @@ -0,0 +1,13 @@ +using System.Diagnostics; + +namespace Silk.NET.Input; + +/// +/// Contains information pertaining to a character being typed on a keyboard. +/// +/// The keyboard with which the end user typed a character. +/// +/// The timestamp (as retrieved from ) at which the event occurred. +/// +/// The character that was typed. A null character denotes a backspace. +public readonly record struct KeyCharEvent(IKeyboard Keyboard, long Timestamp, char? Character); \ No newline at end of file diff --git a/sources/Input/Input/KeyModifiers.cs b/sources/Input/Input/KeyModifiers.cs new file mode 100644 index 0000000000..8b6660641f --- /dev/null +++ b/sources/Input/Input/KeyModifiers.cs @@ -0,0 +1,40 @@ +namespace Silk.NET.Input; + +/// +/// A bitmask denoting the modifier keys that can be active when a key press occurs to modify its behaviour. +/// +[Flags] +public enum KeyModifiers +{ + /// No modifier keys are active. + None = 0, + /// The left "shift" key. + ShiftLeft = 1 << 0, + + /// The right "shift" key. + ShiftRight = 1 << 1, + + /// The left "control" key. + ControlLeft = 1 << 2, + + /// The right "control" key. + ControlRight = 1 << 3, + + /// The left "alt" key. + AltLeft = 1 << 4, + + /// The right "alt" key. + AltRight = 1 << 5, + + /// The left "super" (e.g. Windows/Start) key. + SuperLeft = 1 << 6, + + /// The right "super" (e.g. Windows/Start) key. + SuperRight = 1 << 7, + + /// The "num lock" key. + NumLock = 1 << 8, + + /// The "caps lock" key. + CapsLock = 1 << 9 +} \ No newline at end of file diff --git a/sources/Input/Input/KeyName.cs b/sources/Input/Input/KeyName.cs new file mode 100644 index 0000000000..2def1ba7d7 --- /dev/null +++ b/sources/Input/Input/KeyName.cs @@ -0,0 +1,828 @@ +namespace Silk.NET.Input; + +/// +/// Enumerates names for physical key positions as defined by the +/// USB HID Usage Tables published by +/// the USB-IF. Note that these denote an en-US-centric definition of the keys that reside at each physical position, +/// and does not take account of keyboard layout. That is, represents the Q key on a QWERTY +/// keyboard but represents the " key on a Dvorak keyboard. Use to +/// determine the localised name of a physical key position name () when taking account of the +/// user's selected keyboard layout. +/// +public enum KeyName +{ + // BEFORE ADDING ANYTHING TO THIS FILE MAKE SURE YOU REALISE THAT InputMarshal RELIES ON ASSUMPTIONS ON THE VALUES + + // These values are from usage page 0x07 (USB keyboard page). + /// + /// A key that was not recognised. + /// + Unknown = 0, + + /// The "A" key. + A = 4, + + /// The "B" key. + B = 5, + + /// The "C" key. + C = 6, + + /// The "D" key. + D = 7, + + /// The "E" key. + E = 8, + + /// The "F" key. + F = 9, + + /// The "G" key. + G = 10, + + /// The "H" key. + H = 11, + + /// The "I" key. + I = 12, + + /// The "J" key. + J = 13, + + /// The "K" key. + K = 14, + + /// The "L" key. + L = 15, + + /// The "M" key. + M = 16, + + /// The "N" key. + N = 17, + + /// The "O" key. + O = 18, + + /// The "P" key. + P = 19, + + /// The "Q" key. + Q = 20, + + /// The "R" key. + R = 21, + + /// The "S" key. + S = 22, + + /// The "T" key. + T = 23, + + /// The "U" key. + U = 24, + + /// The "V" key. + V = 25, + + /// The "W" key. + W = 26, + + /// The "X" key. + X = 27, + + /// The "Y" key. + Y = 28, + + /// The "Z" key. + Z = 29, + + /// The "1" key. + Number1 = 30, + + /// The "2" key. + Number2 = 31, + + /// The "3" key. + Number3 = 32, + + /// The "4" key. + Number4 = 33, + + /// The "5" key. + Number5 = 34, + + /// The "6" key. + Number6 = 35, + + /// The "7" key. + Number7 = 36, + + /// The "8" key. + Number8 = 37, + + /// The "9" key. + Number9 = 38, + + /// The "0" key. + Number0 = 39, + + /// The "return" key. + Return = 40, + + /// The "escape" key. + Escape = 41, + + /// The "backspace" key. + Backspace = 42, + + /// The "tab" key. + Tab = 43, + + /// The "space" key. + Space = 44, + + /// The "minus" key. + Minus = 45, + + /// The "equals" key. + Equals = 46, + + /// The "left bracket" key. + LeftBracket = 47, + + /// The "right bracket" key. + RightBracket = 48, + + /// The "backslash" key. + Backslash = 49, + + /// + /// A key with region-specific meanings. + /// + /// + /// + /// American \| + /// Belgium µ`£ + /// Canadian-French <}> + /// Danish’* + /// Dutch <> + /// French + /// German #’ + /// Italian ù§ + /// Latin-American }`] + /// Norwegian,* + /// Spanish + /// Swedish , * + /// Swiss + /// British #~. + /// + /// + NonUs1 = 50, + + /// The "semicolon" key. + Semicolon = 51, + + /// The "apostrophe" key. + Apostrophe = 52, + + /// The "grave" key. + Grave = 53, + + /// The "comma" key. + Comma = 54, + + /// The "period" key. + Period = 55, + + /// The "slash" key. + Slash = 56, + + /// The "caps lock" key. + CapsLock = 57, + + /// The first function key. + F1 = 58, + + /// The second function key. + F2 = 59, + + /// The third function key. + F3 = 60, + + /// The fourth function key. + F4 = 61, + + /// The fifth function key. + F5 = 62, + + /// The sixth function key. + F6 = 63, + + /// The seventh function key. + F7 = 64, + + /// The eighth function key. + F8 = 65, + + /// The ninth function key. + F9 = 66, + + /// The tenth function key. + F10 = 67, + + /// The eleventh function key. + F11 = 68, + + /// The twelfth function key. + F12 = 69, + + /// The "print screen" key. + PrintScreen = 70, + + /// The "scroll lock" key. + ScrollLock = 71, + + /// The "pause" key. + Pause = 72, + + /// The "insert" key. + Insert = 73, + + /// The "home" key. + Home = 74, + + /// The "page up" key. + PageUp = 75, + + /// The "delete" key. + Delete = 76, + + /// The "end" key. + End = 77, + + /// The "page down" key. + PageDown = 78, + + /// The "right" key. + Right = 79, + + /// The "left" key. + Left = 80, + + /// The "down" key. + Down = 81, + + /// The "up" key. + Up = 82, + + /// The "num lock clear" key. + NumLockClear = 83, + + /// The "divide" key on the keypad. + KeypadDivide = 84, + + /// The "multiply" key on the keypad. + KeypadMultiply = 85, + + /// The "minus" key on the keypad. + KeypadMinus = 86, + + /// The "plus" key on the keypad. + KeypadPlus = 87, + + /// The "enter" key on the keypad. + KeypadEnter = 88, + + /// The "1" key on the keypad. + Keypad1 = 89, + + /// The "2" key on the keypad. + Keypad2 = 90, + + /// The "3" key on the keypad. + Keypad3 = 91, + + /// The "4" key on the keypad. + Keypad4 = 92, + + /// The "5" key on the keypad. + Keypad5 = 93, + + /// The "6" key on the keypad. + Keypad6 = 94, + + /// The "7" key on the keypad. + Keypad7 = 95, + + /// The "8" key on the keypad. + Keypad8 = 96, + + /// The "9" key on the keypad. + Keypad9 = 97, + + /// The "0" key on the keypad. + Keypad0 = 98, + + /// The "period" key on the keypad. + KeypadPeriod = 99, + + /// + /// A key with region-specific meanings, typically near the Left-Shift key in AT-102 implementations. + /// + /// + /// Belg<\> + /// FrCa«°» + /// Dan<\> + /// Dutch]|[ + /// Fren<> + /// Ger<|> + /// Ital<> + /// LatAm<> + /// Nor<> + /// Span<> + /// Swed<|> + /// Swiss<\> + /// UK\| + /// Brazil\| + /// + NonUs2 = 100, + + /// A key for application-defined functions. + Application = 101, + + /// The "power" key. + Power = 102, + + /// The "equals" key on the keypad. + KeypadEquals = 103, + + /// The thirteenth function key. + F13 = 104, + + /// The fourteenth function key. + F14 = 105, + + /// The fifteenth function key. + F15 = 106, + + /// The sixteenth function key. + F16 = 107, + + /// The seventeenth function key. + F17 = 108, + + /// The eighteenth function key. + F18 = 109, + + /// The nineteenth function key. + F19 = 110, + + /// The twentieth function key. + F20 = 111, + + /// The twenty-first function key. + F21 = 112, + + /// The twenty-second function key. + F22 = 113, + + /// The twenty-third function key. + F23 = 114, + + /// The twenty-fourth function key. + F24 = 115, + + /// The "execute" key. + Execute = 116, + + /// The "help" key. + Help = 117, + + /// The "menu" key. + Menu = 118, + + /// The "select" key. + Select = 119, + + /// The "stop" key. + Stop = 120, + + /// The "again" key. + Again = 121, + + /// The "undo" key. + Undo = 122, + + /// The "cut" key. + Cut = 123, + + /// The "copy" key. + Copy = 124, + + /// The "paste" key. + Paste = 125, + + /// The "find" key. + Find = 126, + + /// The "mute" key. + Mute = 127, + + /// The "volume up" key. + VolumeUp = 128, + + /// The "volume down" key. + VolumeDown = 129, + + // BEFORE ADDING ANYTHING TO THIS FILE MAKE SURE YOU REALISE THAT InputMarshal RELIES ON ASSUMPTIONS ON THE VALUES + + /// The "comma" key on the keypad. + KeypadComma = 133, + + /// The alternative "equals" key on the keypad as typically found on AS-400 keyboards. + OtherKeypadEquals = 134, + + /// The first international key. + International1 = 135, + + /// The second international key. + International2 = 136, + + /// The third international key. + International3 = 137, + + /// The fourth international key. + International4 = 138, + + /// The fifth international key. + International5 = 139, + + /// The sixth international key. + International6 = 140, + + /// The seventh international key. + International7 = 141, + + /// The eighth international key. + International8 = 142, + + /// The ninth international key. + International9 = 143, + + /// The first language key. + Lang1 = 144, + + /// The second language key. + Lang2 = 145, + + /// The third language key. + Lang3 = 146, + + /// The fourth language key. + Lang4 = 147, + + /// The fifth language key. + Lang5 = 148, + + /// The sixth language key. + Lang6 = 149, + + /// The seventh language key. + Lang7 = 150, + + /// The eighth language key. + Lang8 = 151, + + /// The ninth language key. + Lang9 = 152, + + /// The alternative "erase" key, for example an Erase-Eaze™ key. + AlternativeErase = 153, + + /// The "system request" key. + SystemRequest = 154, + + /// The "cancel" key. + Cancel = 155, + + /// The "clear" key. + Clear = 156, + + /// The "prior" key. + Prior = 157, + + /// An alternative "return" key. + Return2 = 158, + + /// The "separator" key. + Separator = 159, + + /// The "out" key. + Out = 160, + + /// The "operation" key. + Oper = 161, + + /// The "clear again" key. + ClearAgain = 162, + + /// The "cursor select" key. + /// + /// For more information consult IBM's "3174 Establishment Controller - Terminal User's Reference for Expanded + /// Functions" (GA23-03320-02, May 1989) + /// + CursorSelect = 163, + + /// The "extend select" key. + /// + /// For more information consult IBM's "3174 Establishment Controller - Terminal User's Reference for Expanded + /// Functions" (GA23-03320-02, May 1989) + /// + ExtendSelect = 164, + + // BEFORE ADDING ANYTHING TO THIS FILE MAKE SURE YOU REALISE THAT InputMarshal RELIES ON ASSUMPTIONS ON THE VALUES + + /// The "00" key on the keypad. + Keypad00 = 176, + + /// The "000" key on the keypad. + Keypad000 = 177, + + /// The "thousands separator" key. + /// Interpreted as a comma for en-US. + ThousandsSeparator = 178, + + /// The "decimal separator" key. + /// Interpreted as a period for en-US. + DecimalSeparator = 179, + + /// The "currency unit" key. + /// Interpreted as a dollar sign for en-US. + CurrencyUnit = 180, + + /// The "currencySubunit" key. + /// Interpreted as a cents symbol for en-US. + CurrencySubunit = 181, + + /// The "leftParenthesis" key on the keypad. + KeypadLeftParenthesis = 182, + + /// The "rightParenthesis" key on the keypad. + KeypadRightParenthesis = 183, + + /// The "leftBrace" key on the keypad. + KeypadLeftBrace = 184, + + /// The "rightBrace" key on the keypad. + KeypadRightBrace = 185, + + /// The "tab" key on the keypad. + KeypadTab = 186, + + /// The "backspace" key on the keypad. + KeypadBackspace = 187, + + /// The "a" key on the keypad. + KeypadA = 188, + + /// The "b" key on the keypad. + KeypadB = 189, + + /// The "c" key on the keypad. + KeypadC = 190, + + /// The "d" key on the keypad. + KeypadD = 191, + + /// The "e" key on the keypad. + KeypadE = 192, + + /// The "f" key on the keypad. + KeypadF = 193, + + /// The "xor" key on the keypad. + KeypadXor = 194, + + /// The "power" key on the keypad. + KeypadPower = 195, + + /// The "percent" key on the keypad. + KeypadPercent = 196, + + /// The "less" key on the keypad. + KeypadLess = 197, + + /// The "greater" key on the keypad. + KeypadGreater = 198, + + /// The "ampersand" key on the keypad. + KeypadAmpersand = 199, + + /// The "doubleAmpersand" key on the keypad. + KeypadDoubleAmpersand = 200, + + /// The "vertical bar" key on the keypad. + KeypadVerticalBar = 201, + + /// The "double vertical bar" key on the keypad. + KeypadDoubleVerticalBar = 202, + + /// The "colon" key on the keypad. + KeypadColon = 203, + + /// The "hash" key on the keypad. + KeypadHash = 204, + + /// The "space" key on the keypad. + KeypadSpace = 205, + + /// The "@" key on the keypad. + KeypadAt = 206, + + /// The "exclamation" key on the keypad. + KeypadExclamation = 207, + + /// The "memory store" key on the keypad. + KeypadMemoryStore = 208, + + /// The "memory recall" key on the keypad. + KeypadMemoryRecall = 209, + + /// The "memory clear" key on the keypad. + KeypadMemoryClear = 210, + + /// The "memory add" key on the keypad. + KeypadMemoryAdd = 211, + + /// The "memory subtract" key on the keypad. + KeypadMemorySubtract = 212, + + /// The "memory multiply" key on the keypad. + KeypadMemoryMultiply = 213, + + /// The "memory divide" key on the keypad. + KeypadMemoryDivide = 214, + + /// The "plus/minus" key on the keypad. + KeypadPlusMinus = 215, + + /// The "clear" key on the keypad. + KeypadClear = 216, + + /// The "clear entry" key on the keypad. + KeypadClearEntry = 217, + + /// The "binary" key on the keypad. + KeypadBinary = 218, + + /// The "octal" key on the keypad. + KeypadOctal = 219, + + /// The "decimal" key on the keypad. + KeypadDecimal = 220, + + /// The "hexadecimal" key on the keypad. + KeypadHexadecimal = 221, + + // BEFORE ADDING ANYTHING TO THIS FILE MAKE SURE YOU REALISE THAT InputMarshal RELIES ON ASSUMPTIONS ON THE VALUES + + /// The left "control" key. + ControlLeft = 224, + + /// The left "shift" key. + ShiftLeft = 225, + + /// The left "alt" key. + AltLeft = 226, + + /// The left "super" (e.g. Windows/Start) key. + SuperLeft = 227, + + /// The right "control" key. + ControlRight = 228, + + /// The right "shift" key. + ShiftRight = 229, + + /// The right "alt" key. + AltRight = 230, + + /// The right "super" (e.g. Windows/Start) key. + SuperRight = 231, + + // 232-256..... wtf? + // BEFORE ADDING ANYTHING TO THIS FILE MAKE SURE YOU REALISE THAT InputMarshal RELIES ON ASSUMPTIONS ON THE VALUES + + /// The "mode" key. + Mode = 257, + + // These values are mapped from usage page 0x0C (USB consumer page). + /// The "sleep" key. + Sleep = 258, + + /// The "wake" key. + Wake = 259, + + /// The "channel increment" key. + ChannelIncrement = 260, + + /// The "channel decrement" key. + ChannelDecrement = 261, + + /// The "play" media key. + MediaPlay = 262, + + /// The "pause" media key. + MediaPause = 263, + + /// The "record" media key. + MediaRecord = 264, + + /// The "fast forward" media key. + MediaFastForward = 265, + + /// The "rewind" media key. + MediaRewind = 266, + + /// The "next track" media key. + MediaNextTrack = 267, + + /// The "previous track" media key. + MediaPreviousTrack = 268, + + /// The "stop" media key. + MediaStop = 269, + + /// The "eject" media key. + MediaEject = 270, + + /// The "play/pause" media key. + MediaPlayPause = 271, + + /// The "select" media key. + MediaSelect = 272, + + /// The "new" application key. + ApplicationNew = 273, + + /// The "open" application key. + ApplicationOpen = 274, + + /// The "close" application key. + ApplicationClose = 275, + + /// The "exit" application key. + ApplicationExit = 276, + + /// The "save" application key. + ApplicationSave = 277, + + /// The "print" application key. + ApplicationPrint = 278, + + /// The "properties" application key. + ApplicationProperties = 279, + + /// The "search" application key. + ApplicationSearch = 280, + + /// The "home" application key. + ApplicationHome = 281, + + /// The "back" application key. + ApplicationBack = 282, + + /// The "forward" application key. + ApplicationForward = 283, + + /// The "stop" application key. + ApplicationStop = 284, + + /// The "refresh" application key. + ApplicationRefresh = 285, + + /// The "bookmarks" application key. + ApplicationBookmarks = 286, + + // BEFORE ADDING ANYTHING TO THIS FILE MAKE SURE YOU REALISE THAT InputMarshal RELIES ON ASSUMPTIONS ON THE VALUES + + // 501-512 is reserved for non-standard (i.e. not from an industry-standard HID page) keys. + /// The left soft key e.g. the left button on a mobile phone. + /// This is not from an industry-standard HID page. + SoftLeft = 501, + + /// The right soft key e.g. the right button on a mobile phone. + /// This is not from an industry-standard HID page. + SoftRight = 502, + + /// The "call" key. + /// This is not from an industry-standard HID page. + Call = 503, + + /// The "end call" key. + /// This is not from an industry-standard HID page. + EndCall = 504, + + // BEFORE ADDING ANYTHING TO THIS FILE MAKE SURE YOU REALISE THAT InputMarshal RELIES ON ASSUMPTIONS ON THE VALUES +} diff --git a/sources/Input/Input/KeyboardState.cs b/sources/Input/Input/KeyboardState.cs new file mode 100644 index 0000000000..78ccbbc0f7 --- /dev/null +++ b/sources/Input/Input/KeyboardState.cs @@ -0,0 +1,77 @@ +namespace Silk.NET.Input; + +/// +/// Contains user input received from an . +/// +public class KeyboardState +{ + /// + /// Gets the text that has been typed since has been called. This will be cleared + /// when is called. + /// + public InputReadOnlyList? Text { get; } + + /// + /// Gets the key state, denoting which keys are pressed on the keyboard. + /// + public ButtonReadOnlyList Keys { get; } + + /// + /// Gets the active modifier keys. + /// + public KeyModifiers Modifiers + { + get + { + var state = KeyModifiers.None; + if(Keys[KeyName.ControlLeft]) + state |= KeyModifiers.ControlLeft; + + if(Keys[KeyName.ControlRight]) + state |= KeyModifiers.ControlRight; + + if(Keys[KeyName.AltLeft]) + state |= KeyModifiers.AltLeft; + + if(Keys[KeyName.AltRight]) + state |= KeyModifiers.AltRight; + + if(Keys[KeyName.ShiftLeft]) + state |= KeyModifiers.ShiftLeft; + + if(Keys[KeyName.ShiftRight]) + state |= KeyModifiers.ShiftRight; + + if(Keys[KeyName.SuperLeft]) + state |= KeyModifiers.SuperLeft; + + if(Keys[KeyName.SuperRight]) + state |= KeyModifiers.SuperRight; + + if(_capsLockActive()) + state |= KeyModifiers.CapsLock; + + if(_numLockActive()) + state |= KeyModifiers.NumLock; + + return state; + } + } + + /// + /// Constructor for the keyboard state - the provided button list should be continuously updated by the + /// implementation + /// + /// The collection of keys that are modified at runtime to give the current keyboard its state + /// Return true if caps lock is currently active, irrespective of pressed status + /// Return true if num lock is currently active, irrespective of pressed status + public KeyboardState(IReadOnlyList> keys, Func capsLockActive, Func numLockActive) + { + Keys = new ButtonReadOnlyList(keys); + _capsLockActive = capsLockActive; + _numLockActive = numLockActive; + } + + private readonly Func _numLockActive; + private readonly Func _capsLockActive; +} diff --git a/sources/Input/Input/Keyboards.cs b/sources/Input/Input/Keyboards.cs new file mode 100644 index 0000000000..34c59a25c6 --- /dev/null +++ b/sources/Input/Input/Keyboards.cs @@ -0,0 +1,33 @@ +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/MouseScrollEvent.cs b/sources/Input/Input/MouseScrollEvent.cs new file mode 100644 index 0000000000..737950479e --- /dev/null +++ b/sources/Input/Input/MouseScrollEvent.cs @@ -0,0 +1,18 @@ +using System.Diagnostics; +using System.Numerics; + +namespace Silk.NET.Input; + +/// +/// Contains information pertaining to the user scrolling using a mouse scroll wheel. +/// +/// The mouse on which the scroll wheel resides. +/// +/// The timestamp (as retrieved from ) at which the event occurred. +/// +/// The mouse's active point when the scroll event occurred. +/// The after the event occurred. +/// +/// The change in as a result of this event represented as a number of ratchets. +/// +public readonly record struct MouseScrollEvent(IMouse Mouse, long Timestamp, TargetPoint Point, Vector2 WheelPosition, Vector2 Delta); \ No newline at end of file diff --git a/sources/Input/Input/MouseState.cs b/sources/Input/Input/MouseState.cs new file mode 100644 index 0000000000..545a7704ae --- /dev/null +++ b/sources/Input/Input/MouseState.cs @@ -0,0 +1,34 @@ +using System.Numerics; + +namespace Silk.NET.Input; + +/// +/// Contains user input received from an . +/// +public class MouseState( + ButtonReadOnlyList buttons, + InputReadOnlyList points, + Vector2 wheelPosition, + float gripPressure = 0 +) : PointerState(buttons, points, gripPressure) +{ + /// + /// Clones a object. + /// + /// The object to clone. + /// + /// This object will not receive any changes from the object it was created from as they are distinct clones. + /// + public MouseState(MouseState other) + : this( + new ButtonReadOnlyList(other.Buttons), + new InputReadOnlyList(other.Points), + other.WheelPosition, + other.GripPressure + ) { } + + /// + /// Gets the current position of the scroll wheel in number of ratchets. + /// + public Vector2 WheelPosition { get; internal set; } = wheelPosition; +} diff --git a/sources/Input/Input/PointChangedEvent.cs b/sources/Input/Input/PointChangedEvent.cs new file mode 100644 index 0000000000..cf9ae15e8c --- /dev/null +++ b/sources/Input/Input/PointChangedEvent.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Silk.NET.Input; + +/// +/// Contains information pertaining to a change on a , +/// +/// The pointer device with which the user is pointing. +/// +/// The timestamp (as retrieved from ) at which the event occurred. +/// +/// +/// The previous state for this . If this is a new point (e.g. a finger has only just touched a +/// touch screen), this shall be null. +/// +/// +/// The new state for this . If the point is no longer valid (e.g. a finger is no longer +/// touching a touch screen), this shall be null. +/// +public readonly record struct PointChangedEvent( + IPointerDevice Pointer, + long Timestamp, + TargetPoint? OldPoint, + TargetPoint? NewPoint +); diff --git a/sources/Input/Input/PointerButton.cs b/sources/Input/Input/PointerButton.cs new file mode 100644 index 0000000000..f9b498dd3f --- /dev/null +++ b/sources/Input/Input/PointerButton.cs @@ -0,0 +1,185 @@ +namespace Silk.NET.Input; + +/// +/// Enumerates the buttons available on pointer devices. +/// +[OrderedIndexUsage] +public enum PointerButton +{ + /// + /// An unrecognised button. + /// + Unknown = JoystickAxis.Unknown, + + /// + /// The primary button e.g. left click. + /// + Primary, + + /// + /// The secondary button e.g. right click. + /// + Secondary, + + /// + /// The third button. + /// + Button3, + + /// + /// The middle button i.e. clicking the scroll wheel down. This acts as the third button. + /// + MiddleButton = Button3, + + /// + /// The fourth button. + /// + Button4, + + /// + /// The fifth button. + /// + Button5, + + /// + /// The sixth button. + /// + Button6, + + /// + /// The seventh button. + /// + Button7, + + /// + /// The eighth button. + /// + Button8, + + /// + /// The ninth button. + /// + Button9, + + /// + /// The tenth button. + /// + Button10, + + /// + /// The eleventh button. + /// + Button11, + + /// + /// The twelveth button. + /// + Button12, + + /// + /// The thirteenth button. + /// + Button13, + + /// + /// The fourteenth button. + /// + Button14, + + /// + /// The fifteenth button. + /// + Button15, + + /// + /// The sixteenth button. + /// + Button16, + + /// + /// The seventeenth button. + /// + Button17, + + /// + /// The eighteenth button. + /// + Button18, + + /// + /// The nineteenth button. + /// + Button19, + + /// + /// The twentieth button. + /// + Button20, + + /// + /// The twenty-first button. + /// + Button21, + + /// + /// The twenty-second button. + /// + Button22, + + /// + /// The twenty-third button. + /// + Button23, + + /// + /// The twenty-fourth button. + /// + Button24, + + /// + /// The twenty-fifth button. + /// + Button25, + + /// + /// The twenty-sixth button. + /// + Button26, + + /// + /// The twenty-seventh button. + /// + Button27, + + /// + /// The twenty-eighth button. + /// + Button28, + + /// + /// The twenty-ninth button. + /// + Button29, + + /// + /// The thirtieth button. + /// + Button30, + + /// + /// The eraser tip of a pen pointer device. This acts as the thirtieth button. + /// + EraserTip = Button30, + + /// + /// The thirty-first button. + /// + Button31, + + /// + /// The thirty-second button. + /// + Button32, + + // BEFORE ADDING MORE BUTTONS, ENSURE YOU CHANGE InputMarshal TO ACCOUNT FOR THE NEW MAX +} diff --git a/sources/Input/Input/PointerClickConfiguration.cs b/sources/Input/Input/PointerClickConfiguration.cs new file mode 100644 index 0000000000..4612da0780 --- /dev/null +++ b/sources/Input/Input/PointerClickConfiguration.cs @@ -0,0 +1,19 @@ +namespace Silk.NET.Input; + +/// +/// Denotes the configuration for recognising events apart from single +/// events. +/// +/// +/// The maximum time in milliseconds between two consecutive clicks to count as a double click. +/// +/// +/// The maximum distance in pixels between two consecutive clicks to count as a double click. +/// +public record struct PointerClickConfiguration(int DoubleClickTime, float DoubleClickRange) +{ + /// + /// Gets the default configuration. + /// + public static PointerClickConfiguration Default => new(500, 4); +} diff --git a/sources/Input/Input/PointerClickEvent.cs b/sources/Input/Input/PointerClickEvent.cs new file mode 100644 index 0000000000..b5c75eece6 --- /dev/null +++ b/sources/Input/Input/PointerClickEvent.cs @@ -0,0 +1,17 @@ +using System.Diagnostics; + +namespace Silk.NET.Input; + +/// +/// Contains information pertaining to a pointer button being pressed and released (i.e. clicked). +/// +/// The pointer device on which the button being pressed and released resides. +/// +/// The timestamp (as retrieved from ) at which the event occurred. +/// +/// +/// A specific for which the button press occurred, check to +/// validate if such a point was available. +/// +/// The button that was pressed and released in succession. +public readonly record struct PointerClickEvent(IPointerDevice Pointer, long Timestamp, TargetPoint Point, PointerButton Button); \ No newline at end of file diff --git a/sources/Input/Input/PointerGripChangedEvent.cs b/sources/Input/Input/PointerGripChangedEvent.cs new file mode 100644 index 0000000000..079e1e4e8a --- /dev/null +++ b/sources/Input/Input/PointerGripChangedEvent.cs @@ -0,0 +1,18 @@ +using System.Diagnostics; + +namespace Silk.NET.Input; + +/// +/// Contains information pertaining to the user changing the pressure with which they're applying their grip on the +/// given pointer device. +/// +/// The pointer device the user is gripping. +/// +/// The timestamp (as retrieved from ) at which the event occurred. +/// +/// +/// The grip pressure being applied to the device, where 0.0 is the lowest amount of pressure measurable by the +/// device and 1.0 is the maximum amount of pressure measurable by the device. +/// +/// The change in from its previous value. +public readonly record struct PointerGripChangedEvent(IPointerDevice Pointer, long Timestamp, float GripPressure, float Delta); \ No newline at end of file diff --git a/sources/Input/Input/PointerState.cs b/sources/Input/Input/PointerState.cs new file mode 100644 index 0000000000..b77bc0af0e --- /dev/null +++ b/sources/Input/Input/PointerState.cs @@ -0,0 +1,41 @@ +namespace Silk.NET.Input; + +/// +/// Contains user input state received from an . +/// +public class PointerState( + ButtonReadOnlyList buttons, + InputReadOnlyList points, + float gripPressure = 0 +) +{ + /// + /// Clones a object. + /// + /// The object to clone. + /// + /// This object will not receive any changes from the object it was created from as they are distinct clones. + /// + public PointerState(PointerState other) + : this( + new ButtonReadOnlyList(other.Buttons), + new InputReadOnlyList(other.Points), + other.GripPressure + ) { } + + /// + /// Gets the captured state of each of the buttons on the device. + /// + public ButtonReadOnlyList Buttons { get; } = buttons; + + /// + /// Gets the points on the targets at which the user is pointing using the device. + /// + public InputReadOnlyList Points { get; } = points; + + /// + /// Gets the pressure the user is applying to the grip of the pointer device, where 0.0 is the lowest + /// measurable pressure and 1.0 is the highest measurable pressure. + /// + public float GripPressure { get; internal set; } = gripPressure; +} diff --git a/sources/Input/Input/PointerTargetChangedEvent.cs b/sources/Input/Input/PointerTargetChangedEvent.cs new file mode 100644 index 0000000000..49a97e5950 --- /dev/null +++ b/sources/Input/Input/PointerTargetChangedEvent.cs @@ -0,0 +1,27 @@ +using System.Diagnostics; +using Silk.NET.Maths; + +namespace Silk.NET.Input; + +/// +/// Contains information pertaining to changes to a "target" at which the user can point using a pointer device. +/// +/// The pointer with which the user can point at the given target. +/// +/// The timestamp (as retrieved from ) at which the event occurred. +/// +/// The target at which the user can point. +/// +/// true if this is a newly-added target to , +/// false if this target has been removed from the list of available , +/// null if there has been no change to the target's validity. +/// +/// +/// The old of the target. This may be the same as if there +/// has been no change. +/// +/// +/// The new of the target. This may be the same as if there +/// has been no change. +/// +public readonly record struct PointerTargetChangedEvent(IPointerDevice Pointer, long Timestamp, IPointerTarget Target, bool? IsAdded, Box3D OldBounds, Box3D NewBounds); \ No newline at end of file diff --git a/sources/Input/Input/Pointers.cs b/sources/Input/Input/Pointers.cs new file mode 100644 index 0000000000..f1d5e84181 --- /dev/null +++ b/sources/Input/Input/Pointers.cs @@ -0,0 +1,367 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using System.Runtime.InteropServices; + +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) + { + PointChanged?.Invoke(@event); + 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. + Click?.Invoke( + 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". + Click?.Invoke(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) + { + click.FirstClickTime = null; + click.IsFirstClick = true; + if (click is { FirstClickButton: { } fcb, FirstClickTime: { } fct }) + { + Click?.Invoke(new PointerClickEvent(click.Device, fct, click.FirstClickPosition, fcb)); + } + } + + internal void HandleUpdate() + { + 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 { } fct || updateTime - fct <= _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); + } + } + + /// + 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/Silk.NET.Input.csproj b/sources/Input/Input/Silk.NET.Input.csproj new file mode 100644 index 0000000000..d20067094e --- /dev/null +++ b/sources/Input/Input/Silk.NET.Input.csproj @@ -0,0 +1,17 @@ + + + + net8.0;net9.0 + enable + enable + ST0005;$(NoWarn) + + + + + + + + + + diff --git a/sources/Input/Input/Silk.NET.Input.csproj.DotSettings b/sources/Input/Input/Silk.NET.Input.csproj.DotSettings new file mode 100644 index 0000000000..10361e88b3 --- /dev/null +++ b/sources/Input/Input/Silk.NET.Input.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/sources/Input/Input/TargetPoint.cs b/sources/Input/Input/TargetPoint.cs new file mode 100644 index 0000000000..4607643ae7 --- /dev/null +++ b/sources/Input/Input/TargetPoint.cs @@ -0,0 +1,54 @@ +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using Silk.NET.Maths; + +namespace Silk.NET.Input; + +/// +/// Represents a point on a target at which a pointer is pointing. +/// +/// +/// An integral identifier for the point. This point must be the only point for the device currently pointing at a +/// target with this identifier at any given time. If this point ceases to point at the target, then the identifier +/// becomes free for another device point. This means that this identifier can just be a counter, but may be globally +/// unique depending on the backend's capabilities. If an index is used, points with greater indices should not be +/// "moved" into this point's place should it no longer point at the target. This is to allow applications to track +/// distinct points. +/// +/// Flags describing the state of the point. +/// The absolute position on the target at which the pointer is pointing. +/// +/// The normalized position on the target at which the pointer is pointing, if applicable. If this is not available +/// (e.g. due to the target being infinitely large a.k.a. "unbounded"), then this property shall have a value of +/// default. +/// +/// +/// A ray representing the distance and angle at which the pointer is pointing at the point on the target. A ray with an +/// orientation equivalent to an identity quaternion shall be interpreted as the point directly perpendicular to and +/// facing towards the target, with this being the default value should this information be unavailable. If distance +/// information is unavailable, this shall be equivalent to a default vector. +/// +/// +/// The pressure applied to the point on the target by the pointer, between 0.0 representing the minimum amount +/// of pressure and 1.0 representing the maximum amount of pressure. This shall be 1.0 if such data is +/// unavailable but the point is otherwise valid. +/// +/// The pointer being pointed at. +public readonly record struct TargetPoint( + int Id, + TargetPointFlags Flags, + Vector3 Position, + Vector3 NormalizedPosition, + Ray3D Pointer, + float Pressure, + IPointerTarget? Target +) +{ + /// + /// Gets a value indicating whether this is a valid instance of a point on a + /// that the user is pointing at using their pointer device. + /// + [MemberNotNullWhen(true, nameof(Target))] + public bool IsValid => + (Flags & TargetPointFlags.PointingAtTarget) != TargetPointFlags.NotPointingAtTarget; +} diff --git a/sources/Input/Input/TargetPointFlags.cs b/sources/Input/Input/TargetPointFlags.cs new file mode 100644 index 0000000000..091cb74fb4 --- /dev/null +++ b/sources/Input/Input/TargetPointFlags.cs @@ -0,0 +1,18 @@ +namespace Silk.NET.Input; + +/// +/// Flags describing a state. +/// +[Flags] +public enum TargetPointFlags +{ + /// + /// No flags are set, indicating that the point is not being pointed at and therefore may not be valid. + /// + NotPointingAtTarget = 0, + + /// + /// Indicates that the point has been resolved as a valid point at which the pointer is pointing. + /// + PointingAtTarget = 1 << 0 +} \ No newline at end of file diff --git a/sources/Playground/InputTesting.cs b/sources/Playground/InputTesting.cs new file mode 100644 index 0000000000..4dabddcef6 --- /dev/null +++ b/sources/Playground/InputTesting.cs @@ -0,0 +1,25 @@ +// 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.Input; + +internal partial class InputTesting +{ + private static InputContext InitInput(INativeWindow window) => window.CreateInput(); + + private static void ExecuteInput(InputContext? context, INativeWindow window) + { + if (context == null) + return; + + context.Update(); + + } + + private static void OnInputConnectionChanged(ConnectionEvent obj) + { + Console.WriteLine($"{obj.Device.Name} {obj.Device.Id} {(obj.IsConnected ? "connected" : "disconnected")}"); + } + + +} diff --git a/sources/Playground/Playground.csproj b/sources/Playground/Playground.csproj index 1749875027..e402686b08 100644 --- a/sources/Playground/Playground.csproj +++ b/sources/Playground/Playground.csproj @@ -12,6 +12,7 @@ + diff --git a/sources/Playground/Program.cs b/sources/Playground/Program.cs index 2a7afc3c8d..0132c97b78 100644 --- a/sources/Playground/Program.cs +++ b/sources/Playground/Program.cs @@ -2,11 +2,12 @@ using System.Runtime.InteropServices; using Silk.NET.Core; +using Silk.NET.Input; using Silk.NET.OpenGL; using Silk.NET.Windowing; using Surface = Silk.NET.Windowing.Surface; -internal class MyApplication : ISurfaceApplication +internal partial class InputTesting : ISurfaceApplication { public static void Initialize(TSurface surface) where TSurface : Surface @@ -22,9 +23,11 @@ public static void Initialize(TSurface surface) surface.OpenGL.Profile = OpenGLContextProfile.Core; surface.OpenGL.Version = new Version32(3, 3); + var vbo = 0u; var vao = 0u; var prog = 0u; + InputContext? inputContext = null; surface.Created += _ => { // Make the OpenGL context current, this will allow us to use GL static functions. @@ -92,9 +95,15 @@ void main() GL.DeleteShader(vert); GL.DeleteShader(frag); GL.UseProgram(prog); + + + inputContext = InitInput(surface); + inputContext.ConnectionChanged += OnInputConnectionChanged; }; surface.Render += _ => { + ExecuteInput(inputContext, surface); + GL.Clear(ClearBufferMask.ColorBufferBit); GL.DrawArrays(PrimitiveType.Triangles, 0, 3); }; @@ -105,5 +114,5 @@ void main() }; } - public static void Main() => ISurfaceApplication.Run(); + public static void Main() => ISurfaceApplication.Run(); } diff --git a/sources/SDL/SDL/InputHandleExtensions.cs b/sources/SDL/SDL/InputHandleExtensions.cs new file mode 100644 index 0000000000..9b0fdfd812 --- /dev/null +++ b/sources/SDL/SDL/InputHandleExtensions.cs @@ -0,0 +1,36 @@ +// 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.SDL; + +public partial struct GamepadHandle +{ + private unsafe GamepadHandle(void* handle) => Handle = handle; + + // conversion from void ptr + public static unsafe explicit operator GamepadHandle (void* handle) => new GamepadHandle(handle); +} + +public partial struct JoystickHandle +{ + private unsafe JoystickHandle(void* handle) => Handle = handle; + + // conversion from void ptr + public static unsafe explicit operator JoystickHandle (void* handle) => new JoystickHandle(handle); +} + +public partial struct SensorHandle +{ + private unsafe SensorHandle(void* handle) => Handle = handle; + + // conversion from void ptr + public static unsafe explicit operator SensorHandle (void* handle) => new SensorHandle(handle); +} + +public partial struct HidDeviceHandle +{ + private unsafe HidDeviceHandle(void* handle) => Handle = handle; + + // conversion from void ptr + public static unsafe explicit operator HidDeviceHandle (void* handle) => new HidDeviceHandle(handle); +} diff --git a/sources/SDL/SDL/SdlPlatformInfo.cs b/sources/SDL/SDL/SdlPlatformInfo.cs new file mode 100644 index 0000000000..68deed5cf8 --- /dev/null +++ b/sources/SDL/SDL/SdlPlatformInfo.cs @@ -0,0 +1,11 @@ +// 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.SDL; + +/// +/// The SDL platform-specific handles. +/// +/// . +/// The SDL API interface. If null, use . +public readonly record struct SdlPlatformInfo(WindowHandle Window, ISdl? Sdl = null); diff --git a/sources/Windowing/Windowing/Implementations/SDL3/SdlSurface.cs b/sources/Windowing/Windowing/Implementations/SDL3/SdlSurface.cs index 4def1f9e7a..233fb6bd22 100644 --- a/sources/Windowing/Windowing/Implementations/SDL3/SdlSurface.cs +++ b/sources/Windowing/Windowing/Implementations/SDL3/SdlSurface.cs @@ -89,6 +89,12 @@ public override bool TryGetPlatformInfo( return false; } + if (typeof(TPlatformInfo) == typeof(SdlPlatformInfo)) + { + info = (TPlatformInfo)(object)new SdlPlatformInfo(Impl.Handle, Sdl.Instance); + return true; + } + var props = Sdl.GetWindowProperties(Impl.Handle); if (typeof(TPlatformInfo) == typeof(CocoaPlatformInfo)) { diff --git a/sources/Windowing/Windowing/Surface.cs b/sources/Windowing/Windowing/Surface.cs index e2605e428d..28d38f47a1 100644 --- a/sources/Windowing/Windowing/Surface.cs +++ b/sources/Windowing/Windowing/Surface.cs @@ -284,7 +284,24 @@ protected internal virtual void OnUpdate() /// Centers this window to the given monitor or, if null, the current monitor the window's on. /// /// The specific display to center the window to, if any. - public void Center(IDisplay? display = null) => throw new NotImplementedException(); + public void Center(IDisplay? display = null) + { + if ( + Window is null + || Display is null + || display is not null && !Display.Available.Contains(display) + ) + { + return; + } + + if (display is not null) + { + Display.Current = display; + } + + Window.Position = (Vector2)Display.Current.WorkArea.Center - (Window.Size / 2); + } /// /// Converts a point that is defined in the same coordinate space as to instead be @@ -293,7 +310,15 @@ protected internal virtual void OnUpdate() /// /// The point to transform. /// The transformed point. - public Vector2 ScreenToClient(Vector2 point) => throw new NotImplementedException(); + public Vector2 ScreenToClient(Vector2 point) + { + if (Window is null || Display is null) + { + return point; + } + + return point - Window.Position; + } /// /// Converts a point that is defined relative to to instead be defined in the @@ -302,7 +327,15 @@ protected internal virtual void OnUpdate() /// /// The point to transform. /// The transformed point. - public Vector2 ClientToScreen(Vector2 point) => throw new NotImplementedException(); + public Vector2 ClientToScreen(Vector2 point) + { + if (Window is null || Display is null) + { + return point; + } + + return point + Window.Position; + } /// /// Converts a point that is defined relative to by multiplying it with the @@ -310,7 +343,31 @@ protected internal virtual void OnUpdate() /// /// The point to transform. /// The transformed point. - public Vector2 ClientToDrawable(Vector2 point) => throw new NotImplementedException(); + public Vector2 ClientToDrawable(Vector2 point) + { + if (Window is null) + { + return point; + } + + return point * (DrawableSize / Window.ClientSize); + } + + /// + /// Converts a point that is defined in terms of by dividing it by the division of + /// by 's size. + /// + /// The point to transform. + /// The transformed point. + public Vector2 DrawableToClient(Vector2 point) + { + if (Window is null) + { + return point; + } + + return point / (DrawableSize / Window.ClientSize); + } /// public abstract bool TryGetPlatformInfo( diff --git a/tests/Input/Input/InputMarshalTests.cs b/tests/Input/Input/InputMarshalTests.cs new file mode 100644 index 0000000000..e75c730077 --- /dev/null +++ b/tests/Input/Input/InputMarshalTests.cs @@ -0,0 +1,160 @@ +// 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.Reflection; +using NUnit.Framework; + +namespace Silk.NET.Input.UnitTests; + +[TestFixture] +public class InputMarshalTests +{ + [TestCase(TypeArgs = [typeof(PointerButton)])] + [TestCase(TypeArgs = [typeof(JoystickButton)])] + [TestCase(TypeArgs = [typeof(KeyName)])] + public void GetButtonCount() + where T : unmanaged, Enum + { + // This is to determine the gaps in KeyName. + var prev = -1; + foreach (var @enum in Enum.GetValues().Order()) + { + var val = SilkMarshal.ConstCast(@enum); + if (val - 1 != prev) + { + Console.WriteLine( + $"{prev} ({SilkMarshal.ConstCast(prev)}), {val} ({@enum})" + ); + } + + prev = val; + } + } + + [TestCase(TypeArgs = [typeof(PointerButton)])] + [TestCase(TypeArgs = [typeof(JoystickButton)])] + [TestCase(TypeArgs = [typeof(KeyName)])] + public void EnumerateButtonList() + where T : unmanaged, Enum + { + var list = InputMarshal.CreateList>(); + var expectedCount = Enum.GetNames(typeof(T)) + .DistinctBy(Enum.Parse) + .Count(x => x != "Unknown"); + Assert.That(list.List, Has.Count.EqualTo(expectedCount)); + var encountered = 0; + var values = Enum.GetValues() + .Where(x => x.ToString() != "Unknown") + .Distinct() + .Order() + .GetEnumerator(); + foreach (var btn in list.List) + { + encountered++; + Assert.Multiple(() => + { + Assert.That(values.MoveNext(), Is.True); + Assert.That(btn.Name, Is.EqualTo(values.Current)); + Assert.That(btn.Pressure, Is.EqualTo(0)); + Assert.That(btn.IsDown, Is.False); + }); + } + + Assert.That(encountered, Is.EqualTo(expectedCount)); + } + + [TestCase(TypeArgs = [typeof(PointerButton)])] + [TestCase(TypeArgs = [typeof(JoystickButton)])] + [TestCase(TypeArgs = [typeof(KeyName)])] + public void IndexButtonList() + where T : unmanaged, Enum + { + var list = InputMarshal.CreateList>(); + var idx = 0; + foreach ( + var name in Enum.GetValues().Where(x => x.ToString() != "Unknown").Distinct().Order() + ) + { + var btn = list.List[idx++]; + Assert.Multiple(() => + { + Assert.That(btn.Name, Is.EqualTo(name)); + Assert.That(btn.Pressure, Is.EqualTo(0)); + Assert.That(btn.IsDown, Is.False); + }); + } + } + + [TestCase(TypeArgs = [typeof(PointerButton)])] + [TestCase(TypeArgs = [typeof(JoystickButton)])] + [TestCase(TypeArgs = [typeof(KeyName)])] + public void GetButtonState() + where T : unmanaged, Enum + { + var list = InputMarshal.CreateList>().List.AsButtonList(); + foreach ( + var name in Enum.GetValues().Where(x => x.ToString() != "Unknown").Distinct().Order() + ) + { + var btn = list[name]; + Assert.Multiple(() => + { + Assert.That(btn.Name, Is.EqualTo(name)); + Assert.That(btn.Pressure, Is.EqualTo(0)); + Assert.That(btn.IsDown, Is.False); + }); + } + } + + [TestCase(TypeArgs = [typeof(PointerButton)])] + [TestCase(TypeArgs = [typeof(JoystickButton)])] + [TestCase(TypeArgs = [typeof(KeyName)])] + public void IndexNameTranslationRoundTrip() + where T : unmanaged, Enum + { + var values = Enum.GetValues() + .Where(x => x.ToString() != "Unknown") + .Distinct() + .Order() + .GetEnumerator(); + for (var i = 0; i < InputMarshal.GetButtonListCount(); i++) + { + Assert.That(values.MoveNext(), Is.True); + var name = InputMarshal.ButtonList.IndexName(i); + Assert.Multiple(() => + { + Assert.That(name, Is.EqualTo(values.Current)); + Assert.That(InputMarshal.ButtonList.NameIndex(name), Is.EqualTo(i)); + }); + } + } + + [TestCase(TypeArgs = [typeof(PointerButton)])] + [TestCase(TypeArgs = [typeof(JoystickButton)])] + [TestCase(TypeArgs = [typeof(KeyName)])] + public void SetGetBinaryButtonState() + where T : unmanaged, Enum + { + var arr = Enum.GetValues() + .Where(x => x.ToString() != "Unknown") + .Distinct() + .Order() + .ToArray(); + foreach (var name in arr) + { + var list = InputMarshal.CreateList>(); + InputMarshal.SetButtonState(list, new Button(name, true, 1), true); + foreach (var testName in arr) + { + var btn = list.List.AsButtonList()[testName]; + Assert.Multiple(() => + { + Assert.That(btn.Name, Is.EqualTo(testName)); + Assert.That(btn.Pressure, Is.EqualTo(testName.Equals(name) ? 1 : 0)); + Assert.That(btn.IsDown, Is.EqualTo(testName.Equals(name))); + }); + } + } + } +} diff --git a/tests/Input/Input/Silk.NET.Input.UnitTests.csproj b/tests/Input/Input/Silk.NET.Input.UnitTests.csproj new file mode 100644 index 0000000000..2daa2e92e6 --- /dev/null +++ b/tests/Input/Input/Silk.NET.Input.UnitTests.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + Silk.NET.Core.cs + + + +