From ac3f27edd48a6ad0f89a475148db8b97c47be006 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 11 Dec 2023 00:19:33 -0500
Subject: [PATCH 1/8] icon char select in progres

---
 assets                                        |  2 +-
 source/funkin/play/character/CharacterData.hx | 27 ++++++++
 .../ui/debug/charting/ChartEditorState.hx     | 62 +++++++++++++++++++
 source/funkin/ui/freeplay/SongMenuItem.hx     |  1 +
 4 files changed, 91 insertions(+), 1 deletion(-)

diff --git a/assets b/assets
index 3c8ac202b..32364eacf 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 3c8ac202bbb93bf84c7dcd0697a9d7121d3c5283
+Subproject commit 32364eacf09940cdba39457a2bb32ac1bca958be
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index abe8bf992..f1f5ebebe 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -280,6 +280,33 @@ class CharacterDataParser
     return characterCache.keys().array();
   }
 
+  public static function getCharPixelIconAsset(char:String):String
+  {
+    var icon:String = char;
+
+    switch (icon)
+    {
+      case "bf-christmas" | "bf-car" | "bf-pixel" | "bf-holding-gf":
+        icon = "bf";
+      case "monster-christmas":
+        icon = "monster";
+      case "mom-car":
+        icon = "mommy";
+      case "pico-blazin":
+        icon = "pico";
+      case "gf-christmas" | "gf-car" | "gf-pixel" | "gf-tankmen":
+        icon = "gf";
+      case "dad":
+        icon = "daddy";
+      case "darnell-blazin":
+        icon = "darnell";
+      case "senpai-angry":
+        icon = "senpai";
+    }
+
+    return Paths.image("freeplay/icons/" + icon + "pixel");
+  }
+
   /**
    * Clears the character data cache.
    */
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 66effc775..2cab863b8 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1,6 +1,9 @@
 package funkin.ui.debug.charting;
 
 import funkin.util.logging.CrashHandler;
+import haxe.ui.containers.HBox;
+import haxe.ui.containers.Grid;
+import haxe.ui.containers.ScrollView;
 import haxe.ui.containers.menus.MenuBar;
 import flixel.addons.display.FlxSliceSprite;
 import flixel.addons.display.FlxTiledSprite;
@@ -106,6 +109,7 @@ import haxe.ui.containers.menus.MenuItem;
 import haxe.ui.containers.menus.MenuCheckBox;
 import haxe.ui.containers.TreeView;
 import haxe.ui.containers.TreeViewNode;
+import haxe.ui.components.Image;
 import haxe.ui.core.Component;
 import haxe.ui.core.Screen;
 import haxe.ui.events.DragEvent;
@@ -114,6 +118,7 @@ import haxe.ui.events.UIEvent;
 import haxe.ui.events.UIEvent;
 import haxe.ui.focus.FocusManager;
 import openfl.display.BitmapData;
+import flixel.input.mouse.FlxMouseEvent;
 
 using Lambda;
 
@@ -2082,6 +2087,63 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     healthIconBF.flipX = true;
     add(healthIconBF);
     healthIconBF.zIndex = 30;
+
+    FlxMouseEvent.add(healthIconDad, function(_) {
+      trace("clicked dad");
+
+      var toolbox:CollapsibleDialog = cast haxe.ui.RuntimeComponentBuilder.fromAsset(Paths.ui('chart-editor/toolbox/iconselector'));
+      toolbox.showDialog(false);
+      var scrollView = toolbox.findComponent('charSelectScroll');
+
+      var hbox = new Grid();
+      hbox.columns = 5;
+      hbox.width = 100;
+      scrollView.addComponent(hbox);
+
+      var charIds:Array<String> = CharacterDataParser.listCharacterIds();
+
+      charIds.sort(function(a, b) {
+        var result:Int = 0;
+
+        if (a < b)
+        {
+          result = -1;
+        }
+        else if (a > b)
+        {
+          result = 1;
+        }
+
+        return result;
+      });
+
+      for (char in charIds)
+      {
+        var image = new haxe.ui.components.Button();
+        image.width = 70;
+        image.height = 70;
+
+        image.icon = CharacterDataParser.getCharPixelIconAsset(char);
+        image.onClick = _ -> {
+          toolbox.hideDialog(haxe.ui.containers.dialogs.Dialog.DialogButton.APPLY);
+          // var label = toolbox.findComponent('charIconName');
+          // label.text = char;
+        };
+
+        image.onMouseOver = _ -> {
+          var label = toolbox.findComponent('charIconName');
+          label.text = char;
+        };
+        hbox.addComponent(image);
+      }
+
+      toolbox.x = FlxG.mouse.screenX;
+      toolbox.y = FlxG.mouse.screenY;
+    });
+
+    FlxMouseEvent.add(healthIconBF, function(_) {
+      trace("clicked bf");
+    });
   }
 
   function buildNotePreview():Void
diff --git a/source/funkin/ui/freeplay/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx
index 477047c68..4e0772dfe 100644
--- a/source/funkin/ui/freeplay/SongMenuItem.hx
+++ b/source/funkin/ui/freeplay/SongMenuItem.hx
@@ -175,6 +175,7 @@ class SongMenuItem extends FlxSpriteGroup
     trace(char);
 
     // TODO: Put this in the character metadata where it belongs.
+    // TODO: Also, can use CharacterDataParser.getCharPixelIconAsset()
     switch (char)
     {
       case "monster-christmas":

From 181e3f85b851fd7a9e580a54b9e85251e1675cf8 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 11 Dec 2023 02:26:37 -0500
Subject: [PATCH 2/8] switching opp

---
 source/funkin/ui/debug/charting/ChartEditorState.hx | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 2cab863b8..b6f687c75 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -2123,9 +2123,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         image.width = 70;
         image.height = 70;
 
+        if (char == currentSongMetadata.playData.characters.opponent) image.selected = true;
+
         image.icon = CharacterDataParser.getCharPixelIconAsset(char);
         image.onClick = _ -> {
+          healthIconsDirty = true;
+          currentSongMetadata.playData.characters.opponent = char;
           toolbox.hideDialog(haxe.ui.containers.dialogs.Dialog.DialogButton.APPLY);
+
           // var label = toolbox.findComponent('charIconName');
           // label.text = char;
         };

From 9ffffe9c8afdde6ab256933ed1aa739b523e30e4 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 11 Dec 2023 02:40:58 -0500
Subject: [PATCH 3/8] player char select

---
 .../ui/debug/charting/ChartEditorState.hx     | 130 ++++++++++--------
 1 file changed, 74 insertions(+), 56 deletions(-)

diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index b6f687c75..d5f6fd9ae 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -2089,68 +2089,86 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     healthIconBF.zIndex = 30;
 
     FlxMouseEvent.add(healthIconDad, function(_) {
-      trace("clicked dad");
-
-      var toolbox:CollapsibleDialog = cast haxe.ui.RuntimeComponentBuilder.fromAsset(Paths.ui('chart-editor/toolbox/iconselector'));
-      toolbox.showDialog(false);
-      var scrollView = toolbox.findComponent('charSelectScroll');
-
-      var hbox = new Grid();
-      hbox.columns = 5;
-      hbox.width = 100;
-      scrollView.addComponent(hbox);
-
-      var charIds:Array<String> = CharacterDataParser.listCharacterIds();
-
-      charIds.sort(function(a, b) {
-        var result:Int = 0;
-
-        if (a < b)
-        {
-          result = -1;
-        }
-        else if (a > b)
-        {
-          result = 1;
-        }
-
-        return result;
-      });
-
-      for (char in charIds)
-      {
-        var image = new haxe.ui.components.Button();
-        image.width = 70;
-        image.height = 70;
-
-        if (char == currentSongMetadata.playData.characters.opponent) image.selected = true;
-
-        image.icon = CharacterDataParser.getCharPixelIconAsset(char);
-        image.onClick = _ -> {
-          healthIconsDirty = true;
-          currentSongMetadata.playData.characters.opponent = char;
-          toolbox.hideDialog(haxe.ui.containers.dialogs.Dialog.DialogButton.APPLY);
-
-          // var label = toolbox.findComponent('charIconName');
-          // label.text = char;
-        };
-
-        image.onMouseOver = _ -> {
-          var label = toolbox.findComponent('charIconName');
-          label.text = char;
-        };
-        hbox.addComponent(image);
-      }
-
-      toolbox.x = FlxG.mouse.screenX;
-      toolbox.y = FlxG.mouse.screenY;
+      createAndOpenCharSelect(1);
     });
 
     FlxMouseEvent.add(healthIconBF, function(_) {
-      trace("clicked bf");
+      createAndOpenCharSelect(0);
     });
   }
 
