From 336810b628631b0e1dcb67b25774bac1ce815395 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 4 Jan 2024 10:00:39 -0500
Subject: [PATCH] Tooltips when hovering over chart events

---
 hmm.json                                      |   4 +-
 source/funkin/data/event/SongEventData.hx     | 109 ++++++++++++++----
 source/funkin/data/song/SongData.hx           |  61 ++++++++++
 .../funkin/play/event/FocusCameraSongEvent.hx |   4 +-
 .../play/event/PlayAnimationSongEvent.hx      |   4 +-
 .../play/event/SetCameraBopSongEvent.hx       |   4 +-
 .../funkin/play/event/ZoomCameraSongEvent.hx  |   4 +-
 .../ui/debug/charting/ChartEditorState.hx     |   3 +-
 .../components/ChartEditorEventSprite.hx      |  42 ++++++-
 source/funkin/util/HaxeUIUtil.hx              |  17 +++
 10 files changed, 217 insertions(+), 35 deletions(-)
 create mode 100644 source/funkin/util/HaxeUIUtil.hx

diff --git a/hmm.json b/hmm.json
index 57fbbb555..be9e2dd26 100644
--- a/hmm.json
+++ b/hmm.json
@@ -54,14 +54,14 @@
       "name": "haxeui-core",
       "type": "git",
       "dir": null,
-      "ref": "e765a3e0b7a653823e8dec765e04623f27f573f8",
+      "ref": "67c5700e253ff8892589a95945a7799f34ae4df0",
       "url": "https://github.com/haxeui/haxeui-core"
     },
     {
       "name": "haxeui-flixel",
       "type": "git",
       "dir": null,
-      "ref": "7a517d561eff49d8123c128bf9f5c1123b84d014",
+      "ref": "2b9cff727999b53ed292b1675ac1c9089ac77600",
       "url": "https://github.com/haxeui/haxeui-flixel"
     },
     {
diff --git a/source/funkin/data/event/SongEventData.hx b/source/funkin/data/event/SongEventData.hx
index 7a167b031..a4a41e3a0 100644
--- a/source/funkin/data/event/SongEventData.hx
+++ b/source/funkin/data/event/SongEventData.hx
@@ -161,35 +161,71 @@ class SongEventParser
   }
 }
 
-enum abstract SongEventFieldType(String) from String to String
+@:forward(name, title, type, keys, min, max, step, defaultValue, iterator)
+abstract SongEventSchema(SongEventSchemaRaw)
 {
-  /**
-   * The STRING type will display as a text field.
-   */
-  var STRING = "string";
+  public function new(?fields:Array<SongEventSchemaField>)
+  {
+    this = fields;
+  }
 
-  /**
-   * The INTEGER type will display as a text field that only accepts numbers.
-   */
-  var INTEGER = "integer";
+  @:arrayAccess
+  public function getByName(name:String):SongEventSchemaField
+  {
+    for (field in this)
+    {
+      if (field.name == name) return field;
+    }
 
-  /**
-   * The FLOAT type will display as a text field that only accepts numbers.
-   */
-  var FLOAT = "float";
+    return null;
+  }
 
-  /**
-   * The BOOL type will display as a checkbox.
-   */
-  var BOOL = "bool";
+  public function getFirstField():SongEventSchemaField
+  {
+    return this[0];
+  }
 
-  /**
-   * The ENUM type will display as a dropdown.
-   * Make sure to specify the `keys` field in the schema.
-   */
-  var ENUM = "enum";
+  public function stringifyFieldValue(name:String, value:Dynamic):String
+  {
+    var field:SongEventSchemaField = getByName(name);
+    if (field == null) return 'Unknown';
+
+    switch (field.type)
+    {
+      case SongEventFieldType.STRING:
+        return Std.string(value);
+      case SongEventFieldType.INTEGER:
+        return Std.string(value);
+      case SongEventFieldType.FLOAT:
+        return Std.string(value);
+      case SongEventFieldType.BOOL:
+        return Std.string(value);
+      case SongEventFieldType.ENUM:
+        for (key in field.keys.keys())
+        {
+          if (field.keys.get(key) == value) return key;
+        }
+        return Std.string(value);
+      default:
+        return 'Unknown';
+    }
+  }
+
+  @:arrayAccess
+  public inline function get(key:Int)
+  {
+    return this[key];
+  }
+
+  @:arrayAccess
+  public inline function arrayWrite(k:Int, v:SongEventSchemaField):SongEventSchemaField
+  {
+    return this[k] = v;
+  }
 }
 
