From 4804b9ae22eec299d84569402cb25ca21baf6a86 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sat, 6 Jan 2024 01:06:10 -0500
Subject: [PATCH] Work in progress for Chart Editor gamepad support (navigation
 implemented, note placement WIP)

---
 source/funkin/input/Controls.hx               | 367 +++++++++++-----
 source/funkin/input/TurboActionHandler.hx     | 111 +++++
 source/funkin/input/TurboButtonHandler.hx     | 127 ++++++
 .../ui/debug/charting/ChartEditorState.hx     | 397 ++++++++++++++++--
 .../charting/commands/RemoveEventsCommand.hx  |   4 +
 .../charting/commands/RemoveItemsCommand.hx   |   4 +
 .../charting/commands/RemoveNotesCommand.hx   |   4 +
 .../components/ChartEditorHoldNoteSprite.hx   |   9 +-
 .../handlers/ChartEditorGamepadHandler.hx     | 133 ++++++
 source/funkin/ui/debug/charting/import.hx     |   1 +
 10 files changed, 1009 insertions(+), 148 deletions(-)
 create mode 100644 source/funkin/input/TurboActionHandler.hx
 create mode 100644 source/funkin/input/TurboButtonHandler.hx
 create mode 100644 source/funkin/ui/debug/charting/handlers/ChartEditorGamepadHandler.hx

diff --git a/source/funkin/input/Controls.hx b/source/funkin/input/Controls.hx
index 3fa4e0f75..5c761cd19 100644
--- a/source/funkin/input/Controls.hx
+++ b/source/funkin/input/Controls.hx
@@ -30,47 +30,31 @@ class Controls extends FlxActionSet
    * A list of actions that a player would invoke via some input device.
    * Uses FlxActions to funnel various inputs to a single action.
    */
-  var _ui_up = new FlxActionDigital(Action.UI_UP);
-  var _ui_left = new FlxActionDigital(Action.UI_LEFT);
-  var _ui_right = new FlxActionDigital(Action.UI_RIGHT);
-  var _ui_down = new FlxActionDigital(Action.UI_DOWN);
-  var _ui_upP = new FlxActionDigital(Action.UI_UP_P);
-  var _ui_leftP = new FlxActionDigital(Action.UI_LEFT_P);
-  var _ui_rightP = new FlxActionDigital(Action.UI_RIGHT_P);
-  var _ui_downP = new FlxActionDigital(Action.UI_DOWN_P);
-  var _ui_upR = new FlxActionDigital(Action.UI_UP_R);
-  var _ui_leftR = new FlxActionDigital(Action.UI_LEFT_R);
-  var _ui_rightR = new FlxActionDigital(Action.UI_RIGHT_R);
-  var _ui_downR = new FlxActionDigital(Action.UI_DOWN_R);
-  var _note_up = new FlxActionDigital(Action.NOTE_UP);
-  var _note_left = new FlxActionDigital(Action.NOTE_LEFT);
-  var _note_right = new FlxActionDigital(Action.NOTE_RIGHT);
-  var _note_down = new FlxActionDigital(Action.NOTE_DOWN);
-  var _note_upP = new FlxActionDigital(Action.NOTE_UP_P);
-  var _note_leftP = new FlxActionDigital(Action.NOTE_LEFT_P);
-  var _note_rightP = new FlxActionDigital(Action.NOTE_RIGHT_P);
-  var _note_downP = new FlxActionDigital(Action.NOTE_DOWN_P);
-  var _note_upR = new FlxActionDigital(Action.NOTE_UP_R);
-  var _note_leftR = new FlxActionDigital(Action.NOTE_LEFT_R);
-  var _note_rightR = new FlxActionDigital(Action.NOTE_RIGHT_R);
-  var _note_downR = new FlxActionDigital(Action.NOTE_DOWN_R);
-  var _accept = new FlxActionDigital(Action.ACCEPT);
-  var _back = new FlxActionDigital(Action.BACK);
-  var _pause = new FlxActionDigital(Action.PAUSE);
-  var _reset = new FlxActionDigital(Action.RESET);
-  var _cutscene_advance = new FlxActionDigital(Action.CUTSCENE_ADVANCE);
-  var _cutscene_skip = new FlxActionDigital(Action.CUTSCENE_SKIP);
-  var _debug_menu = new FlxActionDigital(Action.DEBUG_MENU);
-  var _debug_chart = new FlxActionDigital(Action.DEBUG_CHART);
-  var _debug_stage = new FlxActionDigital(Action.DEBUG_STAGE);
-  var _volume_up = new FlxActionDigital(Action.VOLUME_UP);
-  var _volume_down = new FlxActionDigital(Action.VOLUME_DOWN);
-  var _volume_mute = new FlxActionDigital(Action.VOLUME_MUTE);
+  var _ui_up = new FunkinAction(Action.UI_UP, Action.UI_UP_P, Action.UI_UP_R);
+  var _ui_left = new FunkinAction(Action.UI_LEFT, Action.UI_LEFT_P, Action.UI_LEFT_R);
+  var _ui_right = new FunkinAction(Action.UI_RIGHT, Action.UI_RIGHT_P, Action.UI_RIGHT_R);
+  var _ui_down = new FunkinAction(Action.UI_DOWN, Action.UI_DOWN_P, Action.UI_DOWN_R);
+  var _note_up = new FunkinAction(Action.NOTE_UP, Action.NOTE_UP_P, Action.NOTE_UP_R);
+  var _note_left = new FunkinAction(Action.NOTE_LEFT, Action.NOTE_LEFT_P, Action.NOTE_LEFT_R);
+  var _note_right = new FunkinAction(Action.NOTE_RIGHT, Action.NOTE_RIGHT_P, Action.NOTE_RIGHT_R);
+  var _note_down = new FunkinAction(Action.NOTE_DOWN, Action.NOTE_DOWN_P, Action.NOTE_DOWN_R);
+  var _accept = new FunkinAction(Action.ACCEPT);
+  var _back = new FunkinAction(Action.BACK);
+  var _pause = new FunkinAction(Action.PAUSE);
+  var _reset = new FunkinAction(Action.RESET);
+  var _cutscene_advance = new FunkinAction(Action.CUTSCENE_ADVANCE);
+  var _cutscene_skip = new FunkinAction(Action.CUTSCENE_SKIP);
+  var _debug_menu = new FunkinAction(Action.DEBUG_MENU);
+  var _debug_chart = new FunkinAction(Action.DEBUG_CHART);
+  var _debug_stage = new FunkinAction(Action.DEBUG_STAGE);
+  var _volume_up = new FunkinAction(Action.VOLUME_UP);
+  var _volume_down = new FunkinAction(Action.VOLUME_DOWN);
+  var _volume_mute = new FunkinAction(Action.VOLUME_MUTE);
   #if CAN_CHEAT
-  var _cheat = new FlxActionDigital(Action.CHEAT);
+  var _cheat = new FunkinAction(Action.CHEAT);
   #end
 
-  var byName:Map<String, FlxActionDigital> = new Map<String, FlxActionDigital>();
+  var byName:Map<String, FunkinAction> = new Map<String, FunkinAction>();
 
   public var gamepadsAdded:Array<Int> = [];
   public var keyboardScheme = KeyboardScheme.None;
@@ -78,122 +62,142 @@ class Controls extends FlxActionSet
   public var UI_UP(get, never):Bool;
 
   inline function get_UI_UP()
-    return _ui_up.check();
+    return _ui_up.checkPressed();
 
   public var UI_LEFT(get, never):Bool;
 
   inline function get_UI_LEFT()
-    return _ui_left.check();
+    return _ui_left.checkPressed();
 
   public var UI_RIGHT(get, never):Bool;
 
   inline function get_UI_RIGHT()
-    return _ui_right.check();
+    return _ui_right.checkPressed();
 
   public var UI_DOWN(get, never):Bool;
 
   inline function get_UI_DOWN()
-    return _ui_down.check();
+    return _ui_down.checkPressed();
 
   public var UI_UP_P(get, never):Bool;
 
   inline function get_UI_UP_P()
-    return _ui_upP.check();
+    return _ui_up.checkJustPressed();
 
   public var UI_LEFT_P(get, never):Bool;
 
   inline function get_UI_LEFT_P()
-    return _ui_leftP.check();
+    return _ui_left.checkJustPressed();
 
   public var UI_RIGHT_P(get, never):Bool;
 
   inline function get_UI_RIGHT_P()
