From fa28932deaa53fe5e14f2bb2fc95f1f0cab5cf9d Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 12 Sep 2023 18:37:59 -0400
Subject: [PATCH] Implementing custom cursor modes for better interface
 readability.

---
 source/funkin/input/Cursor.hx                 | 300 ++++++++++++++++--
 source/funkin/play/song/SongMigrator.hx       |   2 +-
 .../charting/ChartEditorDialogHandler.hx      |  28 +-
 .../ui/debug/charting/ChartEditorState.hx     |  99 ++++--
 .../ui/haxeui/components/CharacterPlayer.hx   |  12 +-
 .../ui/haxeui/components/FunkinButton.hx      |  30 ++
 .../components/FunkinHorizontalSlider.hx      |  30 ++
 .../funkin/ui/haxeui/components/FunkinLink.hx |  30 ++
 .../ui/haxeui/components/FunkinMenuBar.hx     |  40 +++
 .../haxeui/components/FunkinMenuCheckBox.hx   |  30 ++
 .../ui/haxeui/components/FunkinMenuItem.hx    |  30 ++
 .../haxeui/components/FunkinMenuOptionBox.hx  |  30 ++
 12 files changed, 594 insertions(+), 67 deletions(-)
 create mode 100644 source/funkin/ui/haxeui/components/FunkinButton.hx
 create mode 100644 source/funkin/ui/haxeui/components/FunkinHorizontalSlider.hx
 create mode 100644 source/funkin/ui/haxeui/components/FunkinLink.hx
 create mode 100644 source/funkin/ui/haxeui/components/FunkinMenuBar.hx
 create mode 100644 source/funkin/ui/haxeui/components/FunkinMenuCheckBox.hx
 create mode 100644 source/funkin/ui/haxeui/components/FunkinMenuItem.hx
 create mode 100644 source/funkin/ui/haxeui/components/FunkinMenuOptionBox.hx

diff --git a/source/funkin/input/Cursor.hx b/source/funkin/input/Cursor.hx
index 37e819469..edd9e70f3 100644
--- a/source/funkin/input/Cursor.hx
+++ b/source/funkin/input/Cursor.hx
@@ -4,9 +4,34 @@ import openfl.utils.Assets;
 import lime.app.Future;
 import openfl.display.BitmapData;
 
