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);