-    return _ui_rightP.check();
+    return _ui_right.checkJustPressed();
 
   public var UI_DOWN_P(get, never):Bool;
 
   inline function get_UI_DOWN_P()
-    return _ui_downP.check();
+    return _ui_down.checkJustPressed();
 
   public var UI_UP_R(get, never):Bool;
 
   inline function get_UI_UP_R()
-    return _ui_upR.check();
+    return _ui_up.checkJustReleased();
 
   public var UI_LEFT_R(get, never):Bool;
 
   inline function get_UI_LEFT_R()
-    return _ui_leftR.check();
+    return _ui_left.checkJustReleased();
 
   public var UI_RIGHT_R(get, never):Bool;
 
   inline function get_UI_RIGHT_R()
-    return _ui_rightR.check();
+    return _ui_right.checkJustReleased();
 
   public var UI_DOWN_R(get, never):Bool;
 
   inline function get_UI_DOWN_R()
-    return _ui_downR.check();
+    return _ui_down.checkJustReleased();
+
+  public var UI_UP_GAMEPAD(get, never):Bool;
+
+  inline function get_UI_UP_GAMEPAD()
+    return _ui_up.checkPressedGamepad();
+
+  public var UI_LEFT_GAMEPAD(get, never):Bool;
+
+  inline function get_UI_LEFT_GAMEPAD()
+    return _ui_left.checkPressedGamepad();
+
+  public var UI_RIGHT_GAMEPAD(get, never):Bool;
+
+  inline function get_UI_RIGHT_GAMEPAD()
+    return _ui_right.checkPressedGamepad();
+
+  public var UI_DOWN_GAMEPAD(get, never):Bool;
+
+  inline function get_UI_DOWN_GAMEPAD()
+    return _ui_down.checkPressedGamepad();
 
   public var NOTE_UP(get, never):Bool;
 
   inline function get_NOTE_UP()
-    return _note_up.check();
+    return _note_up.checkPressed();
 
   public var NOTE_LEFT(get, never):Bool;
 
   inline function get_NOTE_LEFT()
-    return _note_left.check();
+    return _note_left.checkPressed();
 
   public var NOTE_RIGHT(get, never):Bool;
 
   inline function get_NOTE_RIGHT()
-    return _note_right.check();
+    return _note_right.checkPressed();
 
   public var NOTE_DOWN(get, never):Bool;
 
   inline function get_NOTE_DOWN()
-    return _note_down.check();
+    return _note_down.checkPressed();
 
   public var NOTE_UP_P(get, never):Bool;
 
   inline function get_NOTE_UP_P()
-    return _note_upP.check();
+    return _note_up.checkJustPressed();
 
   public var NOTE_LEFT_P(get, never):Bool;
 
   inline function get_NOTE_LEFT_P()
-    return _note_leftP.check();
+    return _note_left.checkJustPressed();
 
   public var NOTE_RIGHT_P(get, never):Bool;
 
   inline function get_NOTE_RIGHT_P()
-    return _note_rightP.check();
+    return _note_right.checkJustPressed();
 
   public var NOTE_DOWN_P(get, never):Bool;
 
   inline function get_NOTE_DOWN_P()
-    return _note_downP.check();
+    return _note_down.checkJustPressed();
 
   public var NOTE_UP_R(get, never):Bool;
 
   inline function get_NOTE_UP_R()
-    return _note_upR.check();
+    return _note_up.checkJustReleased();
 
   public var NOTE_LEFT_R(get, never):Bool;
 
   inline function get_NOTE_LEFT_R()
-    return _note_leftR.check();
+    return _note_left.checkJustReleased();
 
   public var NOTE_RIGHT_R(get, never):Bool;
 
   inline function get_NOTE_RIGHT_R()
-    return _note_rightR.check();
+    return _note_right.checkJustReleased();
 
   public var NOTE_DOWN_R(get, never):Bool;
 
   inline function get_NOTE_DOWN_R()
-    return _note_downR.check();
+    return _note_down.checkJustReleased();
 
   public var ACCEPT(get, never):Bool;
 
@@ -270,26 +274,10 @@ class Controls extends FlxActionSet
     add(_ui_left);
     add(_ui_right);
     add(_ui_down);
-    add(_ui_upP);
-    add(_ui_leftP);
-    add(_ui_rightP);
-    add(_ui_downP);
-    add(_ui_upR);
-    add(_ui_leftR);
-    add(_ui_rightR);
-    add(_ui_downR);
     add(_note_up);
     add(_note_left);
     add(_note_right);
     add(_note_down);
-    add(_note_upP);
-    add(_note_leftP);
-    add(_note_rightP);
-    add(_note_downP);
-    add(_note_upR);
-    add(_note_leftR);
-    add(_note_rightR);
-    add(_note_downR);
     add(_accept);
     add(_back);
     add(_pause);
@@ -303,8 +291,16 @@ class Controls extends FlxActionSet
     add(_cheat);
     #end
 
-    for (action in digitalActions)
-      byName[action.name] = action;
+    for (action in digitalActions) {
+      if (Std.isOfType(action, FunkinAction)) {
+        var funkinAction:FunkinAction = cast action;
+        byName[funkinAction.name] = funkinAction;
+        if (funkinAction.namePressed != null)
+          byName[funkinAction.namePressed] = funkinAction;
+        if (funkinAction.nameReleased != null)
+          byName[funkinAction.nameReleased] = funkinAction;
+      }
+    }
 
     if (scheme == null)
       scheme = None;
@@ -317,14 +313,17 @@ class Controls extends FlxActionSet
     super.update();
   }
 