+  /**
+   * @param charType 0 == BF, 1 == Dad
+   */
+  function createAndOpenCharSelect(charType:Int = 0):Void
+  {
+    var charData = currentSongMetadata.playData.characters;
+    var currentChar:String = switch (charType)
+    {
+      case 0: charData.player;
+      case 1: charData.opponent;
+      default: throw 'Invalid charType: ' + charType;
+    };
+    var toolbox:CollapsibleDialog = cast haxe.ui.RuntimeComponentBuilder.fromAsset(Paths.ui('chart-editor/toolbox/iconselector'));
+    toolbox.showDialog(false);
+    var scrollView = toolbox.findComponent('charSelectScroll');
+
+    var hbox = new Grid();
+    hbox.columns = 5;
+    hbox.width = 100;
+    scrollView.addComponent(hbox);
+
+    var charIds:Array<String> = CharacterDataParser.listCharacterIds();
+
+    charIds.sort(function(a, b) {
+      var result:Int = 0;
+
+      if (a < b)
+      {
+        result = -1;
+      }
+      else if (a > b)
+      {
+        result = 1;
+      }
+
+      return result;
+    });
+
+    for (char in charIds)
+    {
+      var image = new haxe.ui.components.Button();
+      image.width = 70;
+      image.height = 70;
+
+      if (char == currentChar) image.selected = true;
+
+      image.icon = CharacterDataParser.getCharPixelIconAsset(char);
+      image.onClick = _ -> {
+        healthIconsDirty = true;
+        switch (charType)
+        {
+          case 0: currentSongMetadata.playData.characters.player = char;
+          case 1: currentSongMetadata.playData.characters.opponent = char;
+          default: throw 'Invalid charType: ' + charType;
+        };
+        toolbox.hideDialog(haxe.ui.containers.dialogs.Dialog.DialogButton.APPLY);
+
+        // var label = toolbox.findComponent('charIconName');
+        // label.text = char;
+      };
+
+      image.onMouseOver = _ -> {
+        var label = toolbox.findComponent('charIconName');
+        label.text = char;
+      };
+      hbox.addComponent(image);
+    }
+
+    toolbox.x = FlxG.mouse.screenX;
+    toolbox.y = FlxG.mouse.screenY;
+  }
+
   function buildNotePreview():Void
   {
     var height:Int = FlxG.height - MENU_BAR_HEIGHT - GRID_TOP_PAD - PLAYBAR_HEIGHT - GRID_TOP_PAD - GRID_TOP_PAD;

From 05f977f85fcf38987f9da2dd6c978be94127c06d Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 11 Dec 2023 03:38:44 -0500
Subject: [PATCH 4/8] character selector

---
 .../ui/debug/charting/ChartEditorState.hx     | 31 ++++++++++++++++---
 1 file changed, 26 insertions(+), 5 deletions(-)

diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index d5f6fd9ae..76247bde2 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -2089,11 +2089,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     healthIconBF.zIndex = 30;
 
     FlxMouseEvent.add(healthIconDad, function(_) {
-      createAndOpenCharSelect(1);
+      if (!isCursorOverHaxeUI) createAndOpenCharSelect(1);
     });
 
     FlxMouseEvent.add(healthIconBF, function(_) {
-      createAndOpenCharSelect(0);
+      if (!isCursorOverHaxeUI) createAndOpenCharSelect(0);
     });
   }
 
@@ -2110,7 +2110,28 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       default: throw 'Invalid charType: ' + charType;
     };
     var toolbox:CollapsibleDialog = cast haxe.ui.RuntimeComponentBuilder.fromAsset(Paths.ui('chart-editor/toolbox/iconselector'));
+    toolbox.title += " - " + switch (charType)
+    {
+      case 0: "Player";
+      case 1: "Opponent";
+      default: throw 'Invalid charType: ' + charType;
+    };
+
+    var _overlay = new Component();
+    _overlay.id = "modal-background";
+    _overlay.addClass("modal-background");
+    _overlay.percentWidth = _overlay.percentHeight = 100;
+    _overlay.opacity = 0;
+    FlxTween.tween(_overlay, {opacity: 0.2}, 0.1, {ease: FlxEase.quartOut});
+    _overlay.onClick = function(_) {
+      toolbox.hideDialog(haxe.ui.containers.dialogs.Dialog.DialogButton.CANCEL);
+      Screen.instance.removeComponent(_overlay);
+    };
+
+    Screen.instance.addComponent(_overlay);
+
     toolbox.showDialog(false);
+    toolbox.closable = false;
     var scrollView = toolbox.findComponent('charSelectScroll');
 
     var hbox = new Grid();
@@ -2153,7 +2174,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           default: throw 'Invalid charType: ' + charType;
         };
         toolbox.hideDialog(haxe.ui.containers.dialogs.Dialog.DialogButton.APPLY);
-
+        Screen.instance.removeComponent(_overlay);
         // var label = toolbox.findComponent('charIconName');
         // label.text = char;
       };
@@ -2165,8 +2186,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       hbox.addComponent(image);
     }
 
-    toolbox.x = FlxG.mouse.screenX;
-    toolbox.y = FlxG.mouse.screenY;
+    toolbox.x = FlxG.mouse.screenX - toolbox.width / 2;
+    toolbox.y = FlxG.mouse.screenY - 16;
   }
 
   function buildNotePreview():Void

From b33f27f0973c5eb12e3f2babb4c2041129d34c47 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 11 Dec 2023 04:35:39 -0500
Subject: [PATCH 5/8] tiny lil polish and cleanin

---
 .../ui/debug/charting/ChartEditorState.hx     | 44 ++++++++++++++-----
 1 file changed, 34 insertions(+), 10 deletions(-)

diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 76247bde2..e49dde838 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -2110,6 +2110,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       default: throw 'Invalid charType: ' + charType;
     };
     var toolbox:CollapsibleDialog = cast haxe.ui.RuntimeComponentBuilder.fromAsset(Paths.ui('chart-editor/toolbox/iconselector'));
+
+    toolbox.x = FlxG.mouse.screenX - toolbox.width / 2;
+    toolbox.y = FlxG.mouse.screenY - 16;
+
     toolbox.title += " - " + switch (charType)
     {
       case 0: "Player";
@@ -2118,21 +2122,38 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     };
 
     var _overlay = new Component();
+
+    var hideCharIconPicker = function() {
+      FlxTween.tween(_overlay, {opacity: 0}, 0.05,
+        {
+          ease: FlxEase.quartOut
+        });
+      FlxTween.tween(toolbox, {opacity: 0, y: toolbox.y + 10}, 0.1,
+        {
+          ease: FlxEase.quartOut,
+          onComplete: function(_) {
+            toolbox.hideDialog(haxe.ui.containers.dialogs.Dialog.DialogButton.CANCEL);
+            Screen.instance.removeComponent(_overlay);
+          }
+        });
+    };
+
     _overlay.id = "modal-background";
     _overlay.addClass("modal-background");
     _overlay.percentWidth = _overlay.percentHeight = 100;
     _overlay.opacity = 0;
     FlxTween.tween(_overlay, {opacity: 0.2}, 0.1, {ease: FlxEase.quartOut});
     _overlay.onClick = function(_) {
-      toolbox.hideDialog(haxe.ui.containers.dialogs.Dialog.DialogButton.CANCEL);
-      Screen.instance.removeComponent(_overlay);
+      hideCharIconPicker();
     };
 
     Screen.instance.addComponent(_overlay);
 
     toolbox.showDialog(false);
+    toolbox.opacity = 0;
+    FlxTween.tween(toolbox, {opacity: 1, y: toolbox.y + 10}, 0.1, {ease: FlxEase.quartOut});
     toolbox.closable = false;
-    var scrollView = toolbox.findComponent('charSelectScroll');
+    var scrollView:ScrollView = cast toolbox.findComponent('charSelectScroll');
 
     var hbox = new Grid();
     hbox.columns = 5;
@@ -2156,13 +2177,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       return result;
     });
 
-    for (char in charIds)
+    for (ind => char in charIds)
     {
       var image = new haxe.ui.components.Button();
       image.width = 70;
       image.height = 70;
+      image.iconPosition = "top";
+      image.text = char;
 
-      if (char == currentChar) image.selected = true;
+      if (char == currentChar)
+      {
+        scrollView.hscrollPos = Math.floor(ind / 5) * 80;
+        image.selected = true;
+      }
 
       image.icon = CharacterDataParser.getCharPixelIconAsset(char);
       image.onClick = _ -> {
@@ -2173,8 +2200,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           case 1: currentSongMetadata.playData.characters.opponent = char;
           default: throw 'Invalid charType: ' + charType;
         };
-        toolbox.hideDialog(haxe.ui.containers.dialogs.Dialog.DialogButton.APPLY);
-        Screen.instance.removeComponent(_overlay);
+        hideCharIconPicker();
+
         // var label = toolbox.findComponent('charIconName');
         // label.text = char;
       };
@@ -2185,9 +2212,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       };
       hbox.addComponent(image);
     }
-
-    toolbox.x = FlxG.mouse.screenX - toolbox.width / 2;
-    toolbox.y = FlxG.mouse.screenY - 16;
   }
 
   function buildNotePreview():Void

From 52f16ce457e57985228df1d81d07c24ec704009d Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 12 Dec 2023 05:24:43 -0500
Subject: [PATCH 6/8] Redo icon dialog and add support to metadata dialog.

