diff --git a/assets b/assets
index 837a8639b..1266cb1c0 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 837a8639bd7abe4aa8786dc3790e8d4576f04f28
+Subproject commit 1266cb1c0c5078158df52b2b36205b332ccde019
diff --git a/source/Main.hx b/source/Main.hx
index f4c5d9eb2..3ae882edd 100644
--- a/source/Main.hx
+++ b/source/Main.hx
@@ -128,6 +128,8 @@ class Main extends Sprite
     Toolkit.init();
     Toolkit.theme = 'dark'; // don't be cringe
     Toolkit.autoScale = false;
+    // Don't focus on UI elements when they first appear.
+    haxe.ui.focus.FocusManager.instance.autoFocus = false;
     funkin.input.Cursor.registerHaxeUICursors();
     haxe.ui.tooltips.ToolTipManager.defaultDelay = 200;
   }
diff --git a/source/funkin/input/Controls.hx b/source/funkin/input/Controls.hx
index 0857678d0..d76c26153 100644
--- a/source/funkin/input/Controls.hx
+++ b/source/funkin/input/Controls.hx
@@ -67,7 +67,7 @@ class Controls extends FlxActionSet
   var _volume_down = new FlxActionDigital(Action.VOLUME_DOWN);
   var _volume_mute = new FlxActionDigital(Action.VOLUME_MUTE);
 
-  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;
@@ -75,122 +75,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;
 
@@ -260,26 +280,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);
@@ -293,8 +297,16 @@ class Controls extends FlxActionSet
     add(_volume_down);
     add(_volume_mute);
 
-    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;
@@ -307,14 +319,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> {
@@ -405,36 +420,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:
@@ -1042,6 +1057,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();
@@ -1229,8 +1411,7 @@ enum Control
   DEBUG_STAGE;
 }
 
-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 dba1a7e55..4e572a26f 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -8,6 +8,7 @@ import flixel.FlxSprite;
 import flixel.FlxSubState;
 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;
@@ -40,6 +41,8 @@ import funkin.data.stage.StageData;
 import funkin.graphics.FunkinCamera;
 import funkin.graphics.FunkinSprite;
 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;
@@ -74,6 +77,7 @@ 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.ChartEditorMeasureTicks;
 import funkin.ui.debug.charting.components.ChartEditorNotePreview;
 import funkin.ui.debug.charting.components.ChartEditorNoteSprite;
 import funkin.ui.debug.charting.components.ChartEditorPlaybarHead;
@@ -401,8 +405,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     renderedSelectionSquares.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
     // Offset the selection box start position, if we are dragging.
     if (selectionBoxStartPos != null) selectionBoxStartPos.y -= diff;
-    // Update the note preview viewport box.
+
+    // Update the note preview.
     setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
+    refreshNotePreviewPlayheadPosition();
+
     // Update the measure tick display.
     if (measureTicks != null) measureTicks.y = gridTiledSprite?.y ?? 0.0;
     return this.scrollPositionInPixels;
@@ -463,6 +470,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     // Move the playhead sprite to the correct position.
     gridPlayhead.y = this.playheadPositionInPixels + GRID_INITIAL_Y_POS;
 
+    updatePlayheadGhostHoldNotes();
+    refreshNotePreviewPlayheadPosition();
+
     return this.playheadPositionInPixels;
   }
 
@@ -769,6 +779,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     return currentPlaceNoteData = value;
   }
 
+  /**
+   * 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
 
   /**
@@ -799,6 +816,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.
    */
@@ -1071,6 +1094,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
    */
@@ -1949,10 +2032,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.
    */
@@ -1970,6 +2058,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var notePreviewViewport:Null<FlxSliceSprite> = null;
 