-  // inline
-  public function checkByName(name:Action):Bool
+  public function check(name:Action, trigger:FlxInputState = JUST_PRESSED, gamepadOnly:Bool = false):Bool
   {
     #if debug
     if (!byName.exists(name))
       throw 'Invalid name: $name';
     #end
-    return byName[name].check();
+    var action = byName[name];
+    if (gamepadOnly)
+      return action.checkFiltered(trigger, GAMEPAD);
+    else
+      return action.checkFiltered(trigger);
   }
 
   public function getKeysForAction(name:Action):Array<FlxKey> {
@@ -411,36 +410,36 @@ class Controls extends FlxActionSet
     {
       case UI_UP:
         func(_ui_up, PRESSED);
-        func(_ui_upP, JUST_PRESSED);
-        func(_ui_upR, JUST_RELEASED);
+        func(_ui_up, JUST_PRESSED);
+        func(_ui_up, JUST_RELEASED);
       case UI_LEFT:
         func(_ui_left, PRESSED);
-        func(_ui_leftP, JUST_PRESSED);
-        func(_ui_leftR, JUST_RELEASED);
+        func(_ui_left, JUST_PRESSED);
+        func(_ui_left, JUST_RELEASED);
       case UI_RIGHT:
         func(_ui_right, PRESSED);
-        func(_ui_rightP, JUST_PRESSED);
-        func(_ui_rightR, JUST_RELEASED);
+        func(_ui_right, JUST_PRESSED);
+        func(_ui_right, JUST_RELEASED);
       case UI_DOWN:
         func(_ui_down, PRESSED);
-        func(_ui_downP, JUST_PRESSED);
-        func(_ui_downR, JUST_RELEASED);
+        func(_ui_down, JUST_PRESSED);
+        func(_ui_down, JUST_RELEASED);
       case NOTE_UP:
         func(_note_up, PRESSED);
-        func(_note_upP, JUST_PRESSED);
-        func(_note_upR, JUST_RELEASED);
+        func(_note_up, JUST_PRESSED);
+        func(_note_up, JUST_RELEASED);
       case NOTE_LEFT:
         func(_note_left, PRESSED);
-        func(_note_leftP, JUST_PRESSED);
-        func(_note_leftR, JUST_RELEASED);
+        func(_note_left, JUST_PRESSED);
+        func(_note_left, JUST_RELEASED);
       case NOTE_RIGHT:
         func(_note_right, PRESSED);
-        func(_note_rightP, JUST_PRESSED);
-        func(_note_rightR, JUST_RELEASED);
+        func(_note_right, JUST_PRESSED);
+        func(_note_right, JUST_RELEASED);
       case NOTE_DOWN:
         func(_note_down, PRESSED);
-        func(_note_downP, JUST_PRESSED);
-        func(_note_downR, JUST_RELEASED);
+        func(_note_down, JUST_PRESSED);
+        func(_note_down, JUST_RELEASED);
       case ACCEPT:
         func(_accept, JUST_PRESSED);
       case BACK:
@@ -1053,6 +1052,173 @@ typedef Swipes =
   ?curTouchPos:FlxPoint
 };
 
+/**
+ * An FlxActionDigital with additional functionality, including:
+ * - Combining `pressed` and `released` inputs into one action.
+ * - Filtering by input method (`KEYBOARD`, `MOUSE`, `GAMEPAD`, etc).
+ */
+class FunkinAction extends FlxActionDigital {
+  public var namePressed(default, null):Null<String>;
+  public var nameReleased(default, null):Null<String>;
+
+  var cache:Map<String, {timestamp:Int, value:Bool}> = [];
+
+  public function new(?name:String = "", ?namePressed:String, ?nameReleased:String)
+  {
+    super(name);
+
+    this.namePressed = namePressed;
+    this.nameReleased = nameReleased;
+  }
+
+  /**
+   * Input checks default to whether the input was just pressed, on any input device.
+   */
+  public override function check():Bool {
+    return checkFiltered(JUST_PRESSED);
+  }
+
+  /**
+   * Check whether the input is currently being held.
+   */
+  public function checkPressed():Bool {
+    return checkFiltered(PRESSED);
+  }
+
+  /**
+   * Check whether the input is currently being held, and was not held last frame.
+   */
+  public function checkJustPressed():Bool {
+    return checkFiltered(JUST_PRESSED);
+  }
+
+  /**
+   * Check whether the input is not currently being held.
+   */
+  public function checkReleased():Bool {
+    return checkFiltered(RELEASED);
+  }
+
+  /**
+   * Check whether the input is not currently being held, and was held last frame.
+   */
+  public function checkJustReleased():Bool {
+    return checkFiltered(JUST_RELEASED);
+  }
+
+  /**
+   * Check whether the input is currently being held by a gamepad device.
+   */
+  public function checkPressedGamepad():Bool {
+    return checkFiltered(PRESSED, GAMEPAD);
+  }
+
+  /**
+   * Check whether the input is currently being held by a gamepad device, and was not held last frame.
+   */
+  public function checkJustPressedGamepad():Bool {
+    return checkFiltered(JUST_PRESSED, GAMEPAD);
+  }
+
+  /**
+   * Check whether the input is not currently being held by a gamepad device.
+   */
+  public function checkReleasedGamepad():Bool {
+    return checkFiltered(RELEASED, GAMEPAD);
+  }
+
+  /**
+   * Check whether the input is not currently being held by a gamepad device, and was held last frame.
+   */
+  public function checkJustReleasedGamepad():Bool {
+    return checkFiltered(JUST_RELEASED, GAMEPAD);
+  }
+
+  public function checkMultiFiltered(?filterTriggers:Array<FlxInputState>, ?filterDevices:Array<FlxInputDevice>):Bool {
+    if (filterTriggers == null) {
+      filterTriggers = [PRESSED, JUST_PRESSED];
+    }
+    if (filterDevices == null) {
+      filterDevices = [];
+    }
+
+    // Perform checkFiltered for each combination.
+    for (i in filterTriggers) {
+      if (filterDevices.length == 0) {
+        if (checkFiltered(i)) {
+          return true;
+        }
+      } else {
+        for (j in filterDevices) {
+          if (checkFiltered(i, j)) {
+            return true;
+          }
+        }
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Performs the functionality of `FlxActionDigital.check()`, but with optional filters.
+   * @param action The action to check for.
+   * @param filterTrigger Optionally filter by trigger condition (`JUST_PRESSED`, `PRESSED`, `JUST_RELEASED`, `RELEASED`).
+   * @param filterDevice Optionally filter by device (`KEYBOARD`, `MOUSE`, `GAMEPAD`, `OTHER`).
+   */
+  public function checkFiltered(?filterTrigger:FlxInputState, ?filterDevice:FlxInputDevice):Bool {
+    // The normal
+
+    // Make sure we only update the inputs once per frame.
+    var key = '${filterTrigger}:${filterDevice}';
+    var cacheEntry = cache.get(key);
+
+    if (cacheEntry != null && cacheEntry.timestamp == FlxG.game.ticks) {
+      return cacheEntry.value;
+    }
+    // Use a for loop instead so we can remove inputs while iterating.
+
+    // We don't return early because we need to call check() on ALL inputs.
+    var result = false;
+		var len = inputs != null ? inputs.length : 0;
+		for (i in 0...len)
+		{
+			var j = len - i - 1;
+			var input = inputs[j];
+
+      // Filter out dead inputs.
+			if (input.destroyed)
+			{
+				inputs.splice(j, 1);
+				continue;
+			}
+
+      // Update the input.
+      input.update();
+
+      // Check whether the input is the right trigger.
+      if (filterTrigger != null && input.trigger != filterTrigger) {
+        continue;
+      }
+
+      // Check whether the input is the right device.
+      if (filterDevice != null && input.device != filterDevice) {
+        continue;
+      }
+
+      // Check whether the input has triggered.
+			if (input.check(this))
+			{
+				result = true;
+			}
+		}
+
+    // We need to cache this result.
+    cache.set(key, {timestamp: FlxG.game.ticks, value: result});
+
+    return result;
+  }
+}
+
 class FlxActionInputDigitalMobileSwipeGameplay extends FlxActionInputDigital
 {
   var touchMap:Map<Int, Swipes> = new Map();
@@ -1242,8 +1408,7 @@ enum Control
   #end
 }
 
-enum
-abstract Action(String) to String from String
+enum abstract Action(String) to String from String
 {
   // NOTE
   var NOTE_UP = "note_up";
diff --git a/source/funkin/input/TurboActionHandler.hx b/source/funkin/input/TurboActionHandler.hx
new file mode 100644
index 000000000..9425db8cd
--- /dev/null
+++ b/source/funkin/input/TurboActionHandler.hx
@@ -0,0 +1,111 @@
+package funkin.input;
+
+import flixel.input.keyboard.FlxKey;
+import flixel.FlxBasic;
+import funkin.input.Controls;
+import funkin.input.Controls.Action;
+
+/**
+ * Handles repeating behavior when holding down a control action.
+ *
+ * When the `action` is pressed, `activated` will be true for the first frame,
+ * then wait `delay` seconds before becoming true for one frame every `interval` seconds.
+ *
+ * Example: Pressing Ctrl+Z will undo, while holding Ctrl+Z will start to undo repeatedly.
+ */
+class TurboActionHandler extends FlxBasic
+{
+  /**
+   * Default delay before repeating.
+   */
+  static inline final DEFAULT_DELAY:Float = 0.4;
+
+  /**
+   * Default interval between repeats.
+   */
+  static inline final DEFAULT_INTERVAL:Float = 0.1;
+
+  /**
+   * Whether the action for this handler is pressed.
+   */
+  public var pressed(get, never):Bool;
+
+  /**
+   * Whether the action for this handler is pressed,
+   * and the handler is ready to repeat.
+   */
+  public var activated(default, null):Bool = false;
+
+  /**
+   * The Funkin Controls handler.
+   */
+  var controls(get, never):Controls;
+
+  function get_controls():Controls
+  {
+    return PlayerSettings.player1.controls;
+  }
+
+  var action:Action;
+
+  var delay:Float;
+  var interval:Float;
+  var gamepadOnly:Bool;
+
+  var pressedTime:Float = 0;
+
+  function new(action:Action, delay:Float = DEFAULT_DELAY, interval:Float = DEFAULT_INTERVAL, gamepadOnly:Bool = false)
+  {
+    super();
+    this.action = action;
+    this.delay = delay;
+    this.interval = interval;
+    this.gamepadOnly = gamepadOnly;
+  }
+
+  function get_pressed():Bool
+  {
+    return controls.check(action, PRESSED, gamepadOnly);
+  }
+
+  public override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    if (pressed)
+    {
+      if (pressedTime == 0)
+      {
+        activated = true;
+      }
+      else if (pressedTime >= (delay + interval))
+      {
+        activated = true;
+        pressedTime -= interval;
+      }
+      else
+      {
+        activated = false;
+      }
+      pressedTime += elapsed;
+    }
+    else
+    {
+      pressedTime = 0;
+      activated = false;
+    }
+  }
+
+  /**
+   * Builds a TurboActionHandler that monitors from a single key.
+   * @param inputKey The key to monitor.
+   * @param delay How long to wait before repeating.
+   * @param repeatDelay How long to wait between repeats.
+   * @return A TurboActionHandler
+   */
+  public static overload inline extern function build(action:Action, ?delay:Float = DEFAULT_DELAY, ?interval:Float = DEFAULT_INTERVAL,
+      ?gamepadOnly:Bool = false):TurboActionHandler
+  {
+    return new TurboActionHandler(action, delay, interval);
+  }
+}
diff --git a/source/funkin/input/TurboButtonHandler.hx b/source/funkin/input/TurboButtonHandler.hx
new file mode 100644
index 000000000..63c2a294b
--- /dev/null
+++ b/source/funkin/input/TurboButtonHandler.hx
@@ -0,0 +1,127 @@
+package funkin.input;
+
+import flixel.input.gamepad.FlxGamepadInputID;
+import flixel.input.gamepad.FlxGamepad;
+import flixel.FlxBasic;
+
+/**
+ * Handles repeating behavior when holding down a gamepad button or button combination.
+ *
+ * When the `inputs` are pressed, `activated` will be true for the first frame,
+ * then wait `delay` seconds before becoming true for one frame every `interval` seconds.
+ *
+ * Example: Pressing Ctrl+Z will undo, while holding Ctrl+Z will start to undo repeatedly.
+ */
+class TurboButtonHandler extends FlxBasic
+{
+  /**
+   * Default delay before repeating.
+   */
+  static inline final DEFAULT_DELAY:Float = 0.4;
+
+  /**
+   * Default interval between repeats.
+   */
+  static inline final DEFAULT_INTERVAL:Float = 0.1;
+
+  /**
+   * Whether all of the keys for this handler are pressed.
+   */
+  public var allPressed(get, never):Bool;
+
+  /**
+   * Whether all of the keys for this handler are activated,
+   * and the handler is ready to repeat.
+   */
+  public var activated(default, null):Bool = false;
+
+  var inputs:Array<FlxGamepadInputID>;
+  var delay:Float;
+  var interval:Float;
+  var targetGamepad:FlxGamepad;
+
+  var allPressedTime:Float = 0;
+
+  function new(inputs:Array<FlxGamepadInputID>, delay:Float = DEFAULT_DELAY, interval:Float = DEFAULT_INTERVAL, ?targetGamepad:FlxGamepad)
+  {
+    super();
+    this.inputs = inputs;
+    this.delay = delay;
+    this.interval = interval;
+    this.targetGamepad = targetGamepad ?? FlxG.gamepads.firstActive;
+  }
+
+  function get_allPressed():Bool
+  {
+    if (targetGamepad == null) return false;
+    if (inputs == null || inputs.length == 0) return false;
+    if (inputs.length == 1) return targetGamepad.anyPressed(inputs);
+
+    // Check if ANY keys are unpressed
+    for (input in inputs)
+    {
+      if (!targetGamepad.anyPressed([input])) return false;
+    }
+    return true;
+  }
+
+  public override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    // Try to find a gamepad if we don't have one
+    if (targetGamepad == null)
+    {
+      targetGamepad = FlxG.gamepads.firstActive;
+    }
+
+    if (allPressed)
+    {
+      if (allPressedTime == 0)
+      {
+        activated = true;
+      }
+      else if (allPressedTime >= (delay + interval))
+      {
+        activated = true;
+        allPressedTime -= interval;
+      }
+      else
+      {
+        activated = false;
+      }
+      allPressedTime += elapsed;
+    }
+    else
+    {
+      allPressedTime = 0;
+      activated = false;
+    }
+  }
+
+  /**
+   * Builds a TurboButtonHandler that monitors from a single input.
+   * @param input The input to monitor.
+   * @param delay How long to wait before repeating.
+   * @param repeatDelay How long to wait between repeats.
+   * @return A TurboKeyHandler
+   */
+  public static overload inline extern function build(input:FlxGamepadInputID, ?delay:Float = DEFAULT_DELAY,
+      ?interval:Float = DEFAULT_INTERVAL):TurboButtonHandler
+  {
+    return new TurboButtonHandler([input], delay, interval);
+  }
+
+  /**
+   * Builds a TurboKeyHandler that monitors a key combination.
+   * @param inputs The combination of inputs to monitor.
+   * @param delay How long to wait before repeating.
+   * @param repeatDelay How long to wait between repeats.
+   * @return A TurboKeyHandler
+   */
+  public static overload inline extern function build(inputs:Array<FlxGamepadInputID>, ?delay:Float = DEFAULT_DELAY,
+      ?interval:Float = DEFAULT_INTERVAL):TurboButtonHandler
+  {
+    return new TurboButtonHandler(inputs, delay, interval);
+  }
+}
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 1773a84fe..f3236578a 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1,24 +1,25 @@
 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;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.FlxCamera;
 import flixel.FlxSprite;
 import flixel.FlxSubState;
+import flixel.graphics.FlxGraphic;
+import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.group.FlxSpriteGroup;
+import flixel.input.gamepad.FlxGamepadInputID;
 import flixel.input.keyboard.FlxKey;
+import flixel.input.mouse.FlxMouseEvent;
 import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
 import flixel.graphics.FlxGraphic;
 import flixel.math.FlxRect;
 import flixel.sound.FlxSound;
+import flixel.system.debug.log.LogStyle;
 import flixel.system.FlxAssets.FlxSoundAsset;
+import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.tweens.misc.VarTween;
@@ -26,19 +27,31 @@ import haxe.ui.Toolkit;
 import flixel.util.FlxColor;
 import flixel.util.FlxSort;
 import flixel.util.FlxTimer;
-import funkin.audio.visualize.PolygonSpectogram;
-import funkin.audio.VoicesGroup;
 import funkin.audio.FunkinSound;
+import funkin.audio.visualize.PolygonSpectogram;
+import funkin.audio.visualize.PolygonSpectogram;
+import funkin.audio.visualize.PolygonVisGroup;
+import funkin.audio.VoicesGroup;
 import funkin.data.notestyle.NoteStyleRegistry;
 import funkin.data.song.SongData.SongCharacterData;
+import funkin.data.song.SongData.SongCharacterData;
+import funkin.data.song.SongData.SongChartData;
 import funkin.data.song.SongData.SongChartData;
 import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongEventData;
 import funkin.data.song.SongData.SongMetadata;
+import funkin.data.song.SongData.SongMetadata;
+import funkin.data.song.SongData.SongNoteData;
 import funkin.data.song.SongData.SongNoteData;
 import funkin.data.song.SongData.SongOffsets;
 import funkin.data.song.SongDataUtils;
+import funkin.data.song.SongDataUtils;
 import funkin.data.song.SongRegistry;
+import funkin.data.song.SongRegistry;
+import funkin.input.Controls.Action;
 import funkin.input.Cursor;
+import funkin.input.TurboActionHandler;
+import funkin.input.TurboButtonHandler;
 import funkin.input.TurboKeyHandler;
 import funkin.modding.events.ScriptEvent;
 import funkin.play.character.BaseCharacter.CharacterType;
@@ -48,20 +61,13 @@ import funkin.play.components.HealthIcon;
 import funkin.play.notes.NoteSprite;
 import funkin.play.PlayState;
 import funkin.play.song.Song;
-import funkin.data.song.SongData.SongChartData;
-import funkin.data.song.SongRegistry;
-import funkin.data.song.SongData.SongEventData;
-import funkin.data.song.SongData.SongMetadata;
-import funkin.data.song.SongData.SongNoteData;
-import funkin.data.song.SongData.SongCharacterData;
-import funkin.data.song.SongDataUtils;
-import funkin.ui.debug.charting.commands.ChartEditorCommand;
 import funkin.play.stage.StageData;
 import funkin.save.Save;
 import funkin.ui.debug.charting.commands.AddEventsCommand;
 import funkin.ui.debug.charting.commands.AddNotesCommand;
 import funkin.ui.debug.charting.commands.ChartEditorCommand;
 import funkin.ui.debug.charting.commands.ChartEditorCommand;
+import funkin.ui.debug.charting.commands.ChartEditorCommand;
 import funkin.ui.debug.charting.commands.CutItemsCommand;
 import funkin.ui.debug.charting.commands.DeselectAllItemsCommand;
 import funkin.ui.debug.charting.commands.DeselectItemsCommand;
@@ -80,17 +86,20 @@ import funkin.ui.debug.charting.commands.SelectItemsCommand;
 import funkin.ui.debug.charting.commands.SetItemSelectionCommand;
 import funkin.ui.debug.charting.components.ChartEditorEventSprite;
 import funkin.ui.debug.charting.components.ChartEditorHoldNoteSprite;
+import funkin.ui.debug.charting.components.ChartEditorMeasureTicks;
 import funkin.ui.debug.charting.components.ChartEditorNotePreview;
 import funkin.ui.debug.charting.components.ChartEditorNoteSprite;
 import funkin.ui.debug.charting.components.ChartEditorMeasureTicks;
 import funkin.ui.debug.charting.components.ChartEditorPlaybarHead;
 import funkin.ui.debug.charting.components.ChartEditorSelectionSquareSprite;
 import funkin.ui.debug.charting.handlers.ChartEditorShortcutHandler;
+import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
 import funkin.ui.haxeui.components.CharacterPlayer;
 import funkin.ui.haxeui.HaxeUIState;
 import funkin.ui.mainmenu.MainMenuState;
 import funkin.util.Constants;
 import funkin.util.FileUtil;
+import funkin.util.logging.CrashHandler;
 import funkin.util.SortUtil;
 import funkin.util.WindowUtil;
 import haxe.DynamicAccess;
@@ -98,22 +107,25 @@ import haxe.io.Bytes;
 import haxe.io.Path;
 import haxe.ui.backend.flixel.UIRuntimeState;
 import haxe.ui.backend.flixel.UIState;
-import haxe.ui.components.DropDown;
-import haxe.ui.components.Label;
 import haxe.ui.components.Button;
+import haxe.ui.components.DropDown;
+import haxe.ui.components.Image;
+import haxe.ui.components.Label;
 import haxe.ui.components.NumberStepper;
 import haxe.ui.components.Slider;
 import haxe.ui.components.TextField;
 import haxe.ui.containers.dialogs.CollapsibleDialog;
 import haxe.ui.containers.Frame;
+import haxe.ui.containers.Grid;
+import haxe.ui.containers.HBox;
 import haxe.ui.containers.menus.Menu;
 import haxe.ui.containers.menus.MenuBar;
-import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.containers.menus.MenuBar;
 import haxe.ui.containers.menus.MenuCheckBox;
+import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.containers.ScrollView;
 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;
@@ -122,12 +134,6 @@ import haxe.ui.events.UIEvent;
 import haxe.ui.events.UIEvent;
 import haxe.ui.focus.FocusManager;
 import openfl.display.BitmapData;
-import funkin.audio.visualize.PolygonSpectogram;
-import flixel.group.FlxGroup.FlxTypedGroup;
-import funkin.audio.visualize.PolygonVisGroup;
-import flixel.input.mouse.FlxMouseEvent;
-import flixel.text.FlxText;
-import flixel.system.debug.log.LogStyle;
 
 using Lambda;
 
@@ -360,6 +366,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       }
     }
 
+    updatePlayheadGhostHoldNotes();
+
     // Move the rendered notes to the correct position.
     renderedNotes.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
     renderedHoldNotes.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
@@ -429,6 +437,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     // Move the playhead sprite to the correct position.
     gridPlayhead.y = this.playheadPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
 
+    updatePlayheadGhostHoldNotes();
+
     return this.playheadPositionInPixels;
   }
 