---
 assets                                        |   2 +-
 source/funkin/play/character/CharacterData.hx |   7 +-
 .../ui/debug/charting/ChartEditorState.hx     | 253 +++---------------
 .../charting/dialogs/ChartEditorBaseDialog.hx |  35 +++
 .../charting/dialogs/ChartEditorBaseMenu.hx   |  24 ++
 .../ChartEditorCharacterIconSelectorMenu.hx   | 129 +++++++++
 .../dialogs/ChartEditorUploadChartDialog.hx   |   1 +
 .../handlers/ChartEditorDialogHandler.hx      |  11 +
 .../ChartEditorImportExportHandler.hx         |   2 +-
 .../handlers/ChartEditorToolboxHandler.hx     | 207 +++-----------
 .../toolboxes/ChartEditorBaseToolbox.hx       |  28 ++
 .../toolboxes/ChartEditorMetadataToolbox.hx   | 203 ++++++++++++++
 12 files changed, 511 insertions(+), 391 deletions(-)
 create mode 100644 source/funkin/ui/debug/charting/dialogs/ChartEditorBaseMenu.hx
 create mode 100644 source/funkin/ui/debug/charting/dialogs/ChartEditorCharacterIconSelectorMenu.hx
 create mode 100644 source/funkin/ui/debug/charting/toolboxes/ChartEditorBaseToolbox.hx
 create mode 100644 source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx

diff --git a/assets b/assets
index 32364eacf..e591e9acc 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 32364eacf09940cdba39457a2bb32ac1bca958be
+Subproject commit e591e9acc12b9aba6124332c4d66453f1f83368c
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index f1f5ebebe..16cc8b299 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -280,6 +280,9 @@ class CharacterDataParser
     return characterCache.keys().array();
   }
 
+  /**
+   * TODO: Hardcode this.
+   */
   public static function getCharPixelIconAsset(char:String):String
   {
     var icon:String = char;
@@ -290,9 +293,9 @@ class CharacterDataParser
         icon = "bf";
       case "monster-christmas":
         icon = "monster";
-      case "mom-car":
+      case "mom" | "mom-car":
         icon = "mommy";
-      case "pico-blazin":
+      case "pico-blazin" | "pico-playable" | "pico-speaker":
         icon = "pico";
       case "gf-christmas" | "gf-car" | "gf-pixel" | "gf-tankmen":
         icon = "gf";
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index e49dde838..2f7df7330 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -110,6 +110,7 @@ import haxe.ui.containers.menus.MenuCheckBox;
 import haxe.ui.containers.TreeView;
 import haxe.ui.containers.TreeViewNode;
 import haxe.ui.components.Image;
+import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
 import haxe.ui.core.Component;
 import haxe.ui.core.Screen;
 import haxe.ui.events.DragEvent;
@@ -2087,131 +2088,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     healthIconBF.flipX = true;
     add(healthIconBF);
     healthIconBF.zIndex = 30;
-
-    FlxMouseEvent.add(healthIconDad, function(_) {
-      if (!isCursorOverHaxeUI) createAndOpenCharSelect(1);
-    });
-
-    FlxMouseEvent.add(healthIconBF, function(_) {
-      if (!isCursorOverHaxeUI) createAndOpenCharSelect(0);
-    });
-  }
-
-  /**
-   * @param charType 0 == BF, 1 == Dad
-   */
-  function createAndOpenCharSelect(charType:Int = 0):Void
-  {
-    var charData = currentSongMetadata.playData.characters;
-    var currentChar:String = switch (charType)
-    {
-      case 0: charData.player;
-      case 1: charData.opponent;
-      default: throw 'Invalid charType: ' + charType;
-    };
-    var toolbox:CollapsibleDialog = cast haxe.ui.RuntimeComponentBuilder.fromAsset(Paths.ui('chart-editor/toolbox/iconselector'));
-
-    toolbox.x = FlxG.mouse.screenX - toolbox.width / 2;
-    toolbox.y = FlxG.mouse.screenY - 16;
-
-    toolbox.title += " - " + switch (charType)
-    {
-      case 0: "Player";
-      case 1: "Opponent";
-      default: throw 'Invalid charType: ' + charType;
-    };
-
-    var _overlay = new Component();
-
-    var hideCharIconPicker = function() {
-      FlxTween.tween(_overlay, {opacity: 0}, 0.05,
-        {
-          ease: FlxEase.quartOut
-        });
-      FlxTween.tween(toolbox, {opacity: 0, y: toolbox.y + 10}, 0.1,
-        {
-          ease: FlxEase.quartOut,
-          onComplete: function(_) {
-            toolbox.hideDialog(haxe.ui.containers.dialogs.Dialog.DialogButton.CANCEL);
-            Screen.instance.removeComponent(_overlay);
-          }
-        });
-    };
-
-    _overlay.id = "modal-background";
-    _overlay.addClass("modal-background");
-    _overlay.percentWidth = _overlay.percentHeight = 100;
-    _overlay.opacity = 0;
-    FlxTween.tween(_overlay, {opacity: 0.2}, 0.1, {ease: FlxEase.quartOut});
-    _overlay.onClick = function(_) {
-      hideCharIconPicker();
-    };
-
-    Screen.instance.addComponent(_overlay);
-
-    toolbox.showDialog(false);
-    toolbox.opacity = 0;
-    FlxTween.tween(toolbox, {opacity: 1, y: toolbox.y + 10}, 0.1, {ease: FlxEase.quartOut});
-    toolbox.closable = false;
-    var scrollView:ScrollView = cast toolbox.findComponent('charSelectScroll');
-
-    var hbox = new Grid();
-    hbox.columns = 5;
-    hbox.width = 100;
-    scrollView.addComponent(hbox);
-
-    var charIds:Array<String> = CharacterDataParser.listCharacterIds();
-
-    charIds.sort(function(a, b) {
-      var result:Int = 0;
-
-      if (a < b)
-      {
-        result = -1;
-      }
-      else if (a > b)
-      {
-        result = 1;
-      }
-
-      return result;
-    });
-
-    for (ind => char in charIds)
-    {
-      var image = new haxe.ui.components.Button();
-      image.width = 70;
-      image.height = 70;
-      image.iconPosition = "top";
-      image.text = char;
-
-      if (char == currentChar)
-      {
-        scrollView.hscrollPos = Math.floor(ind / 5) * 80;
-        image.selected = true;
-      }
-
-      image.icon = CharacterDataParser.getCharPixelIconAsset(char);
-      image.onClick = _ -> {
-        healthIconsDirty = true;
-        switch (charType)
-        {
-          case 0: currentSongMetadata.playData.characters.player = char;
-          case 1: currentSongMetadata.playData.characters.opponent = char;
-          default: throw 'Invalid charType: ' + charType;
-        };
-        hideCharIconPicker();
-
-        // var label = toolbox.findComponent('charIconName');
-        // label.text = char;
-      };
-
-      image.onMouseOver = _ -> {
-        var label = toolbox.findComponent('charIconName');
-        label.text = char;
-      };
-      hbox.addComponent(image);
-    }
   }
 
   function buildNotePreview():Void
@@ -2406,6 +2282,21 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     if (!Preferences.debugDisplay) menubar.paddingLeft = null;
 
     this.setupNotifications();