+  /**
+   * The thin sprite used for representing the playhead on the note preview.
+   * We move this up and down to represent the current position.
+   */
+  var notePreviewPlayhead:Null<FlxSprite> = null;
+
   /**
    * The rectangular sprite used for rendering the selection box.
    * Uses a 9-slice to stretch the selection box to the correct size without warping.
@@ -2349,7 +2443,7 @@ 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;
@@ -2423,6 +2517,15 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     add(notePreviewViewport);
     notePreviewViewport.zIndex = 30;
 
+    notePreviewPlayhead = new FlxSprite().makeGraphic(2, 2, 0xFFFF0000);
+    notePreviewPlayhead.scrollFactor.set(0, 0);
+    notePreviewPlayhead.scale.set(notePreview.width / 2, 0.5); // Setting width does nothing.
+    notePreviewPlayhead.updateHitbox();
+    notePreviewPlayhead.x = notePreview.x;
+    notePreviewPlayhead.y = notePreview.y;
+    add(notePreviewPlayhead);
+    notePreviewPlayhead.zIndex = 31;
+
     setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
   }
 
@@ -2519,6 +2622,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     }
   }
 
+  function refreshNotePreviewPlayheadPosition():Void
+  {
+    if (notePreviewPlayhead == null) return;
+
+    notePreviewPlayhead.y = notePreview.y + (notePreview.height * ((scrollPositionInPixels + playheadPositionInPixels) / songLengthInPixels));
+  }
+
   /**
    * Builds the group that will hold all the notes.
    */