@@ -720,6 +730,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var currentPlaceNoteData:Null<SongNoteData> = null;
 
+  /**
+   * The SongNoteData which is currently being placed, for each column.
+   * `null` if the user isn't currently placing a note.
+   * As the user moves down, we will update this note's sustain length, and finalize the note when they release.
+   */
+  var currentLiveInputPlaceNoteData:Array<SongNoteData> = [];
+
   // Note Movement
 
   /**
@@ -750,6 +767,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var dragLengthCurrent:Float = 0;
 
+  /**
+   * The current length of the hold note we are placing with the playhead, in steps.
+   * Play a sound when this value changes.
+   */
+  var playheadDragLengthCurrent:Array<Float> = [];
+
   /**
    * Flip-flop to alternate between two stretching sounds.
    */
@@ -1020,6 +1043,66 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var pageDownKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.PAGEDOWN);
 
+  /**
+   * Variable used to track how long the user has been holding up on the dpad.
+   */
+  var dpadUpGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.DPAD_UP);
+
+  /**
+   * Variable used to track how long the user has been holding down on the dpad.
+   */
+  var dpadDownGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.DPAD_DOWN);
+
+  /**
+   * Variable used to track how long the user has been holding left on the dpad.
+   */
+  var dpadLeftGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.DPAD_LEFT);
+
+  /**
+   * Variable used to track how long the user has been holding right on the dpad.
+   */
+  var dpadRightGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.DPAD_RIGHT);
+
+  /**
+   * Variable used to track how long the user has been holding up on the left stick.
+   */
+  var leftStickUpGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.LEFT_STICK_DIGITAL_UP);
+
+  /**
+   * Variable used to track how long the user has been holding down on the left stick.
+   */
+  var leftStickDownGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.LEFT_STICK_DIGITAL_DOWN);
+
+  /**
+   * Variable used to track how long the user has been holding left on the left stick.
+   */
+  var leftStickLeftGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.LEFT_STICK_DIGITAL_LEFT);
+
+  /**
+   * Variable used to track how long the user has been holding right on the left stick.
+   */
+  var leftStickRightGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.LEFT_STICK_DIGITAL_RIGHT);
+
+  /**
+   * Variable used to track how long the user has been holding up on the right stick.
+   */
+  var rightStickUpGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.RIGHT_STICK_DIGITAL_UP);
+
+  /**
+   * Variable used to track how long the user has been holding down on the right stick.
+   */
+  var rightStickDownGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.RIGHT_STICK_DIGITAL_DOWN);
+
+  /**
+   * Variable used to track how long the user has been holding left on the right stick.
+   */
+  var rightStickLeftGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.RIGHT_STICK_DIGITAL_LEFT);
+
+  /**
+   * Variable used to track how long the user has been holding right on the right stick.
+   */
+  var rightStickRightGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.RIGHT_STICK_DIGITAL_RIGHT);
+
   /**
    * AUDIO AND SOUND DATA
    */