+
+    // Setup character dropdowns.
+    FlxMouseEvent.add(healthIconDad, function(_) {
+      if (!isCursorOverHaxeUI)
+      {
+        this.openCharacterDropdown(CharacterType.DAD, true);
+      }
+    });
+
+    FlxMouseEvent.add(healthIconBF, function(_) {
+      if (!isCursorOverHaxeUI)
+      {
+        this.openCharacterDropdown(CharacterType.BF, true);
+      }
+    });
   }
 
   /**
@@ -2446,13 +2337,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       else
       {
         Conductor.currentTimeChange.bpm += 1;
-        refreshMetadataToolbox();
+        this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
       }
     }
 
     playbarBPM.onRightClick = _ -> {
       Conductor.currentTimeChange.bpm -= 1;
-      refreshMetadataToolbox();
+      this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
     }
 
     // Add functionality to the menu items.
@@ -3509,6 +3400,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
       var overlapsSelection:Bool = FlxG.mouse.overlaps(renderedSelectionSquares);
 
+      var overlapsHealthIcons:Bool = FlxG.mouse.overlaps(healthIconBF) || FlxG.mouse.overlaps(healthIconDad);
+
       if (FlxG.mouse.justPressedMiddle)
       {
         if (scrollAnchorScreenPos == null)
@@ -3528,11 +3421,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         {
           scrollAnchorScreenPos = null;
         }
-        else if (gridPlayheadScrollArea != null && FlxG.mouse.overlaps(gridPlayheadScrollArea))
+        else if (gridPlayheadScrollArea != null && FlxG.mouse.overlaps(gridPlayheadScrollArea) && !isCursorOverHaxeUI)
         {
           gridPlayheadScrollAreaPressed = true;
         }
-        else if (notePreview != null && FlxG.mouse.overlaps(notePreview))
+        else if (notePreview != null && FlxG.mouse.overlaps(notePreview) && !isCursorOverHaxeUI)
         {
           // Clicked note preview
           notePreviewScrollAreaStartPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY);
@@ -4220,6 +4113,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           {
             targetCursorMode = Cell;
           }
+          else if (overlapsHealthIcons)
+          {
+            targetCursorMode = Pointer;
+          }
         }
       }
 
@@ -4250,7 +4147,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       difficultySelectDirty = false;
 
       // Manage the Select Difficulty tree view.
-      var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+      var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox_OLD(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
       if (difficultyToolbox == null) return;
 
       var treeView:Null<TreeView> = difficultyToolbox.findComponent('difficultyToolboxTree');
@@ -4297,7 +4194,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   function handlePlayerPreviewToolbox():Void
   {
     // Manage the Select Difficulty tree view.
-    var charPreviewToolbox:Null<CollapsibleDialog> = this.getToolbox(CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
+    var charPreviewToolbox:Null<CollapsibleDialog> = this.getToolbox_OLD(CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
     if (charPreviewToolbox == null) return;
 
     // TODO: Re-enable the player preview once we figure out the performance issues.
@@ -4333,7 +4230,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   function handleOpponentPreviewToolbox():Void
   {
     // Manage the Select Difficulty tree view.
-    var charPreviewToolbox:Null<CollapsibleDialog> = this.getToolbox(CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT);
+    var charPreviewToolbox:Null<CollapsibleDialog> = this.getToolbox_OLD(CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT);
     if (charPreviewToolbox == null) return;
 
     // TODO: Re-enable the player preview once we figure out the performance issues.
@@ -5042,7 +4939,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         Conductor.mapTimeChanges(this.currentSongMetadata.timeChanges);
 
         refreshDifficultyTreeSelection();
-        refreshMetadataToolbox();
+        this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
       }
       else
       {
@@ -5051,7 +4948,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         selectedDifficulty = prevDifficulty;
 
         refreshDifficultyTreeSelection();
-        refreshMetadataToolbox();
+        this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
       }
     }
     else
@@ -5070,7 +4967,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         selectedDifficulty = nextDifficulty;
 
         refreshDifficultyTreeSelection();
-        refreshMetadataToolbox();
+        this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
       }
       else
       {
@@ -5079,7 +4976,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         selectedDifficulty = nextDifficulty;
 
         refreshDifficultyTreeSelection();
-        refreshMetadataToolbox();
+        this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
       }
     }
 
@@ -5192,7 +5089,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     if (treeView == null)
     {
       // Manage the Select Difficulty tree view.
-      var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+      var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox_OLD(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
       if (difficultyToolbox == null) return;
 
       treeView = difficultyToolbox.findComponent('difficultyToolboxTree');
@@ -5212,7 +5109,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   {
     if (treeView == null)
     {
-      var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+      var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox_OLD(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
       if (difficultyToolbox == null) return null;
 
       treeView = difficultyToolbox.findComponent('difficultyToolboxTree');
@@ -5256,8 +5153,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           trace('Changing difficulty to "$variation:$difficulty"');
           selectedVariation = variation;
           selectedDifficulty = difficulty;
-          // refreshDifficultyTreeSelection(treeView);
-          refreshMetadataToolbox();
+          this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
         }
       // case 'song':
       // case 'variation':
@@ -5266,82 +5162,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         trace('Selected wrong node type, resetting selection.');
         var currentTreeDifficultyNode = getCurrentTreeDifficultyNode(treeView);
         if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode;
-        refreshMetadataToolbox();
-    }
-  }
-
-  /**
-   * When the difficulty changes, update the song metadata toolbox to reflect the new data.
-   */
-  function refreshMetadataToolbox():Void
-  {
-    var toolbox:Null<CollapsibleDialog> = this.getToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
-    if (toolbox == null) return;
-
-    var inputSongName:Null<TextField> = toolbox.findComponent('inputSongName', TextField);
-    if (inputSongName != null) inputSongName.value = currentSongMetadata.songName;
-
-    var inputSongArtist:Null<TextField> = toolbox.findComponent('inputSongArtist', TextField);
-    if (inputSongArtist != null) inputSongArtist.value = currentSongMetadata.artist;
-
-    var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown);
-    if (inputStage != null) inputStage.value = currentSongMetadata.playData.stage;
-
-    var inputNoteStyle:Null<DropDown> = toolbox.findComponent('inputNoteStyle', DropDown);
-    if (inputNoteStyle != null) inputNoteStyle.value = currentSongMetadata.playData.noteStyle;
-
-    var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper);
-    if (inputBPM != null) inputBPM.value = currentSongMetadata.timeChanges[0].bpm;
-
-    var labelScrollSpeed:Null<Label> = toolbox.findComponent('labelScrollSpeed', Label);
-    if (labelScrollSpeed != null) labelScrollSpeed.text = 'Scroll Speed: ${currentSongChartScrollSpeed}x';
-
-    var inputScrollSpeed:Null<Slider> = toolbox.findComponent('inputScrollSpeed', Slider);
-    if (inputScrollSpeed != null) inputScrollSpeed.value = currentSongChartScrollSpeed;
-
-    var frameVariation:Null<Frame> = toolbox.findComponent('frameVariation', Frame);
-    if (frameVariation != null) frameVariation.text = 'Variation: ${selectedVariation.toTitleCase()}';
-    var frameDifficulty:Null<Frame> = toolbox.findComponent('frameDifficulty', Frame);
-    if (frameDifficulty != null) frameDifficulty.text = 'Difficulty: ${selectedDifficulty.toTitleCase()}';
-
-    var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown);
-    var stageId:String = currentSongMetadata.playData.stage;
-    var stageData:Null<StageData> = StageDataParser.parseStageData(stageId);
-    if (inputStage != null)
-    {
-      inputStage.value = (stageData != null) ?
-        {id: stageId, text: stageData.name} :
-          {id: "mainStage", text: "Main Stage"};
-    }
-
-    var inputCharacterPlayer:Null<DropDown> = toolbox.findComponent('inputCharacterPlayer', DropDown);
-    var charIdPlayer:String = currentSongMetadata.playData.characters.player;
-    var charDataPlayer:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charIdPlayer);
-    if (inputCharacterPlayer != null)
-    {
-      inputCharacterPlayer.value = (charDataPlayer != null) ?
-        {id: charIdPlayer, text: charDataPlayer.name} :
-          {id: "bf", text: "Boyfriend"};
-    }
-
-    var inputCharacterOpponent:Null<DropDown> = toolbox.findComponent('inputCharacterOpponent', DropDown);
-    var charIdOpponent:String = currentSongMetadata.playData.characters.opponent;
-    var charDataOpponent:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charIdOpponent);
-    if (inputCharacterOpponent != null)
-    {
-      inputCharacterOpponent.value = (charDataOpponent != null) ?
-        {id: charIdOpponent, text: charDataOpponent.name} :
-          {id: "dad", text: "Dad"};
-    }
-
-    var inputCharacterGirlfriend:Null<DropDown> = toolbox.findComponent('inputCharacterGirlfriend', DropDown);
-    var charIdGirlfriend:String = currentSongMetadata.playData.characters.girlfriend;
-    var charDataGirlfriend:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charIdGirlfriend);
-    if (inputCharacterGirlfriend != null)
-    {
-      inputCharacterGirlfriend.value = (charDataGirlfriend != null) ?
-        {id: charIdGirlfriend, text: charDataGirlfriend.name} :
-          {id: "none", text: "None"};
+        this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
     }
   }
 
diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx
index a180825a8..e3d6fd13a 100644
--- a/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx
+++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx
@@ -2,8 +2,12 @@ package funkin.ui.debug.charting.dialogs;
 
 import haxe.ui.containers.dialogs.Dialog;
 import haxe.ui.containers.dialogs.Dialog.DialogEvent;
+import haxe.ui.animation.AnimationBuilder;
+import haxe.ui.styles.EasingFunction;
 import haxe.ui.core.Component;
 
+// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
+
 @:access(funkin.ui.debug.charting.ChartEditorState)
 class ChartEditorBaseDialog extends Dialog
 {
@@ -25,6 +29,12 @@ class ChartEditorBaseDialog extends Dialog
     this.onDialogClosed = event -> onClose(event);
   }
 
