diff --git a/src/KappaDuck.Quack/Input/Cursor.cs b/src/KappaDuck.Quack/Input/Cursor.cs new file mode 100644 index 0000000..d7d4ac1 --- /dev/null +++ b/src/KappaDuck.Quack/Input/Cursor.cs @@ -0,0 +1,228 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Core; +using KappaDuck.Quack.Exceptions; +using KappaDuck.Quack.Geometry; +using KappaDuck.Quack.Interop.SDL.Primitives; +using KappaDuck.Quack.Video.Pixels; + +namespace KappaDuck.Quack.Input; + +/// +/// Represents a mouse cursor. +/// +public sealed class Cursor : IDisposable +{ + private readonly bool _owned; + + /// + /// Creates a cursor with a system cursor type. + /// + /// The system cursor type. + public Cursor(CursorType type) : this(SDL3.CreateSystemCursor(type), true) + { + } + + /// + /// Creates a cursor with the image + /// + /// + /// If the surface contains alternate images added with , + /// the surface will be interpreted as the content to be used for 100% display scale. + /// For example, if the original surface is 32x32, then on a 200% display scale on Windows, + /// a 64x64 version of the image will be used, if available. + /// If a matching version of the image isn't available, the closest larger size image will be downscaled + /// to the appropriate size and be used instead, if available. Otherwise, the closest smaller image will be upscaled and be used instead. + /// + /// The image to use + /// The cursor hotspot + public Cursor(Surface surface, Point hotspot) : this(SDL3.CreateColorCursor(surface.Handle, hotspot.X, hotspot.Y), true) + { + } + + /// + /// Creates a monochrome (black and white) cursor from bitmap data and a mask. + /// + /// + /// + /// and are packed one bit per pixel, eight pixels per byte, most + /// significant bit first. For each bit, in order: a data bit of 1 with a mask bit of 1 draws black, a data bit of + /// 0 with a mask bit of 1 draws white, and a mask bit of 0 leaves the pixel transparent regardless of the data bit. + /// + /// For a full color cursor, use instead. + /// + /// The bitmap data, packed one bit per pixel, most significant bit first. + /// The bitmap mask, packed one bit per pixel, most significant bit first. + /// The cursor size in pixels. must be a multiple of 8. + /// The cursor hotspot. + /// or is negative or zero. + /// + /// is not a multiple of 8, or or is not + /// exactly / 8 * bytes long. + /// + /// Failed to create the cursor. + public Cursor(ReadOnlySpan data, ReadOnlySpan mask, Size size, Point hotspot) : this(data, mask, size.Width, size.Height, hotspot) + { + } + + /// + /// Creates a monochrome (black and white) cursor from bitmap data and a mask. + /// + /// + /// + /// and are packed one bit per pixel, eight pixels per byte, most + /// significant bit first. For each bit, in order: a data bit of 1 with a mask bit of 1 draws black, a data bit of + /// 0 with a mask bit of 1 draws white, and a mask bit of 0 leaves the pixel transparent regardless of the data bit. + /// + /// For a full color cursor, use instead. + /// + /// The bitmap data, packed one bit per pixel, most significant bit first. + /// The bitmap mask, packed one bit per pixel, most significant bit first. + /// The cursor width in pixels. Must be a multiple of 8. + /// The cursor height in pixels. + /// The cursor hotspot. + /// or is negative or zero. + /// + /// is not a multiple of 8, or or is not + /// exactly / 8 * bytes long. + /// + /// Failed to create the cursor. + public Cursor(ReadOnlySpan data, ReadOnlySpan mask, int width, int height, Point hotspot) + : this(CreateMonochrome(data, mask, width, height, hotspot), true) + { + } + + /// + /// Creates an animated cursor that cycles through a sequence of color frames. + /// + /// + /// + /// The hotspot applies to every frame, and all frames must share the same dimensions. Alternatively, set + /// on the first frame's image to override . + /// + /// + /// If a frame's image contains alternate images added with , + /// they are interpreted the same way as with . + /// + /// To create a one-shot animation that stops on the last frame, set that frame's duration to . + /// + /// The sequence of frames to animate, in order. + /// The cursor hotspot, shared by every frame. + /// is empty. + /// Failed to create the cursor. + public Cursor(ReadOnlySpan frames, Point hotspot) : this(CreateAnimated(frames, hotspot), true) + { + } + + private Cursor(SDL_Cursor* handle, bool owned) + { + QuackEngine.EnsureInitialized(Subsystem.Video); + + SDLThrowHelper.ThrowIfNull(handle); + + Handle = handle; + _owned = owned; + } + + /// + /// Gets the active cursor. + /// + public static Cursor Current => new(SDL3.GetCursor(), false); + + /// + /// Gets the default cursor. + /// + public static Cursor Default { get; } = new(SDL3.GetDefaultCursor(), false); + + /// + /// Gets or sets a value indicating whether the active cursor is visible. + /// + /// Thrown when failed to change the cursor visibility. + public static bool Visible + { + get; + set + { + if (field == value) + return; + + SDLThrowHelper.ThrowIfFailed(value ? SDL3.ShowCursor() : SDL3.HideCursor()); + field = value; + } + } = true; + + internal SDL_Cursor* Handle { get; private set; } + + /// + public void Dispose() + { + if (Handle is null || !_owned) + return; + + SDL3.DestroyCursor(Handle); + Handle = null; + } + + /// + /// Creates a color cursor from an image file. + /// + /// The image is loaded, used to build the cursor, and disposed; the cursor does not depend on the file afterwards. + /// The path to the image file. + /// The cursor hotspot. + /// The newly created cursor. + /// The file does not exist. + /// Failed to load the image or create the cursor. + public static Cursor FromFile(string path, Point hotspot) + { + using Surface surface = Surface.FromFile(path); + return new Cursor(surface, hotspot); + } + + /// + /// Creates a color cursor from an image stream. + /// + /// The image is loaded, used to build the cursor, and disposed; the cursor does not depend on the stream afterwards. + /// The stream to read the image from. + /// The cursor hotspot. + /// The newly created cursor. + /// Failed to load the image or create the cursor. + public static Cursor FromStream(Stream stream, Point hotspot) + { + using Surface surface = Surface.FromStream(stream); + return new Cursor(surface, hotspot); + } + + private static SDL_Cursor* CreateMonochrome(ReadOnlySpan data, ReadOnlySpan mask, int width, int height, Point hotspot) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + + if (width % 8 != 0) + throw new ArgumentException("Width must be a multiple of 8.", nameof(width)); + + int expected = width / 8 * height; + + ArgumentOutOfRangeException.ThrowIfNotEqual(data.Length, expected); + ArgumentOutOfRangeException.ThrowIfNotEqual(mask.Length, expected); + + return SDL3.CreateCursor(data, mask, width, height, hotspot.X, hotspot.Y); + } + + private static SDL_Cursor* CreateAnimated(ReadOnlySpan frames, Point hotspot) + { + ArgumentOutOfRangeException.ThrowIfZero(frames.Length); + + SDL_CursorFrameInfo[] info = new SDL_CursorFrameInfo[frames.Length]; + + for (int i = 0; i < frames.Length; i++) + { + CursorFrame frame = frames[i]; + uint duration = frame.Duration > TimeSpan.Zero ? (uint)frame.Duration.TotalMilliseconds : 0; + + info[i] = new SDL_CursorFrameInfo(frame.Surface.Handle, duration); + } + + return SDL3.CreateAnimatedCursor(info, info.Length, hotspot.X, hotspot.Y); + } +} diff --git a/src/KappaDuck.Quack/Input/CursorFrame.cs b/src/KappaDuck.Quack/Input/CursorFrame.cs new file mode 100644 index 0000000..801a60e --- /dev/null +++ b/src/KappaDuck.Quack/Input/CursorFrame.cs @@ -0,0 +1,30 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +using KappaDuck.Quack.Video.Pixels; + +namespace KappaDuck.Quack.Input; + +/// +/// Represents a single frame of an animated cursor. +/// +/// +/// Every frame of an animation must share the same dimensions. +/// +/// The image used for this frame. +/// +/// How long the frame is shown before advancing to the next one, rounded down to the nearest millisecond. A +/// duration of or less means the frame never advances, ending the animation on that frame. +/// +public readonly struct CursorFrame(Surface surface, TimeSpan duration) +{ + /// + /// Gets the image used for this frame. + /// + public Surface Surface { get; } = surface; + + /// + /// Gets how long the frame is shown before advancing to the next one. + /// + public TimeSpan Duration { get; } = duration; +} diff --git a/src/KappaDuck.Quack/Input/CursorType.cs b/src/KappaDuck.Quack/Input/CursorType.cs new file mode 100644 index 0000000..f36b582 --- /dev/null +++ b/src/KappaDuck.Quack/Input/CursorType.cs @@ -0,0 +1,179 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +namespace KappaDuck.Quack.Input; + +/// +/// Cursor types. +/// +public enum CursorType +{ + /// + /// Default cursor. Usually an arrow. + /// + Default = 0, + + /// + /// Text selection. Usually an I-beam. + /// + Text = 1, + + /// + /// Wait. Usually an hourglass or watch or spinning ball. + /// + Wait = 2, + + /// + /// Crosshair. + /// + Crosshair = 3, + + /// + /// Program is busy but still interactive. Usually it's WAIT with an arrow. + /// + Progress = 4, + + /// + /// Double arrow pointing northwest and southeast. + /// + DoubleResizeNorthWestSouthEast = 5, + + /// + /// Double arrow pointing northeast and southwest. + /// + DoubleResizeNorthEastSouthWest = 6, + + /// + /// Double arrow pointing west and east. + /// + DoubleResizeEastWest = 7, + + /// + /// Double arrow pointing north and south. + /// + DoubleResizeNorthSouth = 8, + + /// + /// Four pointed arrow pointing north, south, east, and west. + /// + Move = 9, + /// + /// Not permitted. Usually a slashed circle or crossbones. + /// + NotAllowed = 10, + + /// + /// Pointer that indicates a link. Usually a pointing hand. + /// + PointingHand = 11, + + /// + /// Window resize top-left. This may be a single arrow or a double arrow like . + /// + ResizeNorthWestSouthEast = 12, + + /// + /// Window resize top. May be . + /// + ResizeNorthSouth = 13, + + /// + /// Window resize top-right. May be . + /// + ResizeNorthEast = 14, + + /// + /// Window resize right. May be . + /// + ResizeEastWest = 15, + + /// + /// Window resize bottom-right. May be . + /// + ResizeSouthEast = 16, + + /// + /// Window resize bottom. May be . + /// + ResizeSouth = 17, + + /// + /// Window resize bottom-left. May be . + /// + ResizeSouthWest = 18, + + /// + /// Window resize left. May be . + /// + ResizeWest = 19, + + /// + /// A context menu is available for the object under the cursor. + /// + ContextMenu = 20, + + /// + /// Help is available for the object under the cursor. + /// + Help = 21, + + /// + /// A set of cells may be selected. + /// + Cell = 22, + + /// + /// Text selection. May be . + /// + VerticalText = 23, + + /// + /// A shortcut is to be created. + /// + Alias = 24, + + /// + /// Something is to be copied. + /// + Copy = 25, + + /// + /// The dragged item cannot be dropped at this location. May be . + /// + NoDrop = 26, + + /// + /// The object under the cursor can be grabbed. + /// + Grab = 27, + + /// + /// An object is currently being grabbed. + /// + Grabbing = 28, + + /// + /// Column resize. May be . + /// + ResizeColumn = 29, + + /// + /// Row resize. May be . + /// + ResizeRow = 30, + + /// + /// Four pointed arrow pointing north, south, east, and west, indicating that scrolling is available in every direction. + /// + AllScroll = 31, + + /// + /// Zoom in. + /// + ZoomIn = 32, + + /// + /// Zoom out. + /// + ZoomOut = 33 +} diff --git a/src/KappaDuck.Quack/Input/Mouse.cs b/src/KappaDuck.Quack/Input/Mouse.cs index 7b4b4b2..9cc0d11 100644 --- a/src/KappaDuck.Quack/Input/Mouse.cs +++ b/src/KappaDuck.Quack/Input/Mouse.cs @@ -90,16 +90,6 @@ public static MouseState State } } - /// - /// Gets or sets a value indicating whether the cursor is visible. - /// - /// Thrown when failed to change the cursor visibility. - public static bool Visible - { - get => SDL3.CursorVisible(); - set => SDLThrowHelper.ThrowIfFailed(value ? SDL3.ShowCursor() : SDL3.HideCursor()); - } - /// /// Capture the mouse and to track input outside the window. /// @@ -150,6 +140,12 @@ public static bool Visible /// if the button is released; otherwise, . public static bool IsUp(MouseButton button) => State.IsUp(button); + /// + /// Changes the active cursor. + /// + /// The new active cursor to set + public static void SetCursor(Cursor cursor) => SDL3.SetCursor(cursor.Handle); + /// /// Installs a transform applied to all relative mouse motion, replacing any previous transform. /// @@ -175,6 +171,11 @@ public static void SetRelativeMotionTransform(MouseMotionTransform? transform) SDLThrowHelper.ThrowIfFailed(SDL3.SetRelativeMouseTransform(&OnTransform, null)); } + /// + /// Reset the active cursor to + /// + public static void ResetCursor() => SetCursor(Cursor.Default); + /// /// Moves the mouse cursor to the given position in global screen space. /// diff --git a/src/KappaDuck.Quack/Interop/SDL/Primitives/SDL_Cursor.cs b/src/KappaDuck.Quack/Interop/SDL/Primitives/SDL_Cursor.cs new file mode 100644 index 0000000..2a0c3f6 --- /dev/null +++ b/src/KappaDuck.Quack/Interop/SDL/Primitives/SDL_Cursor.cs @@ -0,0 +1,19 @@ +// Copyright (c) KappaDuck. +// Licensed under the MIT license. + +namespace KappaDuck.Quack.Interop.SDL.Primitives; + +internal readonly struct SDL_Cursor; + +[StructLayout(LayoutKind.Sequential)] +internal readonly struct SDL_CursorFrameInfo +{ + private readonly SDL_Surface* _surface; + private readonly uint _duration; + + internal SDL_CursorFrameInfo(SDL_Surface* surface, uint duration) + { + _surface = surface; + _duration = duration; + } +} diff --git a/src/KappaDuck.Quack/Interop/SDL/SDL3.Input.cs b/src/KappaDuck.Quack/Interop/SDL/SDL3.Input.cs index b376f52..60535ff 100644 --- a/src/KappaDuck.Quack/Interop/SDL/SDL3.Input.cs +++ b/src/KappaDuck.Quack/Interop/SDL/SDL3.Input.cs @@ -3,6 +3,7 @@ using KappaDuck.Quack.Input; using KappaDuck.Quack.Interop.SDL.Marshalling; +using KappaDuck.Quack.Interop.SDL.Primitives; namespace KappaDuck.Quack.Interop.SDL; @@ -13,11 +14,39 @@ internal static partial class SDL3 [return: MarshalAs(UnmanagedType.I1)] internal static partial bool CaptureMouse([MarshalAs(UnmanagedType.I1)] bool enabled); + [LibraryImport(nameof(SDL3), EntryPoint = "SDL_CreateAnimatedCursor")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial SDL_Cursor* CreateAnimatedCursor(ReadOnlySpan frames, int frameCount, int hotX, int hotY); + + [LibraryImport(nameof(SDL3), EntryPoint = "SDL_CreateColorCursor")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial SDL_Cursor* CreateColorCursor(SDL_Surface* surface, int hotX, int hotY); + + [LibraryImport(nameof(SDL3), EntryPoint = "SDL_CreateCursor")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial SDL_Cursor* CreateCursor(ReadOnlySpan data, ReadOnlySpan mask, int width, int height, int hotX, int hotY); + + [LibraryImport(nameof(SDL3), EntryPoint = "SDL_CreateSystemCursor")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial SDL_Cursor* CreateSystemCursor(CursorType type); + [LibraryImport(nameof(SDL3), EntryPoint = "SDL_CursorVisible")] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] [return: MarshalAs(UnmanagedType.I1)] internal static partial bool CursorVisible(); + [LibraryImport(nameof(SDL3), EntryPoint = "SDL_DestroyCursor")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial void DestroyCursor(SDL_Cursor* cursor); + + [LibraryImport(nameof(SDL3), EntryPoint = "SDL_GetCursor")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial SDL_Cursor* GetCursor(); + + [LibraryImport(nameof(SDL3), EntryPoint = "SDL_GetDefaultCursor")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial SDL_Cursor* GetDefaultCursor(); + [LibraryImport(nameof(SDL3), EntryPoint = "SDL_GetGlobalMouseState")] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] internal static partial MouseButtonState GetGlobalMouseState(out float x, out float y); @@ -109,6 +138,11 @@ internal static partial class SDL3 [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] internal static partial void ResetKeyboard(); + [LibraryImport(nameof(SDL3), EntryPoint = "SDL_SetCursor")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalAs(UnmanagedType.I1)] + internal static partial bool SetCursor(SDL_Cursor* cursor); + [LibraryImport(nameof(SDL3), EntryPoint = "SDL_SetModState")] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] internal static partial void SetModState(Keymod modifier);