@@ -3015,6 +3125,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   function setupTurboKeyHandlers():Void
   {
+    // Keyboard shortcuts
     add(undoKeyHandler);
     add(redoKeyHandler);
     add(upKeyHandler);
@@ -3023,6 +3134,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);
   }
 
   /**
@@ -3709,32 +3834,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;
@@ -3744,20 +3893,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;
@@ -3767,26 +3933,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.
@@ -4380,9 +4566,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
             }
 
             gridGhostHoldNote.visible = true;
-            gridGhostHoldNote.noteData = currentPlaceNoteData;
-            gridGhostHoldNote.noteDirection = currentPlaceNoteData.getDirection();
-
+            gridGhostHoldNote.noteData = gridGhostNote.noteData;
+            gridGhostHoldNote.noteDirection = gridGhostNote.noteData.getDirection();
             gridGhostHoldNote.setHeightDirectly(dragLengthPixels, true);
 
             gridGhostHoldNote.updateHoldNotePosition(renderedHoldNotes);
@@ -4943,37 +5128,57 @@ 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.WASDKeys:
         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.
     }
+
+    updatePlayheadGhostHoldNotes();
   }
 
   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));
@@ -4984,14 +5189,136 @@ 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)
     {
+      trace('Placing note. ${column}');
       var newNoteData:SongNoteData = new SongNoteData(playheadPosSnappedMs, column, 0, noteKindToPlace);
       performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL));
+      currentLiveInputPlaceNoteData[column] = newNoteData;
+    }
+    else if (removeNoteInstead)
+    {
+      trace('Removing existing note at position. ${column}');
+      performCommand(new RemoveNotesCommand(notesAtPos));
     }
     else
     {
-      trace('Already a note there.');
+      trace('Already a note there. ${column}');
+    }
+  }
+
+  function updatePlayheadGhostHoldNotes():Void
+  {
+    // Ensure all the ghost hold notes exist.
+    while (gridPlayheadGhostHoldNotes.length < (STRUMLINE_SIZE * 2))
+    {
+      var ghost = new ChartEditorHoldNoteSprite(this);
+      ghost.alpha = 0.6;
+      ghost.noteData = null;
+      ghost.visible = false;
+      ghost.zIndex = 11;
+      add(ghost); // Don't add to `renderedHoldNotes` because then it will get killed every frame.
+
+      gridPlayheadGhostHoldNotes.push(ghost);
+      refresh();
+    }
+
+    // Update playhead ghost hold notes.
+    for (column in 0...gridPlayheadGhostHoldNotes.length)
+    {
+      var targetNoteData = currentLiveInputPlaceNoteData[column];
+      var ghostHold = gridPlayheadGhostHoldNotes[column];
+
+      if (targetNoteData == null && ghostHold.noteData != null)
+      {
+        // Remove the ghost hold note.
+        ghostHold.noteData = null;
+      }
+
+      if (targetNoteData != null && ghostHold.noteData == null)
+      {
+        // Readd the new ghost hold note.
+        ghostHold.noteData = targetNoteData.clone();
+        ghostHold.noteDirection = ghostHold.noteData.getDirection();
+        ghostHold.visible = true;
+        ghostHold.alpha = 0.6;
+        ghostHold.setHeightDirectly(0);
+        ghostHold.updateHoldNotePosition(renderedHoldNotes);
+      }
+
+      if (ghostHold.noteData == null)
+      {
+        ghostHold.visible = false;
+        ghostHold.setHeightDirectly(0);
+        playheadDragLengthCurrent[column] = 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[column] != targetNoteLengthStepsInt)
+        {
+          stretchySounds = !stretchySounds;
+          this.playSound(Paths.sound('chartingSounds/stretch' + (stretchySounds ? '1' : '2') + '_UI'));
+          playheadDragLengthCurrent[column] = targetNoteLengthStepsInt;
+        }
+        ghostHold.visible = true;
+        ghostHold.alpha = 0.6;
+        ghostHold.setHeightDirectly(targetNoteLengthPixels, true);
+        ghostHold.updateHoldNotePosition(renderedHoldNotes);
+        trace('lerpLength: ${ghostHold.fullSustainLength}');
+        trace('position: ${ghostHold.x}, ${ghostHold.y}');
+      }
+      else
+      {
+        ghostHold.visible = false;
+        ghostHold.setHeightDirectly(0);
+        playheadDragLengthCurrent[column] = 0;
+        continue;
+      }
+    }
+  }
+
+  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. ${column}');
+      currentLiveInputPlaceNoteData[column] = null;
+      gridPlayheadGhostHoldNotes[column].noteData = null;
+    }
+    else
+    {
+      // Extend the note to the playhead position.
+      trace('Extending note. ${column}');
+      this.playSound(Paths.sound('chartingSounds/stretchSNAP_UI'));
+      performCommand(new ExtendNoteLengthCommand(currentLiveInputPlaceNoteData[column], newNoteLength));
+      currentLiveInputPlaceNoteData[column] = null;
+      gridPlayheadGhostHoldNotes[column].noteData = null;
     }
   }
 
diff --git a/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx
index b4d913607..30f4280d2 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 69317aff4..1cc61f233 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 4811f831d..18ad6e04d 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 c7f7747c0..aeb6dd0e4 100644
--- a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
@@ -54,11 +54,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..70383d3fd
--- /dev/null
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorGamepadHandler.hx
@@ -0,0 +1,193 @@
+package funkin.ui.debug.charting.handlers;
+
+import haxe.ui.focus.FocusManager;
+import flixel.input.gamepad.FlxGamepad;
+import haxe.ui.actions.ActionManager;
+import haxe.ui.actions.IActionInputSource;
+import haxe.ui.actions.ActionType;
+
+/**
+ * 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) handleGamepad(chartEditorState, FlxG.gamepads.firstActive);
+  }
+
+  /**
+   * Handle context-generic binds for the gamepad.
+   * @param chartEditorState The chart editor state.
+   * @param gamepad The gamepad to handle.
+   */
+  static function handleGamepad(chartEditorState:ChartEditorState, gamepad:FlxGamepad):Void
+  {
+    if (chartEditorState.isHaxeUIFocused)
+    {
+      ChartEditorGamepadActionInputSource.instance.handleGamepad(gamepad);
+    }
+    else
+    {
+      handleGamepadLiveInputs(chartEditorState, gamepad);
+
+      if (gamepad.justPressed.RIGHT_SHOULDER)
+      {
+        trace('Gamepad: Right shoulder pressed, toggling audio playback.');
+        chartEditorState.toggleAudioPlayback();
+      }
+
+      if (gamepad.justPressed.START)
+      {
+        var minimal = gamepad.pressed.LEFT_SHOULDER;
+        chartEditorState.hideAllToolboxes();
+        trace('Gamepad: Start pressed, opening playtest (minimal: ${minimal})');
+        chartEditorState.testSongInPlayState(minimal);
+      }
+
+      if (gamepad.justPressed.BACK && !gamepad.pressed.LEFT_SHOULDER)
+      {
+        trace('Gamepad: Back pressed, focusing on HaxeUI menu.');
+        // FocusManager.instance.focus = chartEditorState.menubarMenuFile;
+      }
+      else if (gamepad.justPressed.BACK && gamepad.pressed.LEFT_SHOULDER)
+      {
+        trace('Gamepad: Back pressed, unfocusing on HaxeUI menu.');
+        FocusManager.instance.focus = null;
+      }
+    }
+
+    if (gamepad.justPressed.GUIDE)
+    {
+      trace('Gamepad: Guide pressed, quitting chart editor.');
+      chartEditorState.quitChartEditor();
+    }
+  }
+
+  static function handleGamepadLiveInputs(chartEditorState:ChartEditorState, gamepad:FlxGamepad):Void
+  {
+    // Place notes at the playhead with the gamepad.
+    // Disable when we are interacting with HaxeUI.
+    if (!(chartEditorState.isHaxeUIFocused || chartEditorState.isHaxeUIDialogOpen))
+    {
+      if (gamepad.justPressed.DPAD_LEFT) chartEditorState.placeNoteAtPlayhead(4);
+      if (gamepad.justReleased.DPAD_LEFT) chartEditorState.finishPlaceNoteAtPlayhead(4);
+      if (gamepad.justPressed.DPAD_DOWN) chartEditorState.placeNoteAtPlayhead(5);
+      if (gamepad.justReleased.DPAD_DOWN) chartEditorState.finishPlaceNoteAtPlayhead(5);
+      if (gamepad.justPressed.DPAD_UP) chartEditorState.placeNoteAtPlayhead(6);
+      if (gamepad.justReleased.DPAD_UP) chartEditorState.finishPlaceNoteAtPlayhead(6);
+      if (gamepad.justPressed.DPAD_RIGHT) chartEditorState.placeNoteAtPlayhead(7);
+      if (gamepad.justReleased.DPAD_RIGHT) chartEditorState.finishPlaceNoteAtPlayhead(7);
+
+      if (gamepad.justPressed.X) chartEditorState.placeNoteAtPlayhead(0);
+      if (gamepad.justReleased.X) chartEditorState.finishPlaceNoteAtPlayhead(0);
+      if (gamepad.justPressed.A) chartEditorState.placeNoteAtPlayhead(1);
+      if (gamepad.justReleased.A) chartEditorState.finishPlaceNoteAtPlayhead(1);
+      if (gamepad.justPressed.Y) chartEditorState.placeNoteAtPlayhead(2);
+      if (gamepad.justReleased.Y) chartEditorState.finishPlaceNoteAtPlayhead(2);
+      if (gamepad.justPressed.B) chartEditorState.placeNoteAtPlayhead(3);
+      if (gamepad.justReleased.B) chartEditorState.finishPlaceNoteAtPlayhead(3);
+    }
+  }
+}
+
+class ChartEditorGamepadActionInputSource implements IActionInputSource
+{
+  public static var instance:ChartEditorGamepadActionInputSource = new ChartEditorGamepadActionInputSource();
+
+  public function new() {}
+
+  public function start():Void {}
+
+  /**
+   * Handle HaxeUI-specific binds for the gamepad.
+   * Only called when the HaxeUI menu is focused.
+   * @param chartEditorState The chart editor state.
+   * @param gamepad The gamepad to handle.
+   */
+  public function handleGamepad(gamepad:FlxGamepad):Void
+  {
+    if (gamepad.justPressed.DPAD_LEFT)
+    {
+      trace('Gamepad: DPAD_LEFT pressed, moving left.');
+      ActionManager.instance.actionStart(ActionType.LEFT, this);
+    }
+    else if (gamepad.justReleased.DPAD_LEFT)
+    {
+      ActionManager.instance.actionEnd(ActionType.LEFT, this);
+    }
+
+    if (gamepad.justPressed.DPAD_RIGHT)
+    {
+      trace('Gamepad: DPAD_RIGHT pressed, moving right.');
+      ActionManager.instance.actionStart(ActionType.RIGHT, this);
+    }
+    else if (gamepad.justReleased.DPAD_RIGHT)
+    {
+      ActionManager.instance.actionEnd(ActionType.RIGHT, this);
+    }
+
+    if (gamepad.justPressed.DPAD_UP)
+    {
+      trace('Gamepad: DPAD_UP pressed, moving up.');
+      ActionManager.instance.actionStart(ActionType.UP, this);
+    }
+    else if (gamepad.justReleased.DPAD_UP)
+    {
+      ActionManager.instance.actionEnd(ActionType.UP, this);
+    }
+
+    if (gamepad.justPressed.DPAD_DOWN)
+    {
+      trace('Gamepad: DPAD_DOWN pressed, moving down.');
+      ActionManager.instance.actionStart(ActionType.DOWN, this);
+    }
+    else if (gamepad.justReleased.DPAD_DOWN)
+    {
+      ActionManager.instance.actionEnd(ActionType.DOWN, this);
+    }
+
+    if (gamepad.justPressed.A)
+    {
+      trace('Gamepad: A pressed, confirmingg.');
+      ActionManager.instance.actionStart(ActionType.CONFIRM, this);
+    }
+    else if (gamepad.justReleased.A)
+    {
+      ActionManager.instance.actionEnd(ActionType.CONFIRM, this);
+    }
+
+    if (gamepad.justPressed.B)
+    {
+      trace('Gamepad: B pressed, cancelling.');
+      ActionManager.instance.actionStart(ActionType.CANCEL, this);
+    }
+    else if (gamepad.justReleased.B)
+    {
+      ActionManager.instance.actionEnd(ActionType.CANCEL, this);
+    }
+
+    if (gamepad.justPressed.LEFT_TRIGGER)
+    {
+      trace('Gamepad: LEFT_TRIGGER pressed, moving to previous item.');
+      ActionManager.instance.actionStart(ActionType.PREVIOUS, this);
+    }
+    else if (gamepad.justReleased.LEFT_TRIGGER)
+    {
+      ActionManager.instance.actionEnd(ActionType.PREVIOUS, this);
+    }
+
+    if (gamepad.justPressed.RIGHT_TRIGGER)
+    {
+      trace('Gamepad: RIGHT_TRIGGER pressed, moving to next item.');
+      ActionManager.instance.actionStart(ActionType.NEXT, this);
+    }
+    else if (gamepad.justReleased.RIGHT_TRIGGER)
+    {
+      ActionManager.instance.actionEnd(ActionType.NEXT, this);
+    }
+  }
+}
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;
diff --git a/source/funkin/ui/haxeui/FlxGamepadActionInputSource.hx b/source/funkin/ui/haxeui/FlxGamepadActionInputSource.hx
new file mode 100644
index 000000000..9c2901d16
--- /dev/null
+++ b/source/funkin/ui/haxeui/FlxGamepadActionInputSource.hx
@@ -0,0 +1,53 @@
+package funkin.ui.haxeui;
+
+import flixel.FlxBasic;
+import flixel.input.gamepad.FlxGamepad;
+import haxe.ui.actions.IActionInputSource;
+
+/**
+ * Receives button presses from the Flixel gamepad and emits HaxeUI events.
+ */
+class FlxGamepadActionInputSource extends FlxBasic
+{
+  public static var instance(get, null):FlxGamepadActionInputSource;
+
+  static function get_instance():FlxGamepadActionInputSource
+  {
+    if (instance == null) instance = new FlxGamepadActionInputSource();
+    return instance;
+  }
+
+  public function new()
+  {
+    super();
+  }
+
+  public function start():Void
+  {
+    FlxG.plugins.addPlugin(this);
+  }
+
+  public override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    if (FlxG.gamepads.firstActive != null)
+    {
+      updateGamepad(elapsed, FlxG.gamepads.firstActive);
+    }
+  }
+
+  function updateGamepad(elapsed:Float, gamepad:FlxGamepad):Void
+  {
+    if (gamepad.justPressed.BACK)
+    {
+      //
+    }
+  }
+
+  public override function destroy():Void
+  {
+    super.destroy();
+    FlxG.plugins.remove(this);
+  }
+}