+  private override function onReady():Void
+  {
+    _overlay.opacity = 0;
+    fadeInDialogOverlay();
+  }
+
   /**
    * Called when the dialog is closed.
    * Override this to add custom behavior.
@@ -54,6 +64,31 @@ class ChartEditorBaseDialog extends Dialog
 
     this.closable = params.closable ?? false;
   }
+
+  static final OVERLAY_EASE_DURATION:Float = 5.0;
+  static final OVERLAY_EASE_TYPE:String = "linear";
+
+  function fadeInDialogOverlay():Void
+  {
+    if (!modal)
+    {
+      trace('Dialog is not modal, skipping overlay fade...');
+      return;
+    }
+
+    if (_overlay == null)
+    {
+      trace('[WARN] Dialog overlay is null, skipping overlay fade...');
+      return;
+    }
+
+    var builder = new AnimationBuilder(_overlay, OVERLAY_EASE_DURATION, "linear");
+    builder.setPosition(0, "opacity", 0, true); // 0% absolute
+    builder.setPosition(100, "opacity", 0.80, true);
+
+    trace('Fading in dialog overlay...');
+    builder.play();
+  }
 }
 
 typedef DialogParams =
diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseMenu.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseMenu.hx
new file mode 100644
index 000000000..cb4cb447b
--- /dev/null
+++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseMenu.hx
@@ -0,0 +1,24 @@
+package funkin.ui.debug.charting.dialogs;
+
+import haxe.ui.containers.dialogs.Dialog;
+import haxe.ui.containers.dialogs.Dialog.DialogEvent;
+import haxe.ui.animation.AnimationBuilder;
+import haxe.ui.styles.EasingFunction;
+import haxe.ui.core.Component;
+import haxe.ui.containers.menus.Menu;
+
+// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class ChartEditorBaseMenu extends Menu
+{
+  var state:ChartEditorState;
+
+  public function new(state:ChartEditorState)
+  {
+    super();
+
+    this.state = state;
+
+    // this.destroyOnClose = true;
+  }
+}
diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorCharacterIconSelectorMenu.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorCharacterIconSelectorMenu.hx
new file mode 100644
index 000000000..357a739fd
--- /dev/null
+++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorCharacterIconSelectorMenu.hx
@@ -0,0 +1,129 @@
+package funkin.ui.debug.charting.dialogs;
+
+import flixel.math.FlxPoint;
+import funkin.play.character.BaseCharacter.CharacterType;
+import funkin.play.character.CharacterData;
+import funkin.play.character.CharacterData.CharacterDataParser;
+import funkin.play.components.HealthIcon;
+import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogParams;
+import funkin.util.SortUtil;
+import haxe.ui.components.Label;
+import haxe.ui.containers.Grid;
+import haxe.ui.containers.HBox;
+import haxe.ui.containers.ScrollView;
+import haxe.ui.containers.ScrollView;
+import haxe.ui.core.Screen;
+
+// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/character-icon-selector.xml"))
+class ChartEditorCharacterIconSelectorMenu extends ChartEditorBaseMenu
+{
+  public var charSelectScroll:ScrollView;
+  public var charIconName:Label;
+
+  public function new(state2:ChartEditorState, charType:CharacterType, lockPosition:Bool = false)
+  {
+    super(state2);
+
+    initialize(charType, lockPosition);
+  }
+
+  function initialize(charType:CharacterType, lockPosition:Bool)
+  {
+    var currentCharId:String = switch (charType)
+    {
+      case BF: state.currentSongMetadata.playData.characters.player;
+      case GF: state.currentSongMetadata.playData.characters.girlfriend;
+      case DAD: state.currentSongMetadata.playData.characters.opponent;
+      default: throw 'Invalid charType: ' + charType;
+    };
+
+    // Position this menu.
+    var targetHealthIcon:Null<HealthIcon> = switch (charType)
+    {
+      case BF: state.healthIconBF;
+      case DAD: state.healthIconDad;
+      default: null;
+    };
+
+    if (lockPosition && targetHealthIcon != null)
+    {
+      var healthIconBottomCenter:FlxPoint = new FlxPoint(targetHealthIcon.x + targetHealthIcon.width / 2, targetHealthIcon.y + targetHealthIcon.height);
+
+      this.x = healthIconBottomCenter.x - this.width / 2;
+      this.y = healthIconBottomCenter.y;
+    }
+    else
+    {
+      this.x = Screen.instance.currentMouseX;
+      this.y = Screen.instance.currentMouseY;
+    }
+
+    var charGrid = new Grid();
+    charGrid.columns = 5;
+    charGrid.width = 100;
+    charSelectScroll.addComponent(charGrid);
+
+    var charIds:Array<String> = CharacterDataParser.listCharacterIds();
+    charIds.sort(SortUtil.alphabetically);
+
+    var defaultText:String = '(choose a character)';
+
+    for (charIndex => charId in charIds)
+    {
+      var charData:CharacterData = CharacterDataParser.fetchCharacterData(charId);
+
+      var charButton = new haxe.ui.components.Button();
+      charButton.width = 70;
+      charButton.height = 70;
+      charButton.padding = 8;
+      charButton.iconPosition = "top";
+
+      if (charId == currentCharId)
+      {
+        // Scroll to the character if it is already selected.
+        charSelectScroll.hscrollPos = Math.floor(charIndex / 5) * 80;
+        charButton.selected = true;
+
+        defaultText = '${charData.name} [${charId}]';
+      }
+
+      var LIMIT = 6;
+      charButton.icon = CharacterDataParser.getCharPixelIconAsset(charId);
+      charButton.text = charData.name.length > LIMIT ? '${charData.name.substr(0, LIMIT)}.' : '${charData.name}';
+
+      charButton.onClick = _ -> {
+        switch (charType)
+        {
+          case BF: state.currentSongMetadata.playData.characters.player = charId;
+          case GF: state.currentSongMetadata.playData.characters.girlfriend = charId;
+          case DAD: state.currentSongMetadata.playData.characters.opponent = charId;
+          default: throw 'Invalid charType: ' + charType;
+        };
+
+        state.healthIconsDirty = true;
+        state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
+      };
+
+      charButton.onMouseOver = _ -> {
+        charIconName.text = '${charData.name} [${charId}]';
+      };
+      charButton.onMouseOut = _ -> {
+        charIconName.text = defaultText;
+      };
+      charGrid.addComponent(charButton);
+    }
+
+    charIconName.text = defaultText;
+  }
+
+  public static function build(state2:ChartEditorState, charType:CharacterType, lockPosition:Bool = false):ChartEditorCharacterIconSelectorMenu
+  {
+    var menu = new ChartEditorCharacterIconSelectorMenu(state2, charType, lockPosition);
+
+    Screen.instance.addComponent(menu);
+
+    return menu;
+  }
+}
diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx
index 49d5593b0..597f3fb2c 100644
--- a/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx
+++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx
@@ -11,6 +11,7 @@ import haxe.ui.containers.dialogs.Dialogs;
 import haxe.ui.notifications.NotificationManager;
 import haxe.ui.notifications.NotificationType;
 
+// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
 @:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/upload-chart.xml"))
 class ChartEditorUploadChartDialog extends ChartEditorBaseDialog
 {
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
index 46b4b5dc4..666b3656c 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
@@ -16,6 +16,7 @@ import funkin.play.song.Song;
 import funkin.play.stage.StageData;
 import funkin.ui.debug.charting.dialogs.ChartEditorAboutDialog;
 import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogDropTarget;
+import funkin.ui.debug.charting.dialogs.ChartEditorCharacterIconSelectorMenu;
 import funkin.ui.debug.charting.dialogs.ChartEditorUploadChartDialog;
 import funkin.ui.debug.charting.dialogs.ChartEditorWelcomeDialog;
 import funkin.ui.debug.charting.util.ChartEditorDropdowns;
@@ -39,6 +40,7 @@ import haxe.ui.containers.dialogs.Dialog;
 import haxe.ui.containers.dialogs.Dialog.DialogButton;
 import haxe.ui.containers.dialogs.Dialogs;
 import haxe.ui.containers.Form;
+import haxe.ui.containers.menus.Menu;
 import haxe.ui.containers.VBox;
 import haxe.ui.core.Component;
 import haxe.ui.events.UIEvent;
@@ -286,6 +288,15 @@ class ChartEditorDialogHandler
     };
   }
 
+  public static function openCharacterDropdown(state:ChartEditorState, charType:CharacterType, lockPosition:Bool = false):Null<Menu>
+  {
+    var menu = ChartEditorCharacterIconSelectorMenu.build(state, charType, lockPosition);
+
+    menu.zIndex = 1000;
+
+    return menu;
+  }
+
   public static function openCreateSongWizardBasicOnly(state:ChartEditorState, closable:Bool):Void
   {
     // Step 1. Song Metadata
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
index 1726fe4a2..0c8d6a205 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
@@ -99,7 +99,7 @@ class ChartEditorImportExportHandler
     state.switchToCurrentInstrumental();
     state.postLoadInstrumental();
 
-    state.refreshMetadataToolbox();
+    state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
 
     state.success('Success', 'Loaded song (${rawSongMetadata[0].songName})');
   }
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
index 5834de2ee..2e6e28598 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
@@ -11,7 +11,6 @@ import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.play.event.SongEvent;
 import funkin.data.event.SongEventData;
 import funkin.data.song.SongData.SongTimeChange;
-import funkin.ui.debug.charting.commands.ChangeStartingBPMCommand;
 import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.play.character.CharacterData;
 import funkin.play.character.CharacterData.CharacterDataParser;
@@ -35,6 +34,8 @@ import haxe.ui.containers.Box;
 import haxe.ui.containers.dialogs.CollapsibleDialog;
 import haxe.ui.containers.dialogs.Dialog.DialogButton;
 import haxe.ui.containers.dialogs.Dialog.DialogEvent;
+import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorMetadataToolbox;
 import haxe.ui.containers.Frame;
 import haxe.ui.containers.Grid;
 import haxe.ui.containers.TreeView;
@@ -83,7 +84,8 @@ class ChartEditorToolboxHandler
         case ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:
           onShowToolboxDifficulty(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
-          onShowToolboxMetadata(state, toolbox);
+          // TODO: Fix this.
+          cast(toolbox, ChartEditorBaseToolbox).refresh();
         case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
           onShowToolboxPlayerPreview(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@@ -136,6 +138,22 @@ class ChartEditorToolboxHandler
     }
   }
 
+  public static function refreshToolbox(state:ChartEditorState, id:String):Void
+  {
+    var toolbox:Null<ChartEditorBaseToolbox> = cast state.activeToolboxes.get(id);
+
+    if (toolbox == null) return;
+
+    if (toolbox != null)
+    {
+      toolbox.refresh();
+    }
+    else
+    {
+      trace('ChartEditorToolboxHandler.refreshToolbox() - Could not retrieve toolbox: $id');
+    }
+  }
+
   public static function rememberOpenToolboxes(state:ChartEditorState):Void {}
 
   public static function openRememberedToolboxes(state:ChartEditorState):Void {}
@@ -205,7 +223,19 @@ class ChartEditorToolboxHandler
    * @param id The asset ID of the toolbox layout.
    * @return The toolbox.
    */