@@ -1733,10 +1816,15 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   var gridGhostNote:Null<ChartEditorNoteSprite> = null;
 
   /**
-   * A sprite used to indicate the note that will be placed on click.
+   * A sprite used to indicate the hold note that will be placed on click.
    */
   var gridGhostHoldNote:Null<ChartEditorHoldNoteSprite> = null;
 
+  /**
+   * A sprite used to indicate the hold note that will be placed on button release.
+   */
+  var gridPlayheadGhostHoldNotes:Array<ChartEditorHoldNoteSprite> = [];
+
   /**
    * A sprite used to indicate the event that will be placed on click.
    */
@@ -2126,11 +2214,23 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
     gridGhostHoldNote = new ChartEditorHoldNoteSprite(this);
     gridGhostHoldNote.alpha = 0.6;
-    gridGhostHoldNote.noteData = new SongNoteData(0, 0, 0, "");
+    gridGhostHoldNote.noteData = null;
     gridGhostHoldNote.visible = false;
     add(gridGhostHoldNote);
     gridGhostHoldNote.zIndex = 11;
 
+    while (gridPlayheadGhostHoldNotes.length < (STRUMLINE_SIZE * 2))
+    {
+      var ghost = new ChartEditorHoldNoteSprite(this);
+      ghost.alpha = 0.6;
+      ghost.noteData = null;
+      ghost.visible = false;
+      add(ghost);
+      ghost.zIndex = 11;
+
+      gridPlayheadGhostHoldNotes.push(ghost);
+    }
+
     gridGhostEvent = new ChartEditorEventSprite(this);
     gridGhostEvent.alpha = 0.6;
     gridGhostEvent.eventData = new SongEventData(-1, '', {});
