From 105672c132df497b718bef64d6d4c734e50e3617 Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Mon, 3 Aug 2026 16:46:48 -0300 Subject: [PATCH] fix(switch): restore pad cursor in Touch Controls and launcher overlays Opening Touch Controls dropped the launcher virtual cursor and swallowed gamepad input while touch still worked. Share PadCursor with the save editor, forward pad events, and centralize overlay handoff/resume so both hosts park and re-arm the pointer cleanly. Co-authored-by: Cursor --- main.lua | 66 +++++- scripts/test.sh | 1 + src/import/RomImporter.lua | 45 +++- src/ui/PadCursor.lua | 215 +++++++++++++++++ src/ui/TouchControlsEditor.lua | 75 +++++- tests/engine/launcher_nx_pad_cursor_test.lua | 45 +++- .../engine/touch_controls_pad_cursor_test.lua | 85 +++++++ tools/save-editor/PadInput.lua | 218 +----------------- 8 files changed, 513 insertions(+), 237 deletions(-) create mode 100644 src/ui/PadCursor.lua create mode 100644 tests/engine/touch_controls_pad_cursor_test.lua diff --git a/main.lua b/main.lua index e38895ef..b578c1d5 100644 --- a/main.lua +++ b/main.lua @@ -120,10 +120,10 @@ local function openEditor(version, slotId) require("src.import.CacheFs").mountVersion(version) editorVersion = version editorHost = Importer - -- NX: drop the launcher getPosition shim so the save editor sees the real - -- pointer / its own pad cursor. Desktop has no shim — no-op. - if Importer and Importer.parkNxPointerForHost then - Importer:parkNxPointerForHost() + -- Drop launcher pad/FlexLove so the save editor owns input (NX shim + + -- virtual cursor + system hand cursor). Desktop park is a light no-op. + if Importer and Importer.prepareOverlayHandoff then + Importer:prepareOverlayHandoff() end Importer = nil editorMode = true @@ -151,6 +151,9 @@ function closeEditor() restoreWindow() Importer = editorHost editorHost = nil + if Importer and Importer.resumeAfterOverlay then + Importer:resumeAfterOverlay() + end if Importer and version and Importer.savesChanged then Importer:savesChanged(version) end @@ -164,6 +167,9 @@ local closeTouchControlsEditor -- forward declaration local function openTouchControlsEditor() touchEditorHost = Importer + if Importer and Importer.prepareOverlayHandoff then + Importer:prepareOverlayHandoff() + end Importer = nil TouchEditor = require("src.ui.TouchControlsEditor") TouchEditor.load({ onClose = function() closeTouchControlsEditor() end }) @@ -174,6 +180,9 @@ function closeTouchControlsEditor() TouchEditor = nil Importer = touchEditorHost touchEditorHost = nil + if Importer and Importer.resumeAfterOverlay then + Importer:resumeAfterOverlay() + end end local function bootGame(version) @@ -398,7 +407,12 @@ function love.gamepadpressed(joystick, button) end return end - if TouchEditor then return end + if TouchEditor then + if TouchEditor.gamepadpressed then + return TouchEditor.gamepadpressed(joystick, button) + end + return + end if Importer then return Importer:gamepadpressed(joystick, button) end Game:gamepadpressed(joystick, button) end @@ -411,7 +425,12 @@ function love.gamepadreleased(joystick, button) end return end - if TouchEditor then return end + if TouchEditor then + if TouchEditor.gamepadreleased then + return TouchEditor.gamepadreleased(joystick, button) + end + return + end if Importer then return Importer:gamepadreleased(joystick, button) end Game:gamepadreleased(joystick, button) end @@ -424,7 +443,12 @@ function love.gamepadaxis(joystick, axis, value) end return end - if TouchEditor then return end + if TouchEditor then + if TouchEditor.gamepadaxis then + return TouchEditor.gamepadaxis(joystick, axis, value) + end + return + end if Importer then return Importer:gamepadaxis(joystick, axis, value) end Game:gamepadaxis(joystick, axis, value) end @@ -437,7 +461,12 @@ function love.joystickpressed(joystick, button) end return end - if TouchEditor then return end + if TouchEditor then + if TouchEditor.joystickpressed then + return TouchEditor.joystickpressed(joystick, button) + end + return + end if Importer then return Importer:joystickpressed(joystick, button) end Game:joystickpressed(joystick, button) end @@ -450,7 +479,12 @@ function love.joystickreleased(joystick, button) end return end - if TouchEditor then return end + if TouchEditor then + if TouchEditor.joystickreleased then + return TouchEditor.joystickreleased(joystick, button) + end + return + end if Importer then return Importer:joystickreleased(joystick, button) end Game:joystickreleased(joystick, button) end @@ -463,7 +497,12 @@ function love.joystickaxis(joystick, axis, value) end return end - if TouchEditor then return end + if TouchEditor then + if TouchEditor.joystickaxis then + return TouchEditor.joystickaxis(joystick, axis, value) + end + return + end if Importer then return Importer:joystickaxis(joystick, axis, value) end Game:joystickaxis(joystick, axis, value) end @@ -476,7 +515,12 @@ function love.joystickhat(joystick, hat, direction) end return end - if TouchEditor then return end + if TouchEditor then + if TouchEditor.joystickhat then + return TouchEditor.joystickhat(joystick, hat, direction) + end + return + end if Importer then return Importer:joystickhat(joystick, hat, direction) end Game:joystickhat(joystick, hat, direction) end diff --git a/scripts/test.sh b/scripts/test.sh index 9a2b4dd7..18bb960e 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -72,6 +72,7 @@ run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.l run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua run_tier "T0 NX generated-path static guard" "$LUA" tests/engine/nx_generated_guard_test.lua run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_boot_test.lua +run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 4a8792f1..1f5037de 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1166,13 +1166,16 @@ function RomImporter.new(onComplete, opts) end end - -- On Linux handhelds a gamepad is usually already connected at boot; arm - -- the virtual cursor immediately so the player does not have to press a - -- button before seeing something move. - if self.launcher and love.system.getOS() == "Linux" - and love.joystick and love.joystick.getJoystickCount + -- On Linux handhelds / NX a gamepad is usually already connected at boot; + -- arm the virtual cursor immediately so the player does not have to press a + -- button before seeing something move. Desktop keeps the cursor latent + -- until the first stick bump so a plugged DualSense does not steal the mouse. + if self.launcher and love.joystick and love.joystick.getJoystickCount and love.joystick.getJoystickCount() > 0 then - self:_activatePadCursor() + local osName = (love.system and love.system.getOS and love.system.getOS()) or "" + if osName == "Linux" or self.isNX then + self:_activatePadCursor() + end end return self @@ -1969,6 +1972,36 @@ function RomImporter:parkNxPointerForHost() self:_restoreNxPointerBridge() end +-- Temporary overlay handoff (Edit Save / Touch Controls): restore the system +-- arrow cursor, hide the virtual pad pointer, tear down FlexLove when the +-- view is already loaded, and drop the NX getPosition shim. Play uses +-- resetPointerCursor + detach directly because it never returns here. +function RomImporter:prepareOverlayHandoff() + resetPointerCursor(self) + self._padCursorActive = false + -- Avoid requiring LauncherView from headless unit tests (no luautf8). In + -- a real session draw() has already loaded it, so detach runs normally. + if self._flex and package.loaded["src.import.LauncherView"] then + require("src.import.LauncherView").detach(self) + else + self._flex = nil + self:parkNxPointerForHost() + end +end + +-- After an overlay closes: re-arm the pad cursor when a stick is already +-- connected so NX / handhelds are not stranded without a pointer until the +-- next stick bump (same class of bug as opening Touch Controls). +function RomImporter:resumeAfterOverlay() + if not self.launcher then return end + if not (love.joystick and love.joystick.getJoystickCount) then return end + if love.joystick.getJoystickCount() <= 0 then return end + local osName = (love.system and love.system.getOS and love.system.getOS()) or "" + if osName == "Linux" or self.isNX then + self:_activatePadCursor() + end +end + function RomImporter:_cycleTab(delta) local order = { "red", "blue", "yellow", "mods", "find" } local idx = 1 diff --git a/src/ui/PadCursor.lua b/src/ui/PadCursor.lua new file mode 100644 index 00000000..2286e23a --- /dev/null +++ b/src/ui/PadCursor.lua @@ -0,0 +1,215 @@ +-- Virtual pointer for overlay hosts (save editor, touch-controls editor) on +-- Switch / handhelds / any gamepad. Mirrors the launcher's RomImporter pad +-- cursor (speeds, deadzone, dual-path raw gate) without sharing that module. +-- +-- Stick / D-pad move; real mouse motion yields so desktop stays normal. +-- Callers map A → click, B → close, shoulders → host-specific actions, right +-- stick → wheel notches (save editor lists). + +local SafeArea = require("src.core.SafeArea") +local GamepadMap = require("src.core.GamepadMap") + +local PAD_DEAD = 0.28 +local PAD_SPEED = 560 +local PAD_DPAD_SPEED = 420 +-- Right stick → Kit wheel notches: ~2 notches/sec at full deflection so lists +-- scroll at a usable pace without flooding one frame. +local PAD_WHEEL_RATE = 2.0 + +local PadCursor = {} + +local cursor = { x = 0, y = 0 } +local active = false +local inited = false +local axis = { leftx = 0, lefty = 0, righty = 0 } +local dir = {} +local rawHatDirs = {} +local lastMouseX, lastMouseY +local wheelAcc = 0 + +local function activate() + if active then return end + local ox, oy, w, h = SafeArea.rect() + if not inited then + cursor.x = ox + w * 0.5 + cursor.y = oy + h * 0.45 + inited = true + end + active = true +end + +function PadCursor.reset() + cursor.x, cursor.y = 0, 0 + active = false + inited = false + axis.leftx, axis.lefty, axis.righty = 0, 0, 0 + for k in pairs(dir) do dir[k] = nil end + for k in pairs(rawHatDirs) do rawHatDirs[k] = nil end + lastMouseX, lastMouseY = nil, nil + wheelAcc = 0 +end + +-- Touch / mouse press: drop the virtual cursor for this interaction so a tap +-- is not swallowed by the Joy-Con pointer sitting elsewhere on screen. +function PadCursor.yieldToPointer() + active = false +end + +-- Returns mx, my, isActive. When inactive the caller should use the system +-- mouse; when active these coords feed hit-tests / draws. +function PadCursor.pointer() + return cursor.x, cursor.y, active +end + +function PadCursor.isActive() + return active +end + +-- Consume accumulated right-stick scroll as integer wheel notches (same +-- units App.wheelmoved feeds Kit). Fractional remainder stays for next frame. +function PadCursor.takeWheel() + local notches = 0 + if wheelAcc >= 1 or wheelAcc <= -1 then + notches = wheelAcc > 0 and math.floor(wheelAcc) or math.ceil(wheelAcc) + wheelAcc = wheelAcc - notches + end + return notches +end + +function PadCursor.update(dt) + if not (love and love.mouse and love.mouse.getPosition) then return end + local mx, my = love.mouse.getPosition() + if lastMouseX and active then + if math.abs(mx - lastMouseX) > 3 or math.abs(my - lastMouseY) > 3 then + active = false + end + end + lastMouseX, lastMouseY = mx, my + + local ax = axis.leftx or 0 + local ay = axis.lefty or 0 + local dx, dy = 0, 0 + if math.abs(ax) > PAD_DEAD then dx = dx + ax end + if math.abs(ay) > PAD_DEAD then dy = dy + ay end + if dir.dpleft then dx = dx - 1 end + if dir.dpright then dx = dx + 1 end + if dir.dpup then dy = dy - 1 end + if dir.dpdown then dy = dy + 1 end + + if dx ~= 0 or dy ~= 0 then + activate() + local mag = math.sqrt(dx * dx + dy * dy) + if mag > 1 then dx, dy = dx / mag, dy / mag end + local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD) + and PAD_SPEED or PAD_DPAD_SPEED + local ox, oy, w, h = SafeArea.rect() + local nx = cursor.x + dx * speed * dt + local ny = cursor.y + dy * speed * dt + cursor.x = math.max(ox, math.min(ox + w, nx)) + cursor.y = math.max(oy, math.min(oy + h, ny)) + end + + local ry = axis.righty or 0 + if math.abs(ry) > PAD_DEAD then + activate() + -- Negative righty (stick up) scrolls lists up = positive wheel notches. + wheelAcc = wheelAcc + (-ry) * PAD_WHEEL_RATE * dt + end +end + +-- Returns a string action the host handles: +-- "a" | "b" | "tab_prev" | "tab_next" | nil +function PadCursor.gamepadpressed(_, button) + activate() + local action = GamepadMap.mapGamepadButton(button) + if action == "a" or action == "b" then + return action + elseif button == "leftshoulder" then + return "tab_prev" + elseif button == "rightshoulder" then + return "tab_next" + elseif button == "dpup" or button == "dpdown" + or button == "dpleft" or button == "dpright" then + dir[button] = true + end + return nil +end + +function PadCursor.gamepadreleased(_, button) + if button == "dpup" or button == "dpdown" + or button == "dpleft" or button == "dpright" then + dir[button] = nil + end +end + +function PadCursor.gamepadaxis(_, axisName, value) + if axisName == "leftx" or axisName == "lefty" or axisName == "righty" then + axis[axisName] = value + if math.abs(value) > PAD_DEAD then activate() end + end +end + +function PadCursor.joystickpressed(joystick, button) + if GamepadMap.ignoreRawForJoystick(joystick) then return nil end + local padButton = GamepadMap.mapRawToGamepadButton(button) + if padButton then return PadCursor.gamepadpressed(joystick, padButton) end + return nil +end + +function PadCursor.joystickreleased(joystick, button) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + local padButton = GamepadMap.mapRawToGamepadButton(button) + if padButton then PadCursor.gamepadreleased(joystick, padButton) end +end + +function PadCursor.joystickaxis(joystick, axisIndex, value) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + if axisIndex == 1 then + PadCursor.gamepadaxis(joystick, "leftx", value) + elseif axisIndex == 2 then + PadCursor.gamepadaxis(joystick, "lefty", value) + end +end + +function PadCursor.joystickhat(joystick, hat, direction) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + for _, d in ipairs(rawHatDirs[hat] or {}) do + dir[d] = nil + end + local dirs = ({ + u = { "dpup" }, d = { "dpdown" }, l = { "dpleft" }, r = { "dpright" }, + lu = { "dpleft", "dpup" }, ru = { "dpright", "dpup" }, + ld = { "dpleft", "dpdown" }, rd = { "dpright", "dpdown" }, + })[direction] or {} + for _, d in ipairs(dirs) do dir[d] = true end + rawHatDirs[hat] = dirs + if #dirs > 0 then activate() end +end + +function PadCursor.draw() + if not active then return end + if not (love and love.graphics) then return end + local x, y = cursor.x, cursor.y + love.graphics.push("all") + if love.graphics.origin then love.graphics.origin() end + if love.graphics.setLineWidth then love.graphics.setLineWidth(1) end + love.graphics.setColor(0, 0, 0, 0.45) + if love.graphics.polygon then + love.graphics.polygon("fill", + x + 2, y + 2, x + 2, y + 22, x + 8, y + 16, x + 14, y + 26, + x + 18, y + 24, x + 11, y + 14, x + 20, y + 14) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.polygon("fill", + x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, + x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) + love.graphics.setColor(0.05, 0.07, 0.12, 1) + love.graphics.polygon("line", + x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, + x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) + else + love.graphics.rectangle("fill", x, y, 12, 18) + end + love.graphics.pop() +end + +return PadCursor diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua index 6d088189..909a172e 100644 --- a/src/ui/TouchControlsEditor.lua +++ b/src/ui/TouchControlsEditor.lua @@ -11,9 +11,15 @@ -- -- Draws in full window LOVE units -- the same space TouchControls uses -- after Renderer:endFrame -- so what you drag here is what you get in play. +-- +-- Switch / gamepad: PadCursor draws the virtual pointer the launcher just +-- dropped (same soft-lock class as the save editor). A clicks / drags, B +-- closes (Done), shoulders nudge button size. local SaveData = require("src.core.SaveData") local TouchControls = require("src.core.TouchControls") +local PadCursor = require("src.ui.PadCursor") +local GamepadMap = require("src.core.GamepadMap") local Editor = {} @@ -56,11 +62,13 @@ function Editor.load(opts) TouchControls:applyOptions(optsTbl) TouchControls:setPreview(true) Editor.enabled = TouchControls.enabled ~= false + PadCursor.reset() end function Editor.unload() TouchControls:setPreview(false) TouchControls:reset() + PadCursor.reset() Editor.drag = nil Editor.onClose = nil end @@ -94,13 +102,16 @@ local function toggleEnabled() if not Editor.enabled then TouchControls:reset() end end -function Editor.update(_dt) - -- drag follows the live pointer when love.touch / mouse is available; +function Editor.update(dt) + PadCursor.update(dt or 0) + -- drag follows the live pointer when love.touch / mouse / pad is available; -- touchmoved / mousemoved also update, so this is a belt-and-suspenders -- path for Android where move events can be thin if not Editor.drag then return end local x, y - if love.touch and love.touch.getPosition and Editor.drag.touchId then + if Editor.drag.touchId == "pad" then + x, y = PadCursor.pointer() + elseif love.touch and love.touch.getPosition and Editor.drag.touchId then local ok, tx, ty = pcall(love.touch.getPosition, Editor.drag.touchId) if ok and tx then x, y = tx, ty end end @@ -244,6 +255,9 @@ function Editor.draw() love.graphics.circle("line", zone.cx, zone.cy, zone.w * 0.62) end end + + -- pad / Joy-Con virtual cursor (after chrome so it sits on top) + PadCursor.draw() end local function beginDrag(id, x, y) @@ -285,6 +299,9 @@ end function Editor.mousepressed(x, y, button) if button ~= 1 then return end + -- Finger / mouse tap yields the Joy-Con pointer so the click lands where + -- the event said (same NX soft-miss fix as the save editor). + PadCursor.yieldToPointer() beginDrag("mouse", x, y) end @@ -298,6 +315,7 @@ function Editor.mousereleased(x, y, button) end function Editor.touchpressed(id, x, y) + PadCursor.yieldToPointer() beginDrag(id, x, y) end @@ -309,6 +327,57 @@ function Editor.touchreleased(id, x, y) endDrag(id) end +local function handlePadAction(action) + if not action then return end + if action == "a" then + local mx, my = PadCursor.pointer() + beginDrag("pad", mx, my) + elseif action == "b" then + close() + elseif action == "tab_prev" then + TouchControls:nudgeScale(-TouchControls.SCALE_STEP) + elseif action == "tab_next" then + TouchControls:nudgeScale(TouchControls.SCALE_STEP) + end +end + +function Editor.gamepadpressed(joystick, button) + handlePadAction(PadCursor.gamepadpressed(joystick, button)) +end + +function Editor.gamepadreleased(joystick, button) + PadCursor.gamepadreleased(joystick, button) + -- A release ends a pad drag (hold A + stick to reposition a control). + local action = GamepadMap.mapGamepadButton(button) + if action == "a" then endDrag("pad") end +end + +function Editor.gamepadaxis(joystick, axis, value) + PadCursor.gamepadaxis(joystick, axis, value) +end + +function Editor.joystickpressed(joystick, button) + handlePadAction(PadCursor.joystickpressed(joystick, button)) +end + +function Editor.joystickreleased(joystick, button) + PadCursor.joystickreleased(joystick, button) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + local padButton = GamepadMap.mapRawToGamepadButton(button) + if padButton then + local action = GamepadMap.mapGamepadButton(padButton) + if action == "a" then endDrag("pad") end + end +end + +function Editor.joystickaxis(joystick, axis, value) + PadCursor.joystickaxis(joystick, axis, value) +end + +function Editor.joystickhat(joystick, hat, direction) + PadCursor.joystickhat(joystick, hat, direction) +end + function Editor.keypressed(key) if key == "escape" or key == "return" or key == "space" then close() diff --git a/tests/engine/launcher_nx_pad_cursor_test.lua b/tests/engine/launcher_nx_pad_cursor_test.lua index 7e6ecef1..8e27bffd 100644 --- a/tests/engine/launcher_nx_pad_cursor_test.lua +++ b/tests/engine/launcher_nx_pad_cursor_test.lua @@ -156,6 +156,41 @@ do check(desk._padCursorActive, "desktop parkNxPointerForHost is a no-op") end +-- ------- Overlay handoff / resume (Edit Save + Touch Controls) + +do + mouseX, mouseY = 9, 10 + local imp = freshImporter(true) + imp.launcher = true + stickRight(imp, 3) + check(imp._padCursorActive, "pad active before overlay handoff") + check(imp._nxPointerBridge, "bridge on before overlay handoff") + imp:prepareOverlayHandoff() + check(not imp._padCursorActive, "prepareOverlayHandoff clears pad") + check(not imp._nxPointerBridge, "prepareOverlayHandoff clears NX bridge") + check(imp._flex == nil, "prepareOverlayHandoff clears flex without FlexLove") + local gx, gy = love.mouse.getPosition() + eq(gx, 9, "after overlay handoff getPosition is real mouse X") + eq(gy, 10, "after overlay handoff getPosition is real mouse Y") + + local prevCount = love.joystick and love.joystick.getJoystickCount + love.joystick = love.joystick or {} + love.joystick.getJoystickCount = function() return 1 end + imp:resumeAfterOverlay() + check(imp._padCursorActive, "NX resumeAfterOverlay re-arms pad with a stick") + love.joystick.getJoystickCount = function() return 0 end + imp._padCursorActive = false + imp:resumeAfterOverlay() + check(not imp._padCursorActive, "resumeAfterOverlay stays latent with no stick") + love.joystick.getJoystickCount = prevCount + + local desk = freshImporter(false) + desk.launcher = true + desk._padCursorActive = true + desk:prepareOverlayHandoff() + check(not desk._padCursorActive, "desktop prepareOverlayHandoff clears pad too") +end + -- ------- Desktop: setPosition still warps (unchanged path) do @@ -228,6 +263,10 @@ do "RomImporter owns NX getPosition bridge") check(impSrc:find("function RomImporter:parkNxPointerForHost", 1, true) ~= nil, "RomImporter exports parkNxPointerForHost") + check(impSrc:find("function RomImporter:prepareOverlayHandoff", 1, true) ~= nil, + "RomImporter exports prepareOverlayHandoff") + check(impSrc:find("function RomImporter:resumeAfterOverlay", 1, true) ~= nil, + "RomImporter exports resumeAfterOverlay") check(impSrc:find("if not self.isNX then", 1, true) ~= nil, "NX skips desktop mouse-yield path") check(impSrc:find("if not self.isNX and love.mouse.setPosition", 1, true) ~= nil, @@ -236,8 +275,10 @@ do "NX clamps pad cursor dt") local mainSrc = read("main.lua") - check(mainSrc:find("parkNxPointerForHost", 1, true) ~= nil, - "openEditor parks NX pointer before save editor") + check(mainSrc:find("prepareOverlayHandoff", 1, true) ~= nil, + "openEditor / Touch Controls use prepareOverlayHandoff") + check(mainSrc:find("resumeAfterOverlay", 1, true) ~= nil, + "close paths resume the launcher pad cursor") check(view:find("math.floor(x + 0.5)", 1, true) ~= nil, "NX pad cursor draw is pixel-snapped") diff --git a/tests/engine/touch_controls_pad_cursor_test.lua b/tests/engine/touch_controls_pad_cursor_test.lua new file mode 100644 index 00000000..ba4c3928 --- /dev/null +++ b/tests/engine/touch_controls_pad_cursor_test.lua @@ -0,0 +1,85 @@ +-- Touch-controls editor pad / Joy-Con cursor (same class as save-editor soft-lock). +-- Opening Touch Controls parked the launcher cursor and dropped all gamepad +-- input; touch still worked. PadCursor + main.lua forwarding restore the +-- virtual pointer. +-- luajit tests/engine/touch_controls_pad_cursor_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local PadCursor = require("src.ui.PadCursor") +local GamepadMap = require("src.core.GamepadMap") + +PadCursor.reset() +PadCursor.gamepadaxis(nil, "leftx", 1) +PadCursor.update(0.05) +check(PadCursor.isActive(), "left stick activates pad cursor") +local x0 = select(1, PadCursor.pointer()) +PadCursor.update(0.05) +check(select(1, PadCursor.pointer()) > x0, "left stick moves cursor right") + +eq(PadCursor.gamepadpressed(nil, "a"), "a", "gamepad a → click") +eq(PadCursor.gamepadpressed(nil, "b"), "b", "gamepad b → close") +eq(PadCursor.gamepadpressed(nil, "leftshoulder"), "tab_prev", "L → size down") +eq(PadCursor.gamepadpressed(nil, "rightshoulder"), "tab_next", "R → size up") + +GamepadMap._setForceNXForTests(true) +eq(PadCursor.gamepadpressed(nil, "a"), "b", "NX SDL a (south) → close (GB b)") +eq(PadCursor.gamepadpressed(nil, "b"), "a", "NX SDL b (east) → click (GB a)") +GamepadMap._setForceNXForTests(false) + +PadCursor.yieldToPointer() +check(not PadCursor.isActive(), "yieldToPointer drops the virtual cursor") + +-- Compat: tools/save-editor/PadInput.lua re-exports the shared module. +package.path = package.path .. ";./tools/save-editor/?.lua" +local PadInput = require("PadInput") +eq(PadInput, PadCursor, "PadInput shim is PadCursor") + +local function read(path) + local f = assert(io.open(path, "r")) + local src = f:read("*a") + f:close() + return src +end + +local mainSrc = read("main.lua") +check(mainSrc:find("prepareOverlayHandoff", 1, true) ~= nil + and mainSrc:find("openTouchControlsEditor", 1, true) ~= nil, + "main.lua mentions prepareOverlayHandoff + touch editor") +-- prepare must run inside openTouchControlsEditor, not only openEditor +local touchOpen = mainSrc:match("local function openTouchControlsEditor%(%)(.-)\nend") +check(touchOpen ~= nil, "openTouchControlsEditor body found") +check(touchOpen:find("prepareOverlayHandoff", 1, true) ~= nil, + "openTouchControlsEditor prepares overlay handoff like the save editor") +local touchClose = mainSrc:match("function closeTouchControlsEditor%(%)(.-)\nend") +check(touchClose ~= nil, "closeTouchControlsEditor body found") +check(touchClose:find("resumeAfterOverlay", 1, true) ~= nil, + "closeTouchControlsEditor resumes the launcher pad cursor") +check(mainSrc:find("TouchEditor.gamepadpressed", 1, true) ~= nil, + "main.lua forwards gamepadpressed to TouchEditor") +check(mainSrc:find("TouchEditor.gamepadaxis", 1, true) ~= nil, + "main.lua forwards gamepadaxis to TouchEditor") + +local edSrc = read("src/ui/TouchControlsEditor.lua") +check(edSrc:find('require("src.ui.PadCursor")', 1, true) ~= nil, + "TouchControlsEditor loads PadCursor") +check(edSrc:find("function Editor.gamepadpressed", 1, true) ~= nil, + "TouchControlsEditor exposes gamepadpressed") +check(edSrc:find("PadCursor.draw()", 1, true) ~= nil, + "TouchControlsEditor draws the pad cursor") +check(edSrc:find("PadCursor.yieldToPointer()", 1, true) ~= nil, + "touch/mouse yields the pad so taps use event coords") +check(edSrc:find('beginDrag("pad"', 1, true) ~= nil, + "A begins a pad drag at the virtual cursor") +check(edSrc:find('endDrag("pad")', 1, true) ~= nil, + "A release ends a pad drag") + +local packSrc = read("scripts/pack_love.sh") +check(packSrc:find("tools/save-editor/PadInput.lua", 1, true) ~= nil, + "pack still requires PadInput path (compat shim)") + +T.finish("touch_controls_pad_cursor") diff --git a/tools/save-editor/PadInput.lua b/tools/save-editor/PadInput.lua index 6feb6241..460d315c 100644 --- a/tools/save-editor/PadInput.lua +++ b/tools/save-editor/PadInput.lua @@ -1,215 +1,3 @@ --- Virtual pointer for the save editor on Switch / handhelds / any gamepad. --- Mirrors the launcher's RomImporter pad cursor (speeds, deadzone, dual-path --- raw gate) without sharing that module -- keeps RomImporter risk-free. --- --- Stick / D-pad move; real mouse motion yields so desktop stays normal. --- Callers (App.lua) map A → click, B → close, shoulders → tabs, right stick --- → wheel notches. - -local SafeArea = require("src.core.SafeArea") -local GamepadMap = require("src.core.GamepadMap") - -local PAD_DEAD = 0.28 -local PAD_SPEED = 560 -local PAD_DPAD_SPEED = 420 --- Right stick → Kit wheel notches: ~2 notches/sec at full deflection so lists --- scroll at a usable pace without flooding one frame. -local PAD_WHEEL_RATE = 2.0 - -local PadInput = {} - -local cursor = { x = 0, y = 0 } -local active = false -local inited = false -local axis = { leftx = 0, lefty = 0, righty = 0 } -local dir = {} -local rawHatDirs = {} -local lastMouseX, lastMouseY -local wheelAcc = 0 - -local function activate() - if active then return end - local ox, oy, w, h = SafeArea.rect() - if not inited then - cursor.x = ox + w * 0.5 - cursor.y = oy + h * 0.45 - inited = true - end - active = true -end - -function PadInput.reset() - cursor.x, cursor.y = 0, 0 - active = false - inited = false - axis.leftx, axis.lefty, axis.righty = 0, 0, 0 - for k in pairs(dir) do dir[k] = nil end - for k in pairs(rawHatDirs) do rawHatDirs[k] = nil end - lastMouseX, lastMouseY = nil, nil - wheelAcc = 0 -end - --- Touch / mouse press: drop the virtual cursor for this interaction so a tap --- is not swallowed by the Joy-Con pointer sitting elsewhere on screen. -function PadInput.yieldToPointer() - active = false -end - --- Returns mx, my, isActive. When inactive the caller should use the system --- mouse; when active these coords feed Kit.beginFrame. -function PadInput.pointer() - return cursor.x, cursor.y, active -end - -function PadInput.isActive() - return active -end - --- Consume accumulated right-stick scroll as integer wheel notches (same --- units App.wheelmoved feeds Kit). Fractional remainder stays for next frame. -function PadInput.takeWheel() - local notches = 0 - if wheelAcc >= 1 or wheelAcc <= -1 then - notches = wheelAcc > 0 and math.floor(wheelAcc) or math.ceil(wheelAcc) - wheelAcc = wheelAcc - notches - end - return notches -end - -function PadInput.update(dt) - if not (love and love.mouse and love.mouse.getPosition) then return end - local mx, my = love.mouse.getPosition() - if lastMouseX and active then - if math.abs(mx - lastMouseX) > 3 or math.abs(my - lastMouseY) > 3 then - active = false - end - end - lastMouseX, lastMouseY = mx, my - - local ax = axis.leftx or 0 - local ay = axis.lefty or 0 - local dx, dy = 0, 0 - if math.abs(ax) > PAD_DEAD then dx = dx + ax end - if math.abs(ay) > PAD_DEAD then dy = dy + ay end - if dir.dpleft then dx = dx - 1 end - if dir.dpright then dx = dx + 1 end - if dir.dpup then dy = dy - 1 end - if dir.dpdown then dy = dy + 1 end - - if dx ~= 0 or dy ~= 0 then - activate() - local mag = math.sqrt(dx * dx + dy * dy) - if mag > 1 then dx, dy = dx / mag, dy / mag end - local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD) - and PAD_SPEED or PAD_DPAD_SPEED - local ox, oy, w, h = SafeArea.rect() - local nx = cursor.x + dx * speed * dt - local ny = cursor.y + dy * speed * dt - cursor.x = math.max(ox, math.min(ox + w, nx)) - cursor.y = math.max(oy, math.min(oy + h, ny)) - end - - local ry = axis.righty or 0 - if math.abs(ry) > PAD_DEAD then - activate() - -- Negative righty (stick up) scrolls lists up = positive wheel notches. - wheelAcc = wheelAcc + (-ry) * PAD_WHEEL_RATE * dt - end -end - --- Returns a string action the App layer handles: --- "a" | "b" | "tab_prev" | "tab_next" | nil -function PadInput.gamepadpressed(_, button) - activate() - local action = GamepadMap.mapGamepadButton(button) - if action == "a" or action == "b" then - return action - elseif button == "leftshoulder" then - return "tab_prev" - elseif button == "rightshoulder" then - return "tab_next" - elseif button == "dpup" or button == "dpdown" - or button == "dpleft" or button == "dpright" then - dir[button] = true - end - return nil -end - -function PadInput.gamepadreleased(_, button) - if button == "dpup" or button == "dpdown" - or button == "dpleft" or button == "dpright" then - dir[button] = nil - end -end - -function PadInput.gamepadaxis(_, axisName, value) - if axisName == "leftx" or axisName == "lefty" or axisName == "righty" then - axis[axisName] = value - if math.abs(value) > PAD_DEAD then activate() end - end -end - -function PadInput.joystickpressed(joystick, button) - if GamepadMap.ignoreRawForJoystick(joystick) then return nil end - local padButton = GamepadMap.mapRawToGamepadButton(button) - if padButton then return PadInput.gamepadpressed(joystick, padButton) end - return nil -end - -function PadInput.joystickreleased(joystick, button) - if GamepadMap.ignoreRawForJoystick(joystick) then return end - local padButton = GamepadMap.mapRawToGamepadButton(button) - if padButton then PadInput.gamepadreleased(joystick, padButton) end -end - -function PadInput.joystickaxis(joystick, axisIndex, value) - if GamepadMap.ignoreRawForJoystick(joystick) then return end - if axisIndex == 1 then - PadInput.gamepadaxis(joystick, "leftx", value) - elseif axisIndex == 2 then - PadInput.gamepadaxis(joystick, "lefty", value) - end -end - -function PadInput.joystickhat(joystick, hat, direction) - if GamepadMap.ignoreRawForJoystick(joystick) then return end - for _, d in ipairs(rawHatDirs[hat] or {}) do - dir[d] = nil - end - local dirs = ({ - u = { "dpup" }, d = { "dpdown" }, l = { "dpleft" }, r = { "dpright" }, - lu = { "dpleft", "dpup" }, ru = { "dpright", "dpup" }, - ld = { "dpleft", "dpdown" }, rd = { "dpright", "dpdown" }, - })[direction] or {} - for _, d in ipairs(dirs) do dir[d] = true end - rawHatDirs[hat] = dirs - if #dirs > 0 then activate() end -end - -function PadInput.draw() - if not active then return end - if not (love and love.graphics) then return end - local x, y = cursor.x, cursor.y - love.graphics.push("all") - if love.graphics.origin then love.graphics.origin() end - if love.graphics.setLineWidth then love.graphics.setLineWidth(1) end - love.graphics.setColor(0, 0, 0, 0.45) - if love.graphics.polygon then - love.graphics.polygon("fill", - x + 2, y + 2, x + 2, y + 22, x + 8, y + 16, x + 14, y + 26, - x + 18, y + 24, x + 11, y + 14, x + 20, y + 14) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.polygon("fill", - x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, - x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) - love.graphics.setColor(0.05, 0.07, 0.12, 1) - love.graphics.polygon("line", - x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, - x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) - else - love.graphics.rectangle("fill", x, y, 12, 18) - end - love.graphics.pop() -end - -return PadInput +-- Compat shim: PadInput lived here first; shared implementation is now +-- src/ui/PadCursor.lua so the touch-controls editor can reuse it. +return require("src.ui.PadCursor")