-  public static function getToolbox(state:ChartEditorState, id:String):Null<CollapsibleDialog>
+  public static function getToolbox_OLD(state:ChartEditorState, id:String):Null<CollapsibleDialog>
+  {
+    var toolbox:Null<CollapsibleDialog> = state.activeToolboxes.get(id);
+
+    // Initialize the toolbox without showing it.
+    if (toolbox == null) toolbox = initToolbox(state, id);
+
+    if (toolbox == null) throw 'ChartEditorToolboxHandler.getToolbox_OLD() - Could not retrieve or build toolbox: $id';
+
+    return toolbox;
+  }
+
+  public static function getToolbox(state:ChartEditorState, id:String):Null<ChartEditorBaseToolbox>
   {
     var toolbox:Null<CollapsibleDialog> = state.activeToolboxes.get(id);
 
@@ -214,7 +244,7 @@ class ChartEditorToolboxHandler
 
     if (toolbox == null) throw 'ChartEditorToolboxHandler.getToolbox() - Could not retrieve or build toolbox: $id';
 
-    return toolbox;
+    return cast toolbox;
   }
 
   static function buildToolboxNoteDataLayout(state:ChartEditorState):Null<CollapsibleDialog>
@@ -505,180 +535,15 @@ class ChartEditorToolboxHandler
 
   static function onHideToolboxDifficulty(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
 
-  static function buildToolboxMetadataLayout(state:ChartEditorState):Null<CollapsibleDialog>
+  static function buildToolboxMetadataLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox>
   {
-    var toolbox:CollapsibleDialog = cast RuntimeComponentBuilder.fromAsset(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
+    var toolbox:ChartEditorBaseToolbox = ChartEditorMetadataToolbox.build(state);
 
     if (toolbox == null) return null;
 
-    // Starting position.
-    toolbox.x = 150;
-    toolbox.y = 250;
-
-    toolbox.onDialogClosed = function(event:UIEvent) {
-      state.menubarItemToggleToolboxMetadata.selected = false;
-    }
-
-    var inputSongName:Null<TextField> = toolbox.findComponent('inputSongName', TextField);
-    if (inputSongName == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputSongName component.';
-    inputSongName.onChange = function(event:UIEvent) {
-      var valid:Bool = event.target.text != null && event.target.text != '';
-
-      if (valid)
-      {
-        inputSongName.removeClass('invalid-value');
-        state.currentSongMetadata.songName = event.target.text;
-      }
-      else
-      {
-        state.currentSongMetadata.songName = '';
-      }
-    };
-    inputSongName.value = state.currentSongMetadata.songName;
-
-    var inputSongArtist:Null<TextField> = toolbox.findComponent('inputSongArtist', TextField);
-    if (inputSongArtist == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputSongArtist component.';
-    inputSongArtist.onChange = function(event:UIEvent) {
-      var valid:Bool = event.target.text != null && event.target.text != '';
-
-      if (valid)
-      {
-        inputSongArtist.removeClass('invalid-value');
-        state.currentSongMetadata.artist = event.target.text;
-      }
-      else
-      {
-        state.currentSongMetadata.artist = '';
-      }
-    };
-    inputSongArtist.value = state.currentSongMetadata.artist;
-
-    var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown);
-    if (inputStage == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputStage component.';
-    inputStage.onChange = function(event:UIEvent) {
-      var valid:Bool = event.data != null && event.data.id != null;
-
-      if (valid)
-      {
-        state.currentSongMetadata.playData.stage = event.data.id;
-      }
-    };
-    var startingValueStage = ChartEditorDropdowns.populateDropdownWithStages(inputStage, state.currentSongMetadata.playData.stage);
-    inputStage.value = startingValueStage;
-
-    var inputNoteStyle:Null<DropDown> = toolbox.findComponent('inputNoteStyle', DropDown);
-    if (inputNoteStyle == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputNoteStyle component.';
-    inputNoteStyle.onChange = function(event:UIEvent) {
-      if (event.data?.id == null) return;
-      state.currentSongNoteStyle = event.data.id;
-    };
-    inputNoteStyle.value = state.currentSongNoteStyle;
-
-    // By using this flag, we prevent the dropdown value from changing while it is being populated.
-
-    var inputCharacterPlayer:Null<DropDown> = toolbox.findComponent('inputCharacterPlayer', DropDown);
-    if (inputCharacterPlayer == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterPlayer component.';
-    inputCharacterPlayer.onChange = function(event:UIEvent) {
-      if (event.data?.id == null) return;
-      state.currentSongMetadata.playData.characters.player = event.data.id;
-    };
-    var startingValuePlayer = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterPlayer, CharacterType.BF,
-      state.currentSongMetadata.playData.characters.player);
-    inputCharacterPlayer.value = startingValuePlayer;
-
-    var inputCharacterOpponent:Null<DropDown> = toolbox.findComponent('inputCharacterOpponent', DropDown);
-    if (inputCharacterOpponent == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterOpponent component.';
-    inputCharacterOpponent.onChange = function(event:UIEvent) {
-      if (event.data?.id == null) return;
-      state.currentSongMetadata.playData.characters.opponent = event.data.id;
-    };
-    var startingValueOpponent = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterOpponent, CharacterType.DAD,
-      state.currentSongMetadata.playData.characters.opponent);
-    inputCharacterOpponent.value = startingValueOpponent;
-
-    var inputCharacterGirlfriend:Null<DropDown> = toolbox.findComponent('inputCharacterGirlfriend', DropDown);
-    if (inputCharacterGirlfriend == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterGirlfriend component.';
-    inputCharacterGirlfriend.onChange = function(event:UIEvent) {
-      if (event.data?.id == null) return;
-      state.currentSongMetadata.playData.characters.girlfriend = event.data.id == "none" ? "" : event.data.id;
-    };
-    var startingValueGirlfriend = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterGirlfriend, CharacterType.GF,
-      state.currentSongMetadata.playData.characters.girlfriend);
-    inputCharacterGirlfriend.value = startingValueGirlfriend;
-
-    var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper);
-    if (inputBPM == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputBPM component.';
-    inputBPM.onChange = function(event:UIEvent) {
-      if (event.value == null || event.value <= 0) return;
-
-      // Use a command so we can undo/redo this action.
-      var startingBPM = state.currentSongMetadata.timeChanges[0].bpm;
-      if (event.value != startingBPM)
-      {
-        state.performCommand(new ChangeStartingBPMCommand(event.value));
-      }
-    };
-    inputBPM.value = state.currentSongMetadata.timeChanges[0].bpm;
-
-    var inputOffsetInst:Null<NumberStepper> = toolbox.findComponent('inputOffsetInst', NumberStepper);
-    if (inputOffsetInst == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputOffsetInst component.';
-    inputOffsetInst.onChange = function(event:UIEvent) {
-      if (event.value == null) return;
-
-      state.currentInstrumentalOffset = event.value;
-      Conductor.instrumentalOffset = event.value;
-      // Update song length.
-      state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instrumentalOffset;
-    };
-    inputOffsetInst.value = state.currentInstrumentalOffset;
-
-    var inputOffsetVocal:Null<NumberStepper> = toolbox.findComponent('inputOffsetVocal', NumberStepper);
-    if (inputOffsetVocal == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputOffsetVocal component.';
-    inputOffsetVocal.onChange = function(event:UIEvent) {
-      if (event.value == null) return;
-
-      state.currentSongMetadata.offsets.setVocalOffset(state.currentSongMetadata.playData.characters.player, event.value);
-    };
-    inputOffsetVocal.value = state.currentSongMetadata.offsets.getVocalOffset(state.currentSongMetadata.playData.characters.player);
-
-    var labelScrollSpeed:Null<Label> = toolbox.findComponent('labelScrollSpeed', Label);
-    if (labelScrollSpeed == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find labelScrollSpeed component.';
-
-    var inputScrollSpeed:Null<Slider> = toolbox.findComponent('inputScrollSpeed', Slider);
-    if (inputScrollSpeed == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputScrollSpeed component.';
-    inputScrollSpeed.onChange = function(event:UIEvent) {
-      var valid:Bool = event.target.value != null && event.target.value > 0;
-
-      if (valid)
-      {
-        inputScrollSpeed.removeClass('invalid-value');
-        state.currentSongChartScrollSpeed = event.target.value;
-      }
-      else
-      {
-        state.currentSongChartScrollSpeed = 1.0;
-      }
-      labelScrollSpeed.text = 'Scroll Speed: ${state.currentSongChartScrollSpeed}x';
-    };
-    inputScrollSpeed.value = state.currentSongChartScrollSpeed;
-    labelScrollSpeed.text = 'Scroll Speed: ${state.currentSongChartScrollSpeed}x';
-
-    var frameVariation:Null<Frame> = toolbox.findComponent('frameVariation', Frame);
-    if (frameVariation == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find frameVariation component.';
-    frameVariation.text = 'Variation: ${state.selectedVariation.toTitleCase()}';
-
-    var frameDifficulty:Null<Frame> = toolbox.findComponent('frameDifficulty', Frame);
-    if (frameDifficulty == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find frameDifficulty component.';
-    frameDifficulty.text = 'Difficulty: ${state.selectedDifficulty.toTitleCase()}';
-
     return toolbox;
   }
 
-  static function onShowToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void
-  {
-    state.refreshMetadataToolbox();
-  }
-
   static function onHideToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
 
   static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):Null<CollapsibleDialog>
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorBaseToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorBaseToolbox.hx
new file mode 100644
index 000000000..c4c532205
--- /dev/null
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorBaseToolbox.hx
@@ -0,0 +1,28 @@
+package funkin.ui.debug.charting.toolboxes;
+
+import haxe.ui.containers.dialogs.Dialog;
+import haxe.ui.containers.dialogs.CollapsibleDialog;
+import haxe.ui.containers.dialogs.Dialog.DialogEvent;
+import haxe.ui.core.Component;
+
+/**
+ * The base class for the Toolboxes (manipulatable, arrangeable control windows) in the Chart Editor.
+ */
+// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class ChartEditorBaseToolbox extends CollapsibleDialog
+{
+  var state:ChartEditorState;
+
+  private function new(state:ChartEditorState)
+  {
+    super();
+
+    this.state = state;
+  }
+
+  /**
+   * Override to implement this.
+   */
+  public function refresh() {}
+}
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx
new file mode 100644
index 000000000..e0ee4aca3
--- /dev/null
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx
@@ -0,0 +1,203 @@
+package funkin.ui.debug.charting.toolboxes;
+
+import funkin.play.character.BaseCharacter.CharacterType;
+import funkin.play.character.CharacterData;
+import funkin.play.stage.StageData;
+import funkin.ui.debug.charting.commands.ChangeStartingBPMCommand;
+import funkin.ui.debug.charting.util.ChartEditorDropdowns;
+import haxe.ui.components.Button;
+import haxe.ui.components.CheckBox;
+import haxe.ui.components.DropDown;
+import haxe.ui.components.HorizontalSlider;
+import haxe.ui.components.Label;
+import haxe.ui.components.NumberStepper;
+import haxe.ui.components.Slider;
+import haxe.ui.components.TextField;
+import haxe.ui.containers.Box;
+import haxe.ui.containers.Frame;
+import haxe.ui.events.UIEvent;
+
+/**
+ * The toolbox which allows modifying information like Song Title, Scroll Speed, Characters/Stages, and starting BPM.
+ */
+// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/metadata.xml"))
+class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox
+{
+  var inputSongName:TextField;
+  var inputSongArtist:TextField;
+  var inputStage:DropDown;
+  var inputNoteStyle:DropDown;
+  var buttonCharacterPlayer:Button;
+  var buttonCharacterGirlfriend:Button;
+  var buttonCharacterOpponent:Button;
+  var inputBPM:NumberStepper;
+  var inputOffsetInst:NumberStepper;
+  var inputOffsetVocal:NumberStepper;
+  var labelScrollSpeed:Label;
+  var inputScrollSpeed:Slider;
+  var frameVariation:Frame;
+  var frameDifficulty:Frame;
+
+  public function new(state2:ChartEditorState)
+  {
+    super(state2);
+
+    initialize();
+
+    this.onDialogClosed = onClose;
+  }
+
+  function onClose(event:UIEvent)
+  {
+    state.menubarItemToggleToolboxMetadata.selected = false;
+  }
+
+  function initialize():Void
+  {
+    // Starting position.
+    // TODO: Save and load this.
+    this.x = 150;
+    this.y = 250;
+
+    inputSongName.onChange = function(event:UIEvent) {
+      var valid:Bool = event.target.text != null && event.target.text != '';
+
+      if (valid)
+      {
+        inputSongName.removeClass('invalid-value');
+        state.currentSongMetadata.songName = event.target.text;
+      }
+      else
+      {
+        state.currentSongMetadata.songName = '';
+      }
+    };
+
+    inputSongArtist.onChange = function(event:UIEvent) {
+      var valid:Bool = event.target.text != null && event.target.text != '';
+
+      if (valid)
+      {
+        inputSongArtist.removeClass('invalid-value');
+        state.currentSongMetadata.artist = event.target.text;
+      }
+      else
+      {
+        state.currentSongMetadata.artist = '';
+      }
+    };
+
+    inputStage.onChange = function(event:UIEvent) {
+      var valid:Bool = event.data != null && event.data.id != null;
+
+      if (valid)
+      {
+        state.currentSongMetadata.playData.stage = event.data.id;
+      }
+    };
+    var startingValueStage = ChartEditorDropdowns.populateDropdownWithStages(inputStage, state.currentSongMetadata.playData.stage);
+    inputStage.value = startingValueStage;
+
+    inputNoteStyle.onChange = function(event:UIEvent) {
+      if (event.data?.id == null) return;
+      state.currentSongNoteStyle = event.data.id;
+    };
+
+    inputBPM.onChange = function(event:UIEvent) {
+      if (event.value == null || event.value <= 0) return;
+
+      // Use a command so we can undo/redo this action.
+      var startingBPM = state.currentSongMetadata.timeChanges[0].bpm;
+      if (event.value != startingBPM)
+      {
+        state.performCommand(new ChangeStartingBPMCommand(event.value));
+      }
+    };
+
+    inputOffsetInst.onChange = function(event:UIEvent) {
+      if (event.value == null) return;
+
+      state.currentInstrumentalOffset = event.value;
+      Conductor.instrumentalOffset = event.value;
+      // Update song length.
+      state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instrumentalOffset;
+    };
+
+    inputOffsetVocal.onChange = function(event:UIEvent) {
+      if (event.value == null) return;
+
+      state.currentSongMetadata.offsets.setVocalOffset(state.currentSongMetadata.playData.characters.player, event.value);
+    };
+    inputScrollSpeed.onChange = function(event:UIEvent) {
+      var valid:Bool = event.target.value != null && event.target.value > 0;
+
+      if (valid)
+      {
+        inputScrollSpeed.removeClass('invalid-value');
+        state.currentSongChartScrollSpeed = event.target.value;
+      }
+      else
+      {
+        state.currentSongChartScrollSpeed = 1.0;
+      }
+      labelScrollSpeed.text = 'Scroll Speed: ${state.currentSongChartScrollSpeed}x';
+    };
+
+    buttonCharacterOpponent.onClick = function(_) {
+      state.openCharacterDropdown(CharacterType.DAD, false);
+    };
+
+    buttonCharacterGirlfriend.onClick = function(_) {
+      state.openCharacterDropdown(CharacterType.GF, false);
+    };
+
+    buttonCharacterPlayer.onClick = function(_) {
+      state.openCharacterDropdown(CharacterType.BF, false);
+    };
+
+    refresh();
+  }
+
+  public override function refresh():Void
+  {
+    inputSongName.value = state.currentSongMetadata.songName;
+    inputSongArtist.value = state.currentSongMetadata.artist;
+    inputStage.value = state.currentSongMetadata.playData.stage;
+    inputNoteStyle.value = state.currentSongMetadata.playData.noteStyle;
+    inputBPM.value = state.currentSongMetadata.timeChanges[0].bpm;
+    inputScrollSpeed.value = state.currentSongChartScrollSpeed;
+    labelScrollSpeed.text = 'Scroll Speed: ${state.currentSongChartScrollSpeed}x';
+    frameVariation.text = 'Variation: ${state.selectedVariation.toTitleCase()}';
+    frameDifficulty.text = 'Difficulty: ${state.selectedDifficulty.toTitleCase()}';
+
+    var stageId:String = state.currentSongMetadata.playData.stage;
+    var stageData:Null<StageData> = StageDataParser.parseStageData(stageId);
+    if (inputStage != null)
+    {
+      inputStage.value = (stageData != null) ?
+        {id: stageId, text: stageData.name} :
+          {id: "mainStage", text: "Main Stage"};
+    }
+
+    var LIMIT = 6;
+
+    var charDataOpponent:CharacterData = CharacterDataParser.fetchCharacterData(state.currentSongMetadata.playData.characters.opponent);
+    buttonCharacterOpponent.icon = CharacterDataParser.getCharPixelIconAsset(state.currentSongMetadata.playData.characters.opponent);
+    buttonCharacterOpponent.text = charDataOpponent.name.length > LIMIT ? '${charDataOpponent.name.substr(0, LIMIT)}.' : '${charDataOpponent.name}';
+
+    var charDataGirlfriend:CharacterData = CharacterDataParser.fetchCharacterData(state.currentSongMetadata.playData.characters.girlfriend);
+    buttonCharacterGirlfriend.icon = CharacterDataParser.getCharPixelIconAsset(state.currentSongMetadata.playData.characters.girlfriend);
+    buttonCharacterGirlfriend.text = charDataGirlfriend.name.length > LIMIT ? '${charDataGirlfriend.name.substr(0, LIMIT)}.' : '${charDataGirlfriend.name}';
+
+    var charDataPlayer:CharacterData = CharacterDataParser.fetchCharacterData(state.currentSongMetadata.playData.characters.player);
+    buttonCharacterPlayer.icon = CharacterDataParser.getCharPixelIconAsset(state.currentSongMetadata.playData.characters.player);
+    buttonCharacterPlayer.text = charDataPlayer.name.length > LIMIT ? '${charDataPlayer.name.substr(0, LIMIT)}.' : '${charDataPlayer.name}';
+  }
+
+  public static function build(state:ChartEditorState):ChartEditorMetadataToolbox
+  {
+    return new ChartEditorMetadataToolbox(state);
+  }
+}

From b09ed369e8401cf4b81365f09082b17418d32954 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Wed, 13 Dec 2023 09:20:15 -0500
Subject: [PATCH 7/8] f

---
 .../funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx   | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx
index e3d6fd13a..3de67b826 100644
--- a/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx
+++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx
@@ -84,7 +84,7 @@ class ChartEditorBaseDialog extends Dialog
 
     var builder = new AnimationBuilder(_overlay, OVERLAY_EASE_DURATION, "linear");
     builder.setPosition(0, "opacity", 0, true); // 0% absolute
-    builder.setPosition(100, "opacity", 0.80, true);
+    builder.setPosition(100, "opacity", 1, true);
 
     trace('Fading in dialog overlay...');
     builder.play();

From 4193cd4ee2f38162a49dffbb20c46052a1fd96c0 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Wed, 13 Dec 2023 18:24:55 -0500
Subject: [PATCH 8/8] proper dialog fading

---
 hmm.json                                      |  4 ++--
 .../ui/debug/charting/ChartEditorState.hx     |  2 +-
 .../charting/dialogs/ChartEditorBaseDialog.hx | 23 ++++++++++++++-----
 .../ChartEditorNotificationHandler.hx         | 14 ++++++-----
 4 files changed, 28 insertions(+), 15 deletions(-)

diff --git a/hmm.json b/hmm.json
index 0f06acaa7..a10eed1a6 100644
--- a/hmm.json
+++ b/hmm.json
@@ -49,14 +49,14 @@
       "name": "haxeui-core",
       "type": "git",
       "dir": null,
-      "ref": "5d4ac180f85b39e72624f4b8d17925d91ebe4278",
+      "ref": "032192e849cdb7d1070c0a3241c58ee555ffaccc",
       "url": "https://github.com/haxeui/haxeui-core"
     },
     {
       "name": "haxeui-flixel",
       "type": "git",
       "dir": null,
-      "ref": "89a4cf621e5c204922f7a12fbde5d1d84f8b47f5",
+      "ref": "d90758b229d05206400df867d333c79d9fdbd478",
       "url": "https://github.com/haxeui/haxeui-flixel"
     },
     {
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 9a2bfe63c..3696369d4 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -2643,7 +2643,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    * Open the backups folder in the file explorer.
    * Don't call this on HTML5.
    */
-  function openBackupsFolder():Void
+  function openBackupsFolder(?_):Void
   {
     #if sys
     // TODO: Is there a way to open a folder and highlight a file in it?
diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx
index 3de67b826..6f76e543e 100644
--- a/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx
+++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx
@@ -29,6 +29,12 @@ class ChartEditorBaseDialog extends Dialog
     this.onDialogClosed = event -> onClose(event);
   }
 
+  public override function showDialog(modal:Bool = true):Void
+  {
+    super.showDialog(modal);
+    fadeInComponent(this, 1);
+  }
+
   private override function onReady():Void
   {
     _overlay.opacity = 0;
@@ -65,8 +71,8 @@ class ChartEditorBaseDialog extends Dialog
     this.closable = params.closable ?? false;
   }
 
-  static final OVERLAY_EASE_DURATION:Float = 5.0;
-  static final OVERLAY_EASE_TYPE:String = "linear";
+  static final OVERLAY_EASE_DURATION:Float = 0.2;
+  static final OVERLAY_EASE_TYPE:String = "easeOut";
 
   function fadeInDialogOverlay():Void
   {
@@ -82,11 +88,16 @@ class ChartEditorBaseDialog extends Dialog
       return;
     }
 
-    var builder = new AnimationBuilder(_overlay, OVERLAY_EASE_DURATION, "linear");
-    builder.setPosition(0, "opacity", 0, true); // 0% absolute
-    builder.setPosition(100, "opacity", 1, true);
+    fadeInComponent(_overlay, 0.5);
+  }
 
-    trace('Fading in dialog overlay...');
+  function fadeInComponent(component:Component, fadeTo:Float = 1):Void
+  {
+    var builder = new AnimationBuilder(component, OVERLAY_EASE_DURATION, OVERLAY_EASE_TYPE);
+    builder.setPosition(0, "opacity", 0, true); // 0% absolute
+    builder.setPosition(100, "opacity", fadeTo, true);
+
+    trace('Fading in dialog component...');
     builder.play();
   }
 }
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorNotificationHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorNotificationHandler.hx
index 14d95347b..10da77694 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorNotificationHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorNotificationHandler.hx
@@ -5,6 +5,7 @@ import haxe.ui.containers.HBox;
 import haxe.ui.notifications.Notification;
 import haxe.ui.notifications.NotificationManager;
 import haxe.ui.notifications.NotificationType;
+import haxe.ui.notifications.NotificationData.NotificationActionData;
 
 class ChartEditorNotificationHandler
 {
@@ -77,7 +78,7 @@ class ChartEditorNotificationHandler
    * @param actions The actions to add to the notification.
    * @return The notification that was sent.
    */
-  public static function infoWithActions(state:ChartEditorState, title:String, body:String, actions:Array<NotificationAction>):Notification
+  public static function infoWithActions(state:ChartEditorState, title:String, body:String, actions:Array<NotificationActionData>):Notification
   {
     return sendNotification(state, title, body, NotificationType.Info, actions);
   }
@@ -101,7 +102,8 @@ class ChartEditorNotificationHandler
     NotificationManager.instance.removeNotification(notif);
   }
 
-  static function sendNotification(state:ChartEditorState, title:String, body:String, ?type:NotificationType, ?actions:Array<NotificationAction>):Notification
+  static function sendNotification(state:ChartEditorState, title:String, body:String, ?type:NotificationType,
+      ?actions:Array<NotificationActionData>):Notification
   {
     var actionNames:Array<String> = actions == null ? [] : actions.map(action -> action.text);
 
@@ -111,10 +113,10 @@ class ChartEditorNotificationHandler
         body: body,
         type: type ?? NotificationType.Default,
         expiryMs: Constants.NOTIFICATION_DISMISS_TIME,
-        actions: actionNames
+        actions: actions
       });
 
-    if (actionNames.length > 0)
+    if (actions != null && actions.length > 0)
     {
       // TODO: Tell Ian that this is REALLY dumb.
       var actionsContainer:HBox = notif.findComponent('actionsContainer', HBox);
@@ -122,13 +124,13 @@ class ChartEditorNotificationHandler
         if (Std.isOfType(component, Button))
         {
           var button:Button = cast component;
-          var action:Null<NotificationAction> = actions.find(action -> action.text == button.text);
+          var action:Null<NotificationActionData> = actions.find(action -> action.text == button.text);
           if (action != null && action.callback != null)
           {
             button.onClick = function(_) {
               // Don't allow actions to be clicked while the playtest is open.
               if (state.subState != null) return;
-              action.callback();
+              action.callback(action);
             };
           }
         }