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")