+typedef SongEventSchemaRaw = Array<SongEventSchemaField>;
+
 typedef SongEventSchemaField =
 {
   /**
@@ -240,4 +276,31 @@ typedef SongEventSchemaField =
   ?defaultValue:Dynamic,
 }
 
-typedef SongEventSchema = Array<SongEventSchemaField>;
+enum abstract SongEventFieldType(String) from String to String
+{
+  /**
+   * The STRING type will display as a text field.
+   */
+  var STRING = "string";
+
+  /**
+   * The INTEGER type will display as a text field that only accepts numbers.
+   */
+  var INTEGER = "integer";
+
+  /**
+   * The FLOAT type will display as a text field that only accepts numbers.
+   */
+  var FLOAT = "float";
+
+  /**
+   * The BOOL type will display as a checkbox.
+   */
+  var BOOL = "bool";
+
+  /**
+   * The ENUM type will display as a dropdown.
+   * Make sure to specify the `keys` field in the schema.
+   */
+  var ENUM = "enum";
+}
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 600871e2f..de73cd957 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -1,5 +1,8 @@
 package funkin.data.song;
 
+import funkin.play.event.SongEvent;
+import funkin.data.event.SongEventData.SongEventParser;
+import funkin.data.event.SongEventData.SongEventSchema;
 import funkin.data.song.SongRegistry;
 import thx.semver.Version;
 
@@ -617,6 +620,38 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
     this = new SongEventDataRaw(time, event, value);
   }
 
+  public inline function valueAsStruct(?defaultKey:String = "key"):Dynamic
+  {
+    if (this.value == null) return {};
+    if (Std.isOfType(this.value, Array))
+    {
+      var result:haxe.DynamicAccess<Dynamic> = {};
+      result.set(defaultKey, this.value);
+      return cast result;
+    }
+    else if (Reflect.isObject(this.value))
+    {
+      // We enter this case if the value is a struct.
+      return cast this.value;
+    }
+    else
+    {
+      var result:haxe.DynamicAccess<Dynamic> = {};
+      result.set(defaultKey, this.value);
+      return cast result;
+    }
+  }
+
+  public inline function getHandler():Null<SongEvent>
+  {
+    return SongEventParser.getEvent(this.event);
+  }
+
+  public inline function getSchema():Null<SongEventSchema>
+  {
+    return SongEventParser.getEventSchema(this.event);
+  }
+
   public inline function getDynamic(key:String):Null<Dynamic>
   {
     return this.value == null ? null : Reflect.field(this.value, key);
@@ -662,6 +697,32 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
     return this.value == null ? null : cast Reflect.field(this.value, key);
   }
 