+@:nullSafety
 class Cursor
 {
-  public static var cursorMode(default, set):CursorMode;
+  /**
+   * The current cursor mode.
+   * Set this value to change the cursor graphic.
+   */
+  public static var cursorMode(default, set):Null<CursorMode> = null;
+
+  /**
+   * Show the cursor.
+   */
+  public static inline function show():Void
+  {
+    FlxG.mouse.visible = true;
+    // Reset the cursor mode.
+    Cursor.cursorMode = Default;
+  }
+
+  /**
+   * Hide the cursor.
+   */
+  public static inline function hide():Void
+  {
+    FlxG.mouse.visible = false;
+    // Reset the cursor mode.
+    Cursor.cursorMode = null;
+  }
 
   static final CURSOR_DEFAULT_PARAMS:CursorParams =
     {
@@ -15,7 +40,7 @@ class Cursor
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorDefault:BitmapData = null;
+  static var assetCursorDefault:Null<BitmapData> = null;
 
   static final CURSOR_CROSS_PARAMS:CursorParams =
     {
@@ -24,7 +49,7 @@ class Cursor
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorCross:BitmapData = null;
+  static var assetCursorCross:Null<BitmapData> = null;
 
   static final CURSOR_ERASER_PARAMS:CursorParams =
     {
@@ -33,16 +58,16 @@ class Cursor
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorEraser:BitmapData = null;
+  static var assetCursorEraser:Null<BitmapData> = null;
 
   static final CURSOR_GRABBING_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-grabbing.png",
       scale: 1.0,
-      offsetX: 32,
+      offsetX: -8,
       offsetY: 0,
     };
-  static var assetCursorGrabbing:BitmapData = null;
+  static var assetCursorGrabbing:Null<BitmapData> = null;
 
   static final CURSOR_HOURGLASS_PARAMS:CursorParams =
     {
@@ -51,25 +76,34 @@ class Cursor
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorHourglass:BitmapData = null;
+  static var assetCursorHourglass:Null<BitmapData> = null;
 
   static final CURSOR_POINTER_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-pointer.png",
       scale: 1.0,
-      offsetX: 8,
+      offsetX: -8,
       offsetY: 0,
     };
-  static var assetCursorPointer:BitmapData = null;
+  static var assetCursorPointer:Null<BitmapData> = null;
 
   static final CURSOR_TEXT_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-text.png",
-      scale: 1.0,
+      scale: 0.2,
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorText:BitmapData = null;
+  static var assetCursorText:Null<BitmapData> = null;
+
+  static final CURSOR_TEXT_VERTICAL_PARAMS:CursorParams =
+    {
+      graphic: "assets/images/cursor/cursor-text-vertical.png",
+      scale: 0.2,
+      offsetX: 0,
+      offsetY: 0,
+    };
+  static var assetCursorTextVertical:Null<BitmapData> = null;
 
   static final CURSOR_ZOOM_IN_PARAMS:CursorParams =
     {
@@ -78,7 +112,7 @@ class Cursor
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorZoomIn:BitmapData = null;
+  static var assetCursorZoomIn:Null<BitmapData> = null;
 
   static final CURSOR_ZOOM_OUT_PARAMS:CursorParams =
     {
@@ -87,11 +121,36 @@ class Cursor
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorZoomOut:BitmapData = null;
+  static var assetCursorZoomOut:Null<BitmapData> = null;
 
-  static function set_cursorMode(value:CursorMode):CursorMode
+  static final CURSOR_CROSSHAIR_PARAMS:CursorParams =
+    {
+      graphic: "assets/images/cursor/cursor-crosshair.png",
+      scale: 1.0,
+      offsetX: -16,
+      offsetY: -16,
+    };
+  static var assetCursorCrosshair:Null<BitmapData> = null;
+
+  static final CURSOR_CELL_PARAMS:CursorParams =
+    {
+      graphic: "assets/images/cursor/cursor-cell.png",
+      scale: 1.0,
+      offsetX: -16,
+      offsetY: -16,
+    };
+  static var assetCursorCell:Null<BitmapData> = null;
+
+  // DESIRED CURSOR: Resize NS (vertical)
+  // DESIRED CURSOR: Resize EW (horizontal)
+  // DESIRED CURSOR: Resize NESW (diagonal)
+  // DESIRED CURSOR: Resize NWSE (diagonal)
+  // DESIRED CURSOR: Help (Cursor with question mark)
+  // DESIRED CURSOR: Menu (Cursor with menu icon)
+
+  static function set_cursorMode(value:Null<CursorMode>):Null<CursorMode>
   {
-    if (cursorMode != value)
+    if (value != null && cursorMode != value)
     {
       cursorMode = value;
       setCursorGraphic(cursorMode);
@@ -99,16 +158,9 @@ class Cursor
     return cursorMode;
   }
 
-  public static inline function show():Void
-  {
-    FlxG.mouse.visible = true;
-  }
-
-  public static inline function hide():Void
-  {
-    FlxG.mouse.visible = false;
-  }
-
+  /**
+   * Synchronous.
+   */
   static function setCursorGraphic(?value:CursorMode = null):Void
   {
     if (value == null)
@@ -117,6 +169,156 @@ class Cursor
       return;
     }
 
+    switch (value)
+    {
+      case Default:
+        if (assetCursorDefault == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_DEFAULT_PARAMS.graphic);
+          assetCursorDefault = bitmapData;
+          applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS);
+        }
+
+      case Cross:
+        if (assetCursorCross == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_CROSS_PARAMS.graphic);
+          assetCursorCross = bitmapData;
+          applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS);
+        }
+
+      case Eraser:
+        if (assetCursorEraser == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_ERASER_PARAMS.graphic);
+          assetCursorEraser = bitmapData;
+          applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS);
+        }
+
+      case Grabbing:
+        if (assetCursorGrabbing == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_GRABBING_PARAMS.graphic);
+          assetCursorGrabbing = bitmapData;
+          applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS);
+        }
+
+      case Hourglass:
+        if (assetCursorHourglass == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_HOURGLASS_PARAMS.graphic);
+          assetCursorHourglass = bitmapData;
+          applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS);
+        }
+
+      case Pointer:
+        if (assetCursorPointer == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_POINTER_PARAMS.graphic);
+          assetCursorPointer = bitmapData;
+          applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS);
+        }
+
+      case Text:
+        if (assetCursorText == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_TEXT_PARAMS.graphic);
+          assetCursorText = bitmapData;
+          applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS);
+        }
+
+      case ZoomIn:
+        if (assetCursorZoomIn == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_ZOOM_IN_PARAMS.graphic);
+          assetCursorZoomIn = bitmapData;
+          applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS);
+        }
+
+      case ZoomOut:
+        if (assetCursorZoomOut == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_ZOOM_OUT_PARAMS.graphic);
+          assetCursorZoomOut = bitmapData;
+          applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS);
+        }
+
+      case Crosshair:
+        if (assetCursorCrosshair == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_CROSSHAIR_PARAMS.graphic);
+          assetCursorCrosshair = bitmapData;
+          applyCursorParams(assetCursorCrosshair, CURSOR_CROSSHAIR_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorCrosshair, CURSOR_CROSSHAIR_PARAMS);
+        }
+
+      case Cell:
+        if (assetCursorCell == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_CELL_PARAMS.graphic);
+          assetCursorCell = bitmapData;
+          applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS);
+        }
+
+      default:
+        setCursorGraphic(null);
+    }
+  }
+
+  /**
+   * Asynchronous.
+   */
+  static function loadCursorGraphic(?value:CursorMode = null):Void
+  {
+    if (value == null)
+    {
+      FlxG.mouse.unload();
+      return;
+    }
+
     switch (value)
     {
       case Default:
@@ -127,6 +329,7 @@ class Cursor
             assetCursorDefault = bitmapData;
             applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS);
           });