@@ -2702,6 +2802,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   function setupTurboKeyHandlers():Void
   {
+    // Keyboard shortcuts
     add(undoKeyHandler);
     add(redoKeyHandler);
     add(upKeyHandler);
@@ -2710,6 +2811,20 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     add(sKeyHandler);
     add(pageUpKeyHandler);
     add(pageDownKeyHandler);
+
+    // Gamepad inputs
+    add(dpadUpGamepadHandler);
+    add(dpadDownGamepadHandler);
+    add(dpadLeftGamepadHandler);
+    add(dpadRightGamepadHandler);
+    add(leftStickUpGamepadHandler);
+    add(leftStickDownGamepadHandler);
+    add(leftStickLeftGamepadHandler);
+    add(leftStickRightGamepadHandler);
+    add(rightStickUpGamepadHandler);
+    add(rightStickDownGamepadHandler);
+    add(rightStickLeftGamepadHandler);
+    add(rightStickRightGamepadHandler);
   }
 
   /**
@@ -2865,6 +2980,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     handleTestKeybinds();
     handleHelpKeybinds();
 
+    this.handleGamepadControls();
+
     #if debug
     handleQuickWatch();
     #end
@@ -3370,32 +3487,56 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     // Up Arrow = Scroll Up
     if (upKeyHandler.activated && currentLiveInputStyle == None)
     {
-      scrollAmount = -GRID_SIZE * 0.25 * 25.0;
+      scrollAmount = -GRID_SIZE * 4;
       shouldPause = true;
     }
     // Down Arrow = Scroll Down
     if (downKeyHandler.activated && currentLiveInputStyle == None)
     {
-      scrollAmount = GRID_SIZE * 0.25 * 25.0;
+      scrollAmount = GRID_SIZE * 4;
       shouldPause = true;
     }
 
     // W = Scroll Up (doesn't work with Ctrl+Scroll)
     if (wKeyHandler.activated && currentLiveInputStyle == None && !FlxG.keys.pressed.CONTROL)
     {
-      scrollAmount = -GRID_SIZE * 0.25 * 25.0;
+      scrollAmount = -GRID_SIZE * 4;
       shouldPause = true;
     }
     // S = Scroll Down (doesn't work with Ctrl+Scroll)
     if (sKeyHandler.activated && currentLiveInputStyle == None && !FlxG.keys.pressed.CONTROL)
     {
-      scrollAmount = GRID_SIZE * 0.25 * 25.0;
+      scrollAmount = GRID_SIZE * 4;
       shouldPause = true;
     }
 
-    // PAGE UP = Jump up to nearest measure
-    if (pageUpKeyHandler.activated)
+    // GAMEPAD LEFT STICK UP = Scroll Up by 1 note snap
+    if (leftStickUpGamepadHandler.activated)
     {
+      scrollAmount = -GRID_SIZE * noteSnapRatio;
+      shouldPause = true;
+    }
+    // GAMEPAD LEFT STICK DOWN = Scroll Down by 1 note snap
+    if (leftStickDownGamepadHandler.activated)
+    {
+      scrollAmount = GRID_SIZE * noteSnapRatio;
+      shouldPause = true;
+    }
+
+    // GAMEPAD RIGHT STICK UP = Scroll Up by 1 note snap (playhead only)
+    if (rightStickUpGamepadHandler.activated)
+    {
+      playheadAmount = -GRID_SIZE * noteSnapRatio;
+      shouldPause = true;
+    }
+    // GAMEPAD RIGHT STICK DOWN = Scroll Down by 1 note snap (playhead only)
+    if (rightStickDownGamepadHandler.activated)
+    {
+      playheadAmount = GRID_SIZE * noteSnapRatio;
+      shouldPause = true;
+    }
+
+    var funcJumpUp = (playheadOnly:Bool) -> {
       var measureHeight:Float = GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure;
       var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels;
       var targetScrollPosition:Float = Math.floor(playheadPos / measureHeight) * measureHeight;
@@ -3405,20 +3546,37 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       {
         targetScrollPosition -= GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.instance.beatsPerMeasure;
       }
-      scrollAmount = targetScrollPosition - playheadPos;
 
+      if (playheadOnly)
+      {
+        playheadAmount = targetScrollPosition - playheadPos;
+      }
+      else
+      {
+        scrollAmount = targetScrollPosition - playheadPos;
+      }
+    }
+
+    // PAGE UP = Jump up to nearest measure
+    // GAMEPAD LEFT STICK LEFT = Jump up to nearest measure
+    if (pageUpKeyHandler.activated || leftStickLeftGamepadHandler.activated)
+    {
+      funcJumpUp(false);
+      shouldPause = true;
+    }
+    if (rightStickLeftGamepadHandler.activated)
+    {
+      funcJumpUp(true);
       shouldPause = true;
     }
     if (playbarButtonPressed == 'playbarBack')
     {
       playbarButtonPressed = '';
-      scrollAmount = -GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure;
+      funcJumpUp(false);
       shouldPause = true;
     }
 
-    // PAGE DOWN = Jump down to nearest measure
-    if (pageDownKeyHandler.activated)
-    {
+    var funcJumpDown = (playheadOnly:Bool) -> {
       var measureHeight:Float = GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure;
       var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels;
       var targetScrollPosition:Float = Math.ceil(playheadPos / measureHeight) * measureHeight;
@@ -3428,26 +3586,46 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       {
         targetScrollPosition += GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.instance.beatsPerMeasure;
       }
-      scrollAmount = targetScrollPosition - playheadPos;
 
+      if (playheadOnly)
+      {
+        playheadAmount = targetScrollPosition - playheadPos;
+      }
+      else
+      {
+        scrollAmount = targetScrollPosition - playheadPos;
+      }
+    }
+
+    // PAGE DOWN = Jump down to nearest measure
+    // GAMEPAD LEFT STICK RIGHT = Jump down to nearest measure
+    if (pageDownKeyHandler.activated || leftStickRightGamepadHandler.activated)
+    {
+      funcJumpDown(false);
+      shouldPause = true;
+    }
+    if (rightStickRightGamepadHandler.activated)
+    {
+      funcJumpDown(true);
       shouldPause = true;
     }
     if (playbarButtonPressed == 'playbarForward')
     {
       playbarButtonPressed = '';
-      scrollAmount = GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure;
+      funcJumpDown(false);
       shouldPause = true;
     }
 
     // SHIFT + Scroll = Scroll Fast
-    if (FlxG.keys.pressed.SHIFT)
+    // GAMEPAD LEFT STICK CLICK + Scroll = Scroll Fast
+    if (FlxG.keys.pressed.SHIFT || (FlxG.gamepads.firstActive?.pressed?.LEFT_STICK_CLICK ?? false))
     {
       scrollAmount *= 2;
     }
     // CONTROL + Scroll = Scroll Precise
     if (FlxG.keys.pressed.CONTROL)
     {
-      scrollAmount /= 10;
+      scrollAmount /= 4;
     }
 
     // Alt + Drag = Scroll but move the playhead the same amount.
@@ -4520,37 +4698,77 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
   function handlePlayhead():Void
   {
-    // Place notes at the playhead.
+    // Place notes at the playhead with the keyboard.
     switch (currentLiveInputStyle)
     {
       case ChartEditorLiveInputStyle.WASD:
         if (FlxG.keys.justPressed.A) placeNoteAtPlayhead(4);
+        if (FlxG.keys.justReleased.A) finishPlaceNoteAtPlayhead(4);
         if (FlxG.keys.justPressed.S) placeNoteAtPlayhead(5);
+        if (FlxG.keys.justReleased.S) finishPlaceNoteAtPlayhead(5);
         if (FlxG.keys.justPressed.W) placeNoteAtPlayhead(6);
+        if (FlxG.keys.justReleased.W) finishPlaceNoteAtPlayhead(6);
         if (FlxG.keys.justPressed.D) placeNoteAtPlayhead(7);
+        if (FlxG.keys.justReleased.D) finishPlaceNoteAtPlayhead(7);
 
         if (FlxG.keys.justPressed.LEFT) placeNoteAtPlayhead(0);
+        if (FlxG.keys.justReleased.LEFT) finishPlaceNoteAtPlayhead(0);
         if (FlxG.keys.justPressed.DOWN) placeNoteAtPlayhead(1);
+        if (FlxG.keys.justReleased.DOWN) finishPlaceNoteAtPlayhead(1);
         if (FlxG.keys.justPressed.UP) placeNoteAtPlayhead(2);
+        if (FlxG.keys.justReleased.UP) finishPlaceNoteAtPlayhead(2);
         if (FlxG.keys.justPressed.RIGHT) placeNoteAtPlayhead(3);
+        if (FlxG.keys.justReleased.RIGHT) finishPlaceNoteAtPlayhead(3);
       case ChartEditorLiveInputStyle.NumberKeys:
         // Flipped because Dad is on the left but represents data 0-3.
         if (FlxG.keys.justPressed.ONE) placeNoteAtPlayhead(4);
+        if (FlxG.keys.justReleased.ONE) finishPlaceNoteAtPlayhead(4);
         if (FlxG.keys.justPressed.TWO) placeNoteAtPlayhead(5);
+        if (FlxG.keys.justReleased.TWO) finishPlaceNoteAtPlayhead(5);
         if (FlxG.keys.justPressed.THREE) placeNoteAtPlayhead(6);
+        if (FlxG.keys.justReleased.THREE) finishPlaceNoteAtPlayhead(6);
         if (FlxG.keys.justPressed.FOUR) placeNoteAtPlayhead(7);
+        if (FlxG.keys.justReleased.FOUR) finishPlaceNoteAtPlayhead(7);
 
         if (FlxG.keys.justPressed.FIVE) placeNoteAtPlayhead(0);
+        if (FlxG.keys.justReleased.FIVE) finishPlaceNoteAtPlayhead(0);
         if (FlxG.keys.justPressed.SIX) placeNoteAtPlayhead(1);
         if (FlxG.keys.justPressed.SEVEN) placeNoteAtPlayhead(2);
+        if (FlxG.keys.justReleased.SEVEN) finishPlaceNoteAtPlayhead(2);
         if (FlxG.keys.justPressed.EIGHT) placeNoteAtPlayhead(3);
+        if (FlxG.keys.justReleased.EIGHT) finishPlaceNoteAtPlayhead(3);
       case ChartEditorLiveInputStyle.None:
         // Do nothing.
     }
+
+    // Place notes at the playhead with the gamepad.
+    if (FlxG.gamepads.firstActive != null)
+    {
+      if (FlxG.gamepads.firstActive.justPressed.DPAD_LEFT) placeNoteAtPlayhead(4);
+      if (FlxG.gamepads.firstActive.justReleased.DPAD_LEFT) finishPlaceNoteAtPlayhead(4);
+      if (FlxG.gamepads.firstActive.justPressed.DPAD_DOWN) placeNoteAtPlayhead(5);
+      if (FlxG.gamepads.firstActive.justReleased.DPAD_DOWN) finishPlaceNoteAtPlayhead(5);
+      if (FlxG.gamepads.firstActive.justPressed.DPAD_UP) placeNoteAtPlayhead(6);
+      if (FlxG.gamepads.firstActive.justReleased.DPAD_UP) finishPlaceNoteAtPlayhead(6);
+      if (FlxG.gamepads.firstActive.justPressed.DPAD_RIGHT) placeNoteAtPlayhead(7);
+      if (FlxG.gamepads.firstActive.justReleased.DPAD_RIGHT) finishPlaceNoteAtPlayhead(7);
+
+      if (FlxG.gamepads.firstActive.justPressed.X) placeNoteAtPlayhead(0);
+      if (FlxG.gamepads.firstActive.justReleased.X) finishPlaceNoteAtPlayhead(0);
+      if (FlxG.gamepads.firstActive.justPressed.A) placeNoteAtPlayhead(1);
+      if (FlxG.gamepads.firstActive.justReleased.A) finishPlaceNoteAtPlayhead(1);
+      if (FlxG.gamepads.firstActive.justPressed.Y) placeNoteAtPlayhead(2);
+      if (FlxG.gamepads.firstActive.justReleased.Y) finishPlaceNoteAtPlayhead(2);
+      if (FlxG.gamepads.firstActive.justPressed.B) placeNoteAtPlayhead(3);
+      if (FlxG.gamepads.firstActive.justReleased.B) finishPlaceNoteAtPlayhead(3);
+    }
   }
 
   function placeNoteAtPlayhead(column:Int):Void
   {
+    // SHIFT + press or LEFT_SHOULDER + press to remove notes instead of placing them.
+    var removeNoteInstead:Bool = FlxG.keys.pressed.SHIFT || (FlxG.gamepads.firstActive?.pressed?.LEFT_SHOULDER ?? false);
+
     var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels;
     var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / noteSnapRatio;
     var playheadPosStep:Int = Std.int(Math.floor(playheadPosFractionalStep));
@@ -4561,10 +4779,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       playheadPosSnappedMs + Conductor.instance.stepLengthMs * noteSnapRatio);
     notesAtPos = SongDataUtils.getNotesWithData(notesAtPos, [column]);
 
-    if (notesAtPos.length == 0)
+    if (notesAtPos.length == 0 && !removeNoteInstead)
     {
       var newNoteData:SongNoteData = new SongNoteData(playheadPosSnappedMs, column, 0, noteKindToPlace);
       performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL));
+      currentLiveInputPlaceNoteData[column] = newNoteData;
+      gridPlayheadGhostHoldNotes[column].noteData = newNoteData.clone();
+      gridPlayheadGhostHoldNotes[column].noteDirection = newNoteData.getDirection();
+    }
+    else if (removeNoteInstead)
+    {
+      trace('Removing existing note at position.');
+      performCommand(new RemoveNotesCommand(notesAtPos));
     }
     else
     {
@@ -4572,6 +4798,87 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     }
   }
 
+  function updatePlayheadGhostHoldNotes():Void
+  {
+    // Update playhead ghost hold notes.
+    for (index in 0...gridPlayheadGhostHoldNotes.length)
+    {
+      var ghostHold = gridPlayheadGhostHoldNotes[index];
+      if (ghostHold == null) continue;
+
+      if (ghostHold.noteData == null)
+      {
+        ghostHold.visible = false;
+        ghostHold.setHeightDirectly(0);
+        playheadDragLengthCurrent[index] = 0;
+        continue;
+      };
+
+      var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels;
+      var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / noteSnapRatio;
+      var playheadPosStep:Int = Std.int(Math.floor(playheadPosFractionalStep));
+      var playheadPosSnappedMs:Float = playheadPosStep * Conductor.instance.stepLengthMs * noteSnapRatio;
+
+      var newNoteLength:Float = playheadPosSnappedMs - ghostHold.noteData.time;
+      trace('newNoteLength: ${newNoteLength}');
+
+      if (newNoteLength > 0)
+      {
+        ghostHold.noteData.length = newNoteLength;
+        var targetNoteLengthSteps:Float = ghostHold.noteData.getStepLength(true);
+        var targetNoteLengthStepsInt:Int = Std.int(Math.floor(targetNoteLengthSteps));
+        var targetNoteLengthPixels:Float = targetNoteLengthSteps * GRID_SIZE;
+
+        if (playheadDragLengthCurrent[index] != targetNoteLengthStepsInt)
+        {
+          stretchySounds = !stretchySounds;
+          this.playSound(Paths.sound('chartingSounds/stretch' + (stretchySounds ? '1' : '2') + '_UI'));
+          playheadDragLengthCurrent[index] = targetNoteLengthStepsInt;
+        }
+        ghostHold.visible = true;
+        trace('newHeight: ${targetNoteLengthPixels}');
+        ghostHold.setHeightDirectly(targetNoteLengthPixels, true);
+        ghostHold.updateHoldNotePosition(renderedHoldNotes);
+      }
+      else
+      {
+        ghostHold.visible = false;
+        ghostHold.setHeightDirectly(0);
+        playheadDragLengthCurrent[index] = 0;
+      }
+    }
+  }
+
+  function finishPlaceNoteAtPlayhead(column:Int):Void
+  {
+    if (currentLiveInputPlaceNoteData[column] == null) return;
+
+    var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels;
+    var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / noteSnapRatio;
+    var playheadPosStep:Int = Std.int(Math.floor(playheadPosFractionalStep));
+    var playheadPosSnappedMs:Float = playheadPosStep * Conductor.instance.stepLengthMs * noteSnapRatio;
+
+    var newNoteLength:Float = playheadPosSnappedMs - currentLiveInputPlaceNoteData[column].time;
+    trace('finishPlace newNoteLength: ${newNoteLength}');
+
+    if (newNoteLength < Conductor.instance.stepLengthMs)
+    {
+      // Don't extend the note if it's too short.
+      trace('Not extending note.');
+      currentLiveInputPlaceNoteData[column] = null;
+      gridPlayheadGhostHoldNotes[column].noteData = null;
+    }
+    else
+    {
+      // Extend the note to the playhead position.
+      trace('Extending note.');
+      this.playSound(Paths.sound('chartingSounds/stretchSNAP_UI'));
+      performCommand(new ExtendNoteLengthCommand(currentLiveInputPlaceNoteData[column], newNoteLength));
+      currentLiveInputPlaceNoteData[column] = null;
+      gridPlayheadGhostHoldNotes[column].noteData = null;
+    }
+  }
+
   /**
    * Handle aligning the health icons next to the grid.
    */