+  public function buildTooltip():String
+  {
+    var eventHandler = getHandler();
+    var eventSchema = getSchema();
+
+    if (eventSchema == null) return 'Unknown Event: ${this.event}';
+
+    var result = '${eventHandler.getTitle()}';
+
+    var defaultKey = eventSchema.getFirstField()?.name;
+    var valueStruct:haxe.DynamicAccess<Dynamic> = valueAsStruct(defaultKey);
+
+    for (pair in valueStruct.keyValueIterator())
+    {
+      var key = pair.key;
+      var value = pair.value;
+
+      var title = eventSchema.getByName(key)?.title ?? 'UnknownField';
+      var valueStr = eventSchema.stringifyFieldValue(key, value);
+
+      result += '\n- ${title}: ${valueStr}';
+    }
+
+    return result;
+  }
+
   public function clone():SongEventData
   {
     return new SongEventData(this.time, this.event, this.value);
diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx
index 5f63254b0..c91769eb5 100644
--- a/source/funkin/play/event/FocusCameraSongEvent.hx
+++ b/source/funkin/play/event/FocusCameraSongEvent.hx
@@ -132,7 +132,7 @@ class FocusCameraSongEvent extends SongEvent
    */
   public override function getEventSchema():SongEventSchema
   {
-    return [
+    return new SongEventSchema([
       {
         name: "char",
         title: "Character",
@@ -154,6 +154,6 @@ class FocusCameraSongEvent extends SongEvent
         step: 10.0,
         type: SongEventFieldType.FLOAT,
       }
-    ];
+    ]);
   }
 }
diff --git a/source/funkin/play/event/PlayAnimationSongEvent.hx b/source/funkin/play/event/PlayAnimationSongEvent.hx
index 6bc625517..0f611874b 100644
--- a/source/funkin/play/event/PlayAnimationSongEvent.hx
+++ b/source/funkin/play/event/PlayAnimationSongEvent.hx
@@ -89,7 +89,7 @@ class PlayAnimationSongEvent extends SongEvent
    */
   public override function getEventSchema():SongEventSchema
   {
-    return [
+    return new SongEventSchema([
       {
         name: 'target',
         title: 'Target',
@@ -108,6 +108,6 @@ class PlayAnimationSongEvent extends SongEvent
         type: SongEventFieldType.BOOL,
         defaultValue: false
       }
-    ];
+    ]);
   }
 }
diff --git a/source/funkin/play/event/SetCameraBopSongEvent.hx b/source/funkin/play/event/SetCameraBopSongEvent.hx
index 3cdeb9a67..7d5fd4699 100644
--- a/source/funkin/play/event/SetCameraBopSongEvent.hx
+++ b/source/funkin/play/event/SetCameraBopSongEvent.hx
@@ -72,7 +72,7 @@ class SetCameraBopSongEvent extends SongEvent
    */
   public override function getEventSchema():SongEventSchema
   {
-    return [
+    return new SongEventSchema([
       {
         name: 'intensity',
         title: 'Intensity',
@@ -87,6 +87,6 @@ class SetCameraBopSongEvent extends SongEvent
         step: 1,
         type: SongEventFieldType.INTEGER,
       }
-    ];
+    ]);
   }
 }
diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx
index 1ae76039e..9a361f71b 100644
--- a/source/funkin/play/event/ZoomCameraSongEvent.hx
+++ b/source/funkin/play/event/ZoomCameraSongEvent.hx
@@ -99,7 +99,7 @@ class ZoomCameraSongEvent extends SongEvent
    */
   public override function getEventSchema():SongEventSchema
   {
-    return [
+    return new SongEventSchema([
       {
         name: 'zoom',
         title: 'Zoom Level',
@@ -145,6 +145,6 @@ class ZoomCameraSongEvent extends SongEvent
           'Elastic In/Out' => 'elasticInOut',
         ]
       }
-    ];
+    ]);
   }
 }
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 4f96fad69..5c12e3408 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -2113,7 +2113,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     add(gridGhostHoldNote);
     gridGhostHoldNote.zIndex = 11;
 
-    gridGhostEvent = new ChartEditorEventSprite(this);
+    gridGhostEvent = new ChartEditorEventSprite(this, true);
     gridGhostEvent.alpha = 0.6;
     gridGhostEvent.eventData = new SongEventData(-1, '', {});
     gridGhostEvent.visible = false;
@@ -3127,6 +3127,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         // Setting event data resets position relative to the grid so we fix that.
         eventSprite.x += renderedEvents.x;
         eventSprite.y += renderedEvents.y;