+          future.onError(onCursorError.bind(Default));
         }
         else
         {
@@ -141,6 +344,7 @@ class Cursor
             assetCursorCross = bitmapData;
             applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS);
           });
+          future.onError(onCursorError.bind(Cross));
         }
         else
         {
@@ -155,6 +359,7 @@ class Cursor
             assetCursorEraser = bitmapData;
             applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS);
           });
+          future.onError(onCursorError.bind(Eraser));
         }
         else
         {
@@ -169,6 +374,7 @@ class Cursor
             assetCursorGrabbing = bitmapData;
             applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS);
           });
+          future.onError(onCursorError.bind(Grabbing));
         }
         else
         {
@@ -183,6 +389,7 @@ class Cursor
             assetCursorHourglass = bitmapData;
             applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS);
           });
+          future.onError(onCursorError.bind(Hourglass));
         }
         else
         {
@@ -197,6 +404,7 @@ class Cursor
             assetCursorPointer = bitmapData;
             applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS);
           });
+          future.onError(onCursorError.bind(Pointer));
         }
         else
         {
@@ -211,6 +419,7 @@ class Cursor
             assetCursorText = bitmapData;
             applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS);
           });
+          future.onError(onCursorError.bind(Text));
         }
         else
         {
@@ -225,6 +434,7 @@ class Cursor
             assetCursorZoomIn = bitmapData;
             applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS);
           });
+          future.onError(onCursorError.bind(ZoomIn));
         }
         else
         {
@@ -239,14 +449,45 @@ class Cursor
             assetCursorZoomOut = bitmapData;
             applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS);
           });
+          future.onError(onCursorError.bind(ZoomOut));
         }
         else
         {
           applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS);
         }
 
+      case Crosshair:
+        if (assetCursorCrosshair == null)
+        {
+          var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_CROSSHAIR_PARAMS.graphic);
+          future.onComplete(function(bitmapData:BitmapData) {
+            assetCursorCrosshair = bitmapData;
+            applyCursorParams(assetCursorCrosshair, CURSOR_CROSSHAIR_PARAMS);
+          });
+          future.onError(onCursorError.bind(Crosshair));
+        }
+        else
+        {
+          applyCursorParams(assetCursorCrosshair, CURSOR_CROSSHAIR_PARAMS);
+        }
+
+      case Cell:
+        if (assetCursorCell == null)
+        {
+          var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_CELL_PARAMS.graphic);
+          future.onComplete(function(bitmapData:BitmapData) {
+            assetCursorCell = bitmapData;
+            applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS);
+          });
+          future.onError(onCursorError.bind(Cell));
+        }
+        else
+        {
+          applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS);
+        }
+
       default:
-        setCursorGraphic(null);
+        loadCursorGraphic(null);
     }
   }
 
@@ -254,6 +495,11 @@ class Cursor
   {
     FlxG.mouse.load(graphic, params.scale, params.offsetX, params.offsetY);
   }
+
+  static function onCursorError(cursorMode:CursorMode, error:String):Void
+  {
+    trace("Failed to load cursor graphic for cursor mode " + cursorMode + ": " + error);
+  }
 }
 
 // https://developer.mozilla.org/en-US/docs/Web/CSS/cursor
@@ -268,6 +514,8 @@ enum CursorMode
   Text;
   ZoomIn;
   ZoomOut;
+  Crosshair;
+  Cell;
 }
 
 /**
diff --git a/source/funkin/play/song/SongMigrator.hx b/source/funkin/play/song/SongMigrator.hx
index bb8718bb7..f33d9bbe9 100644
--- a/source/funkin/play/song/SongMigrator.hx
+++ b/source/funkin/play/song/SongMigrator.hx
@@ -179,7 +179,7 @@ class SongMigrator
     songMetadata.playData.playableChars = {};
     try
     {
-      Reflect.setField(songMetadata.playData.playableChars, songData.song.player1, new SongPlayableChar('', songData.song.player2));
+      songMetadata.playData.playableChars.set(songData.song.player1, new SongPlayableChar('', songData.song.player2));
     }
     catch (e)
     {
diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
index e5b2d332c..3d1819403 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
@@ -6,6 +6,7 @@ import funkin.util.SerializerUtil;
 import funkin.play.song.SongData.SongChartData;
 import funkin.play.song.SongData.SongMetadata;
 import flixel.util.FlxTimer;
+import funkin.ui.haxeui.components.FunkinLink;
 import funkin.util.SortUtil;
 import funkin.input.Cursor;
 import funkin.play.character.BaseCharacter;
@@ -122,7 +123,7 @@ class ChartEditorDialogHandler
         trace('[WARN] Could not fetch song name for ${targetSongId}');
       }
 
-      var linkTemplateSong:Link = new Link();
+      var linkTemplateSong:Link = new FunkinLink();
       linkTemplateSong.text = songName;
       linkTemplateSong.onClick = function(_event) {
         dialog.hideDialog(DialogButton.CANCEL);
@@ -742,6 +743,14 @@ class ChartEditorDialogHandler
         var songVariationMetadataEntryLabel:Label = songVariationMetadataEntry.findComponent('chartEntryLabel', Label);
         songVariationMetadataEntryLabel.text = 'Drag and drop <song>-metadata-${variation}.json file, or click to browse.';
 
+        songVariationMetadataEntry.onMouseOver = function(_event) {
+          songVariationMetadataEntry.swapClass('upload-bg', 'upload-bg-hover');
+          Cursor.cursorMode = Pointer;
+        }
+        songVariationMetadataEntry.onMouseOut = function(_event) {
+          songVariationMetadataEntry.swapClass('upload-bg-hover', 'upload-bg');
+          Cursor.cursorMode = Default;
+        }
         songVariationMetadataEntry.onClick = onClickMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel);
         addDropHandler(songVariationMetadataEntry, onDropFileMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel));
         chartContainerB.addComponent(songVariationMetadataEntry);
@@ -751,6 +760,14 @@ class ChartEditorDialogHandler
         var songVariationChartDataEntryLabel:Label = songVariationChartDataEntry.findComponent('chartEntryLabel', Label);
         songVariationChartDataEntryLabel.text = 'Drag and drop <song>-chart-${variation}.json file, or click to browse.';
 
+        songVariationChartDataEntry.onMouseOver = function(_event) {
+          songVariationChartDataEntry.swapClass('upload-bg', 'upload-bg-hover');
+          Cursor.cursorMode = Pointer;
+        }
+        songVariationChartDataEntry.onMouseOut = function(_event) {
+          songVariationChartDataEntry.swapClass('upload-bg-hover', 'upload-bg');
+          Cursor.cursorMode = Default;
+        }
         songVariationChartDataEntry.onClick = onClickChartDataVariation.bind(variation).bind(songVariationChartDataEntryLabel);
         addDropHandler(songVariationChartDataEntry, onDropFileChartDataVariation.bind(variation).bind(songVariationChartDataEntryLabel));
         chartContainerB.addComponent(songVariationChartDataEntry);
@@ -885,6 +902,14 @@ class ChartEditorDialogHandler
 
     metadataEntry.onClick = onClickMetadataVariation.bind(Constants.DEFAULT_VARIATION).bind(metadataEntryLabel);
     addDropHandler(metadataEntry, onDropFileMetadataVariation.bind(Constants.DEFAULT_VARIATION).bind(metadataEntryLabel));
+    metadataEntry.onMouseOver = function(_event) {
+      metadataEntry.swapClass('upload-bg', 'upload-bg-hover');
+      Cursor.cursorMode = Pointer;
+    }
+    metadataEntry.onMouseOut = function(_event) {
+      metadataEntry.swapClass('upload-bg-hover', 'upload-bg');
+      Cursor.cursorMode = Default;
+    }
 
     chartContainerA.addComponent(metadataEntry);
 
@@ -928,7 +953,6 @@ class ChartEditorDialogHandler
       importBox.swapClass('upload-bg', 'upload-bg-hover');
       Cursor.cursorMode = Pointer;
     }
-
     importBox.onMouseOut = function(_event) {
       importBox.swapClass('upload-bg-hover', 'upload-bg');
       Cursor.cursorMode = Default;
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 5090c791a..7f6e2b679 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1195,6 +1195,9 @@ class ChartEditorState extends HaxeUIState
     // Set the z-index of the HaxeUI.
     this.component.zIndex = 100;
 
+    // Show the mouse cursor.
+    Cursor.show();
+
     fixCamera();
 
     // Get rid of any music from the previous state.
@@ -2050,6 +2053,11 @@ class ChartEditorState extends HaxeUIState
 
     if (shouldHandleCursor)
     {
+      // Over the course of this big conditional block,
+      // we determine what the cursor should look like,
+      // and fall back to the default cursor if none of the conditions are met.
+      var targetCursorMode:Null<CursorMode> = null;
+
       if (gridTiledSprite == null) throw "ERROR: Tried to handle cursor, but gridTiledSprite is null! Check ChartEditorState.buildGrid()";
 
       var overlapsGrid:Bool = FlxG.mouse.overlaps(gridTiledSprite);
@@ -2059,9 +2067,9 @@ class ChartEditorState extends HaxeUIState
       var cursorY:Float = FlxG.mouse.screenY - gridTiledSprite.y;
 
       var overlapsSelectionBorder:Bool = overlapsGrid
-        && (cursorX % 40) < (GRID_SELECTION_BORDER_WIDTH / 2)
+        && ((cursorX % 40) < (GRID_SELECTION_BORDER_WIDTH / 2)
           || (cursorX % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2))
-            || (cursorY % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) || (cursorY % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2));
+            || (cursorY % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) || (cursorY % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2)));
 
       if (FlxG.mouse.justPressed)
       {
@@ -2077,6 +2085,8 @@ class ChartEditorState extends HaxeUIState
         else if (!overlapsGrid || overlapsSelectionBorder)
         {
           selectionBoxStartPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY);
+          // Drawing selection box.
+          targetCursorMode = Crosshair;
         }
         else
         {
@@ -2087,23 +2097,6 @@ class ChartEditorState extends HaxeUIState
         }
       }
 
-      if (gridPlayheadScrollAreaPressed)
-      {
-        Cursor.cursorMode = Grabbing;
-      }
-      else if (notePreviewScrollAreaStartPos != null)
-      {
-        Cursor.cursorMode = Pointer;
-      }
-      else if (gridPlayheadScrollArea != null && FlxG.mouse.overlaps(gridPlayheadScrollArea))
-      {
-        Cursor.cursorMode = Pointer;
-      }
-      else
-      {
-        Cursor.cursorMode = Default;
-      }
-
       if (gridPlayheadScrollAreaPressed && FlxG.mouse.released)
       {
         gridPlayheadScrollAreaPressed = false;
@@ -2120,6 +2113,9 @@ class ChartEditorState extends HaxeUIState
         // Move the playhead to the cursor position.
         this.playheadPositionInPixels = FlxG.mouse.screenY - MENU_BAR_HEIGHT - GRID_TOP_PAD;
         moveSongToScrollPosition();
+
+        // Cursor should be a grabby hand.
+        if (targetCursorMode == null) targetCursorMode = Grabbing;
       }
 
       // The song position of the cursor, in steps.
@@ -2272,6 +2268,8 @@ class ChartEditorState extends HaxeUIState
             selectionRect.width = Math.abs(FlxG.mouse.screenX - selectionBoxStartPos.x);
             selectionRect.height = Math.abs(FlxG.mouse.screenY - selectionBoxStartPos.y);
             setSelectionBoxBounds(selectionRect);
+
+            targetCursorMode = Crosshair;
           }
         }
         else if (FlxG.mouse.justReleased)
@@ -2363,7 +2361,9 @@ class ChartEditorState extends HaxeUIState
       }
       else if (notePreviewScrollAreaStartPos != null)
       {
-        trace('Updating current song time while clicking and holding...');
+        // Player is clicking and holding on note preview to scrub around.
+        targetCursorMode = Grabbing;
+
         var clickedPosInPixels:Float = FlxMath.remapToRange(FlxG.mouse.screenY, (notePreview?.y ?? 0.0),
           (notePreview?.y ?? 0.0) + (notePreview?.height ?? 0.0), 0, songLengthInPixels);
 
@@ -2531,8 +2531,6 @@ class ChartEditorState extends HaxeUIState
         // Handle grid cursor.
         if (overlapsGrid && !overlapsSelectionBorder && !gridPlayheadScrollAreaPressed)
         {
-          Cursor.cursorMode = Pointer;
-
           // Indicate that we can place a note here.
 
           if (cursorColumn == eventColumn)
@@ -2552,6 +2550,8 @@ class ChartEditorState extends HaxeUIState
             gridGhostEvent.visible = true;
             gridGhostEvent.eventData = eventData;
             gridGhostEvent.updateEventPosition(renderedEvents);
+
+            targetCursorMode = Cell;
           }
           else
           {
@@ -2573,30 +2573,56 @@ class ChartEditorState extends HaxeUIState
             gridGhostNote.visible = true;
             gridGhostNote.noteData = noteData;
             gridGhostNote.updateNotePosition(renderedNotes);
-          }
 
-          // gridCursor.visible = true;
-          // // X and Y are the cursor position relative to the grid, snapped to the top left of the grid square.
-          // gridCursor.x = Math.floor(cursorX / GRID_SIZE) * GRID_SIZE + gridTiledSprite.x + (GRID_SELECTION_BORDER_WIDTH / 2);
-          // gridCursor.y = cursorStep * GRID_SIZE + gridTiledSprite.y + (GRID_SELECTION_BORDER_WIDTH / 2);
+            targetCursorMode = Cell;
+          }
         }
         else
         {
           if (gridGhostNote != null) gridGhostNote.visible = false;
           if (gridGhostEvent != null) gridGhostEvent.visible = false;
-          Cursor.cursorMode = Default;
         }
       }
+
+      if (targetCursorMode == null)
+      {
+        if (FlxG.mouse.pressed)
+        {
+          if (overlapsSelectionBorder)
+          {
+            targetCursorMode = Crosshair;
+          }
+        }
+        else
+        {
+          if (FlxG.mouse.overlaps(notePreview))
+          {
+            targetCursorMode = Pointer;
+          }
+          else if (FlxG.mouse.overlaps(gridPlayheadScrollArea))
+          {
+            targetCursorMode = Pointer;
+          }
+          else if (overlapsSelectionBorder)
+          {
+            targetCursorMode = Crosshair;
+          }
+          else if (overlapsGrid)
+          {
+            targetCursorMode = Cell;
+          }
+        }
+      }
+
+      // Actually set the cursor mode to the one we specified earlier.
+      Cursor.cursorMode = targetCursorMode ?? Default;
     }
     else
     {
       if (gridGhostNote != null) gridGhostNote.visible = false;
       if (gridGhostEvent != null) gridGhostEvent.visible = false;
-    }
 
-    if (isCursorOverHaxeUIButton && Cursor.cursorMode == Default)
-    {
-      Cursor.cursorMode = Pointer;
+      // Do not set Cursor.cursorMode here, because it will be set by the HaxeUI.
     }
   }
 
@@ -2842,10 +2868,10 @@ class ChartEditorState extends HaxeUIState
       }
 
       // Sort the notes DESCENDING. This keeps the sustain behind the associated note.
-      renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING);
+      renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort()
 
       // Sort the events DESCENDING. This keeps the sustain behind the associated note.
-      renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING);
+      renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort()
     }
 
     // Add a debug value which displays the current size of the note pool.
@@ -4159,10 +4185,12 @@ class ChartEditorState extends HaxeUIState
 
   function sortChartData():Void
   {
+    // TODO: .insertionSort()
     currentSongChartNoteData.sort(function(a:SongNoteData, b:SongNoteData):Int {
       return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time);
     });
 
+    // TODO: .insertionSort()
     currentSongChartEventData.sort(function(a:SongEventData, b:SongEventData):Int {
       return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time);
     });
@@ -4210,6 +4238,9 @@ class ChartEditorState extends HaxeUIState
 
     cleanupAutoSave();
 
+    // Hide the mouse cursor on other states.
+    Cursor.hide();
+
     @:privateAccess
     ChartEditorNoteSprite.noteFrameCollection = null;
   }
diff --git a/source/funkin/ui/haxeui/components/CharacterPlayer.hx b/source/funkin/ui/haxeui/components/CharacterPlayer.hx
index c638e8a72..daeb17fc9 100644
--- a/source/funkin/ui/haxeui/components/CharacterPlayer.hx
+++ b/source/funkin/ui/haxeui/components/CharacterPlayer.hx
@@ -30,7 +30,7 @@ typedef AnimationInfo =
 @:composite(Layout)
 class CharacterPlayer extends Box
 {
-  var character:BaseCharacter;
+  var character:Null<BaseCharacter>;
 
   public function new(defaultToBf:Bool = true)
   {
@@ -47,7 +47,7 @@ class CharacterPlayer extends Box
 
   function get_charId():String
   {
-    return character.characterId;
+    return character?.characterId ?? '';
   }
 
   function set_charId(value:String):String
@@ -60,7 +60,7 @@ class CharacterPlayer extends Box
 
   function get_charName():String
   {
-    return character.characterName;
+    return character?.characterName ?? "Unknown";
   }
 
   // possible haxeui bug: if listener is added after event is dispatched, event is "lost"... is it smart to "collect and redispatch"? Not sure
@@ -86,7 +86,11 @@ class CharacterPlayer extends Box
 
     // Prevent script issues by fetching with debug=true.
     var newCharacter:BaseCharacter = CharacterDataParser.fetchCharacter(id, true);
-    if (newCharacter == null) return; // Fail if character doesn't exist.
+    if (newCharacter == null)
+    {
+      character = null;
+      return; // Fail if character doesn't exist.
+    }
 
     // Assign character.
     character = newCharacter;
diff --git a/source/funkin/ui/haxeui/components/FunkinButton.hx b/source/funkin/ui/haxeui/components/FunkinButton.hx
new file mode 100644
index 000000000..45987b9ec
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinButton.hx
@@ -0,0 +1,30 @@
+package funkin.ui.haxeui.components;
+
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.components.Button;
+
+/**
+ * A HaxeUI button which:
+ * - Changes the current cursor when hovered over.
+ */
+class FunkinButton extends Button
+{
+  public function new()
+  {
+    super();
+
+    this.onMouseOver = handleMouseOver;
+    this.onMouseOut = handleMouseOut;
+  }
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}
diff --git a/source/funkin/ui/haxeui/components/FunkinHorizontalSlider.hx b/source/funkin/ui/haxeui/components/FunkinHorizontalSlider.hx
new file mode 100644
index 000000000..baf42aada
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinHorizontalSlider.hx
@@ -0,0 +1,30 @@
+package funkin.ui.haxeui.components;
+
+import haxe.ui.components.HorizontalSlider;
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+
+/**
+ * A HaxeUI horizontal slider which:
+ * - Changes the current cursor when hovered over.
+ */
+class FunkinHorizontalSlider extends HorizontalSlider
+{
+  public function new()
+  {
+    super();
+
+    this.onMouseOver = handleMouseOver;
+    this.onMouseOut = handleMouseOut;
+  }
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}
diff --git a/source/funkin/ui/haxeui/components/FunkinLink.hx b/source/funkin/ui/haxeui/components/FunkinLink.hx
new file mode 100644
index 000000000..74eb6e7c4
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinLink.hx
@@ -0,0 +1,30 @@
+package funkin.ui.haxeui.components;
+
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.components.Link;
+
+/**
+ * A HaxeUI link which:
+ * - Changes the current cursor when hovered over.
+ */
+class FunkinLink extends Link
+{
+  public function new()
+  {
+    super();
+
+    this.onMouseOver = handleMouseOver;
+    this.onMouseOut = handleMouseOut;
+  }
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}
diff --git a/source/funkin/ui/haxeui/components/FunkinMenuBar.hx b/source/funkin/ui/haxeui/components/FunkinMenuBar.hx
new file mode 100644
index 000000000..1dcfb770c
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinMenuBar.hx
@@ -0,0 +1,40 @@
+package funkin.ui.haxeui.components;
+
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.containers.menus.MenuBar;
+import haxe.ui.core.CompositeBuilder;
+
+/**
+ * A HaxeUI menu bar which:
+ * - Changes the current cursor when each button is hovered over.
+ */
+class FunkinMenuBar extends MenuBar
+{
+  public function new()
+  {
+    super();
+
+    registerListeners();
+  }
+
+  private function registerListeners():Void
+  {
+    var builder = cast(this._compositeBuilder, MenuBar.Builder);
+    for (button in builder._buttons)
+    {
+      button.onMouseOver = handleMouseOver;
+      button.onMouseOut = handleMouseOut;
+    }
+  }
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}
diff --git a/source/funkin/ui/haxeui/components/FunkinMenuCheckBox.hx b/source/funkin/ui/haxeui/components/FunkinMenuCheckBox.hx
new file mode 100644
index 000000000..263277c6f
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinMenuCheckBox.hx
@@ -0,0 +1,30 @@
+package funkin.ui.haxeui.components;
+
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.containers.menus.MenuCheckBox;
+
+/**
+ * A HaxeUI menu checkbox which:
+ * - Changes the current cursor when hovered over.
+ */
+class FunkinMenuCheckBox extends MenuCheckBox
+{
+  public function new()
+  {
+    super();
+
+    this.onMouseOver = handleMouseOver;
+    this.onMouseOut = handleMouseOut;
+  }
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}
diff --git a/source/funkin/ui/haxeui/components/FunkinMenuItem.hx b/source/funkin/ui/haxeui/components/FunkinMenuItem.hx
new file mode 100644
index 000000000..2eb7db729
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinMenuItem.hx
@@ -0,0 +1,30 @@
+package funkin.ui.haxeui.components;
+
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.containers.menus.MenuItem;
+
+/**
+ * A HaxeUI menu item which:
+ * - Changes the current cursor when hovered over.
+ */
+class FunkinMenuItem extends MenuItem
+{
+  public function new()
+  {
+    super();
+
+    this.onMouseOver = handleMouseOver;
+    this.onMouseOut = handleMouseOut;
+  }
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}
diff --git a/source/funkin/ui/haxeui/components/FunkinMenuOptionBox.hx b/source/funkin/ui/haxeui/components/FunkinMenuOptionBox.hx
new file mode 100644
index 000000000..d9985eede
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinMenuOptionBox.hx
@@ -0,0 +1,30 @@
+package funkin.ui.haxeui.components;
+
+import haxe.ui.containers.menus.MenuOptionBox;
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+
+/**
+ * A HaxeUI menu option box which:
+ * - Changes the current cursor when hovered over.
+ */
+class FunkinMenuOptionBox extends MenuOptionBox
+{
+  public function new()
+  {
+    super();
+
+    this.onMouseOver = handleMouseOver;
+    this.onMouseOut = handleMouseOut;
+  }
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}