diff --git a/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx
index 7e620c210..102257fc8 100644
--- a/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx
@@ -20,6 +20,8 @@ class RemoveEventsCommand implements ChartEditorCommand
 
   public function execute(state:ChartEditorState):Void
   {
+    if (events.length == 0) return;
+
     state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
     state.currentEventSelection = [];
 
@@ -34,6 +36,8 @@ class RemoveEventsCommand implements ChartEditorCommand
 
   public function undo(state:ChartEditorState):Void
   {
+    if (events.length == 0) return;
+
     for (event in events)
     {
       state.currentSongChartEventData.push(event);
diff --git a/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx
index 77184209e..376908726 100644
--- a/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx
@@ -23,6 +23,8 @@ class RemoveItemsCommand implements ChartEditorCommand
 
   public function execute(state:ChartEditorState):Void
   {
+    if ((notes.length + events.length) == 0) return;
+
     state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
     state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
 
@@ -40,6 +42,8 @@ class RemoveItemsCommand implements ChartEditorCommand
 
   public function undo(state:ChartEditorState):Void
   {
+    if ((notes.length + events.length) == 0) return;
+
     for (note in notes)
     {
       state.currentSongChartNoteData.push(note);
diff --git a/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx
index e189be83e..4e7b480dd 100644
--- a/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx
@@ -20,6 +20,8 @@ class RemoveNotesCommand implements ChartEditorCommand
 
   public function execute(state:ChartEditorState):Void
   {
+    if (notes.length == 0) return;
+
     state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
     state.currentNoteSelection = [];
     state.currentEventSelection = [];
@@ -35,6 +37,8 @@ class RemoveNotesCommand implements ChartEditorCommand
 
   public function undo(state:ChartEditorState):Void
   {
+    if (notes.length == 0) return;
+
     for (note in notes)
     {
       state.currentSongChartNoteData.push(note);
diff --git a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
index e5971db08..193390341 100644
--- a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
@@ -43,11 +43,16 @@ class ChartEditorHoldNoteSprite extends SustainTrail
    * Set the height directly, to a value in pixels.
    * @param h The desired height in pixels.
    */
-  public function setHeightDirectly(h:Float, ?lerp:Bool = false)
+  public function setHeightDirectly(h:Float, lerp:Bool = false)
   {
-    if (lerp != null && lerp) sustainLength = FlxMath.lerp(sustainLength, h / (getScrollSpeed() * Constants.PIXELS_PER_MS), 0.25);
+    if (lerp)
+    {
+      sustainLength = FlxMath.lerp(sustainLength, h / (getScrollSpeed() * Constants.PIXELS_PER_MS), 0.25);
+    }
     else
+    {
       sustainLength = h / (getScrollSpeed() * Constants.PIXELS_PER_MS);
+    }
 
     fullSustainLength = sustainLength;
   }
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorGamepadHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorGamepadHandler.hx
new file mode 100644
index 000000000..896e2df68
--- /dev/null
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorGamepadHandler.hx
@@ -0,0 +1,133 @@
+package funkin.ui.debug.charting.handlers;
+
+/**
+ * Yes, we're that crazy. Gamepad support for the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class ChartEditorGamepadHandler
+{
+  public static function handleGamepadControls(chartEditorState:ChartEditorState)
+  {
+    if (FlxG.gamepads.firstActive == null) return;
+
+    if (FlxG.gamepads.firstActive.justPressed.A)
+    {
+      // trace('Gamepad: A pressed');
+    }
+    if (FlxG.gamepads.firstActive.justPressed.B)
+    {
+      // trace('Gamepad: B pressed');
+    }
+    if (FlxG.gamepads.firstActive.justPressed.X)
+    {
+      // trace('Gamepad: X pressed');
+    }
+    if (FlxG.gamepads.firstActive.justPressed.Y)
+    {
+      // trace('Gamepad: Y pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.LEFT_SHOULDER)
+    {
+      // trace('Gamepad: LEFT_SHOULDER pressed');
+    }
+    if (FlxG.gamepads.firstActive.justPressed.RIGHT_SHOULDER)
+    {
+      // trace('Gamepad: RIGHT_SHOULDER pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.LEFT_STICK_CLICK)
+    {
+      // trace('Gamepad: LEFT_STICK_CLICK pressed');
+    }
+    if (FlxG.gamepads.firstActive.justPressed.RIGHT_STICK_CLICK)
+    {
+      // trace('Gamepad: RIGHT_STICK_CLICK pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.LEFT_TRIGGER)
+    {
+      // trace('Gamepad: LEFT_TRIGGER pressed');
+    }
+    if (FlxG.gamepads.firstActive.justPressed.RIGHT_TRIGGER)
+    {
+      // trace('Gamepad: RIGHT_TRIGGER pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.START)
+    {
+      // trace('Gamepad: START pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.BACK)
+    {
+      // trace('Gamepad: BACK pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.GUIDE)
+    {
+      // trace('Gamepad: GUIDE pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.DPAD_UP)
+    {
+      // trace('Gamepad: DPAD_UP pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.DPAD_DOWN)
+    {
+      // trace('Gamepad: DPAD_DOWN pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.DPAD_LEFT)
+    {
+      // trace('Gamepad: DPAD_LEFT pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.DPAD_RIGHT)
+    {
+      // trace('Gamepad: DPAD_RIGHT pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.LEFT_STICK_DIGITAL_UP)
+    {
+      // trace('Gamepad: LEFT_STICK_DIGITAL_UP pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.LEFT_STICK_DIGITAL_DOWN)
+    {
+      // trace('Gamepad: LEFT_STICK_DIGITAL_DOWN pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.LEFT_STICK_DIGITAL_LEFT)
+    {
+      // trace('Gamepad: LEFT_STICK_DIGITAL_LEFT pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.LEFT_STICK_DIGITAL_RIGHT)
+    {
+      // trace('Gamepad: LEFT_STICK_DIGITAL_RIGHT pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.RIGHT_STICK_DIGITAL_UP)
+    {
+      // trace('Gamepad: RIGHT_STICK_DIGITAL_UP pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.RIGHT_STICK_DIGITAL_DOWN)
+    {
+      // trace('Gamepad: RIGHT_STICK_DIGITAL_DOWN pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.RIGHT_STICK_DIGITAL_LEFT)
+    {
+      // trace('Gamepad: RIGHT_STICK_DIGITAL_LEFT pressed');
+    }
+
+    if (FlxG.gamepads.firstActive.justPressed.RIGHT_STICK_DIGITAL_RIGHT)
+    {
+      // trace('Gamepad: RIGHT_STICK_DIGITAL_RIGHT pressed');
+    }
+  }
+}
diff --git a/source/funkin/ui/debug/charting/import.hx b/source/funkin/ui/debug/charting/import.hx
index b0569e3bb..2c3d59ef7 100644
--- a/source/funkin/ui/debug/charting/import.hx
+++ b/source/funkin/ui/debug/charting/import.hx
@@ -5,6 +5,7 @@ package funkin.ui.debug.charting;
 using funkin.ui.debug.charting.handlers.ChartEditorAudioHandler;
 using funkin.ui.debug.charting.handlers.ChartEditorContextMenuHandler;
 using funkin.ui.debug.charting.handlers.ChartEditorDialogHandler;
+using funkin.ui.debug.charting.handlers.ChartEditorGamepadHandler;
 using funkin.ui.debug.charting.handlers.ChartEditorImportExportHandler;
 using funkin.ui.debug.charting.handlers.ChartEditorNotificationHandler;
 using funkin.ui.debug.charting.handlers.ChartEditorShortcutHandler;