+        eventSprite.updateTooltipPosition();
       }
 
       // Add hold notes that have been made visible (but not their parents)
diff --git a/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx
index 4c9d91407..cc9acf344 100644
--- a/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx
@@ -11,6 +11,9 @@ import flixel.graphics.frames.FlxFramesCollection;
 import flixel.graphics.frames.FlxTileFrames;
 import flixel.math.FlxPoint;
 import funkin.data.song.SongData.SongEventData;
+import haxe.ui.tooltips.ToolTipRegionOptions;
+import funkin.util.HaxeUIUtil;
+import haxe.ui.tooltips.ToolTipManager;
 
 /**
  * A sprite that can be used to display a song event in a chart.
@@ -36,6 +39,13 @@ class ChartEditorEventSprite extends FlxSprite
 
   public var overrideStepTime(default, set):Null<Float> = null;
 
+  public var tooltip:ToolTipRegionOptions;
+
+  /**
+   * Whether this sprite is a "ghost" sprite used when hovering to place a new event.
+   */
+  public var isGhost:Bool = false;
+
   function set_overrideStepTime(value:Null<Float>):Null<Float>
   {
     if (overrideStepTime == value) return overrideStepTime;
@@ -45,12 +55,14 @@ class ChartEditorEventSprite extends FlxSprite
     return overrideStepTime;
   }
 
-  public function new(parent:ChartEditorState)
+  public function new(parent:ChartEditorState, isGhost:Bool = false)
   {
     super();
 
     this.parentState = parent;
+    this.isGhost = isGhost;
 
+    this.tooltip = HaxeUIUtil.buildTooltip('N/A');
     this.frames = buildFrames();
 
     buildAnimations();
@@ -140,6 +152,7 @@ class ChartEditorEventSprite extends FlxSprite
       // Disown parent. MAKE SURE TO REVIVE BEFORE REUSING
       this.kill();
       this.visible = false;
+      updateTooltipPosition();
       return null;
     }
     else
@@ -151,6 +164,8 @@ class ChartEditorEventSprite extends FlxSprite
       this.eventData = value;
       // Update the position to match the note data.
       updateEventPosition();
+      // Update the tooltip text.
+      this.tooltip.tipData = {text: this.eventData.buildTooltip()};
       return this.eventData;
     }
   }
@@ -169,6 +184,31 @@ class ChartEditorEventSprite extends FlxSprite
       this.x += origin.x;
       this.y += origin.y;
     }
+
+    this.updateTooltipPosition();
+  }
+
+  public function updateTooltipPosition():Void
+  {
+    // No tooltip for ghost sprites.
+    if (this.isGhost) return;
+
+    if (this.eventData == null)
+    {
+      // Disable the tooltip.
+      ToolTipManager.instance.unregisterTooltipRegion(this.tooltip);
+    }
+    else
+    {
+      // Update the position.
+      this.tooltip.left = this.x;
+      this.tooltip.top = this.y;
+      this.tooltip.width = this.width;
+      this.tooltip.height = this.height;
+
+      // Enable the tooltip.
+      ToolTipManager.instance.registerTooltipRegion(this.tooltip);
+    }
   }
 
   /**
diff --git a/source/funkin/util/HaxeUIUtil.hx b/source/funkin/util/HaxeUIUtil.hx
new file mode 100644
index 000000000..1ffd9cd40
--- /dev/null
+++ b/source/funkin/util/HaxeUIUtil.hx
@@ -0,0 +1,17 @@
+package funkin.util;
+
+import haxe.ui.tooltips.ToolTipRegionOptions;
+
+class HaxeUIUtil
+{
+  public static function buildTooltip(text:String, ?left:Float, ?top:Float, ?width:Float, ?height:Float):ToolTipRegionOptions
+  {
+    return {
+      tipData: {text: text},
+      left: left ?? 0.0,
+      top: top ?? 0.0,
+      width: width ?? 0.0,
+      height: height ?? 0.0
+    }
+  }
+}