fix(switch): restore Save Editor pad and touch input on NX

Route gamepad/touch into the editor instead of dropping them in editorMode,
and hit-test clicks at event coords so a finger tap is not lost under the
Joy-Con virtual cursor.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-02 23:04:37 -03:00
parent f0829ed54f
commit 8bf99c3318
8 changed files with 479 additions and 9 deletions
+82 -1
View File
@@ -24,6 +24,7 @@ local State = require("State")
local Kit = require("Kit")
local Theme = require("Theme")
local Ops = require("Ops")
local PadInput = require("PadInput")
local PAL = Theme.PAL
local Party = require("Party")
@@ -41,6 +42,10 @@ local S
-- vanilla records over an already-merged Data
local mods
local mouseClicked = false
-- Click position from the press event. Kit samples the pointer in draw, so a
-- touch / mouse / pad-A click must use the event coords -- not love.mouse
-- (often stale on NX) and not the virtual cursor when a finger taps elsewhere.
local clickX, clickY
-- Wheel notches queued by App.wheelmoved since the last draw, handed to Kit
-- there like mouseClicked is: LOVE delivers events before love.draw, so a
-- notch is always spent by the frame that follows it (#595).
@@ -233,6 +238,34 @@ function App.unload()
-- deaf to every click (#541).
Kit.blur()
Kit.blockClicks = false
PadInput.reset()
end
local function cycleTab(delta)
if not S then return end
local idx = 1
for i, t in ipairs(TABS) do
if t.id == S.tab then idx = i; break end
end
idx = ((idx - 1 + delta) % #TABS) + 1
S.tab = TABS[idx].id
Ops.say(S, "Tab: " .. TABS[idx].label)
end
-- Pad / Joy-Con actions from PadInput.gamepadpressed (A/B via GamepadMap so
-- NX physical A confirms and B closes).
local function handlePadAction(action)
if not action or not S then return end
if action == "a" then
local mx, my = PadInput.pointer()
App.mousepressed(mx, my, 1)
elseif action == "b" then
App.close()
elseif action == "tab_prev" then
cycleTab(-1)
elseif action == "tab_next" then
cycleTab(1)
end
end
function App.save()
@@ -277,6 +310,7 @@ end
-- host's onClose runs App.unload, which drops S -- doing that inline left the
-- rest of the frame drawing against a nil state.
function App.close()
if not S then return false end
if S.dirty and not S._quitArmed then
S._quitArmed = true
S.status = "Unsaved changes, Save first or click Close again to discard"
@@ -301,16 +335,55 @@ function App.update(dt)
-- directly in App.draw() via Kit.beginFrame. Tile animation (water,
-- flowers) still needs ticking so the Map tab isn't static.
TileRenderer.tick()
PadInput.update(dt)
local notches = PadInput.takeWheel()
if notches ~= 0 then
App.wheelmoved(0, notches)
end
end
function App.mousepressed(x, y, button)
if button == 1 then mouseClicked = true end
if button == 1 then
mouseClicked = true
clickX, clickY = x, y
-- A finger / mouse tap yields the virtual cursor so the click lands where
-- the event said, not under the Joy-Con pointer (NX touch soft-miss).
PadInput.yieldToPointer()
end
end
function App.textinput(text)
Kit.textinput(text)
end
function App.gamepadpressed(joystick, button)
handlePadAction(PadInput.gamepadpressed(joystick, button))
end
function App.gamepadreleased(joystick, button)
PadInput.gamepadreleased(joystick, button)
end
function App.gamepadaxis(joystick, axis, value)
PadInput.gamepadaxis(joystick, axis, value)
end
function App.joystickpressed(joystick, button)
handlePadAction(PadInput.joystickpressed(joystick, button))
end
function App.joystickreleased(joystick, button)
PadInput.joystickreleased(joystick, button)
end
function App.joystickaxis(joystick, axis, value)
PadInput.joystickaxis(joystick, axis, value)
end
function App.joystickhat(joystick, hat, direction)
PadInput.joystickhat(joystick, hat, direction)
end
-- ------------------------------------------------------------------ chrome
-- The file chip: the single source of truth for "which file am I editing".
-- The path truncates from the LEFT so the filename is always readable, and
@@ -624,8 +697,15 @@ function App.draw()
local s = Kit.scale
local mx, my = love.mouse.getPosition()
local padX, padY, padOn = PadInput.pointer()
if mouseClicked and clickX ~= nil then
mx, my = clickX, clickY
elseif padOn then
mx, my = padX, padY
end
Kit.beginFrame(mx, my, mouseClicked, wheelY)
mouseClicked = false
clickX, clickY = nil, nil
wheelY = 0
-- Modal shield. Kit has no z-order, so the picker cannot simply be drawn
-- last: the chrome and the panel underneath would take the same tap. The
@@ -656,6 +736,7 @@ function App.draw()
Kit.blockClicks = false
SpeciesPicker.draw(S, Kit, width, height)
Kit.endFrame()
PadInput.draw()
-- Only now, with the whole frame painted, is it safe to drop the editor.
if S._closeRequested then finishClose() end
+215
View File
@@ -0,0 +1,215 @@
-- 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
+11
View File
@@ -37,6 +37,7 @@ If the file isn't there (or you want another copy), use **Open...**, drop a
| --- | --- |
| `Theme.lua` | the launcher's palette + drawing primitives (cards, glow, dashed outlines, letterspaced captions) |
| `Kit.lua` | immediate-mode widgets built on Theme: buttons, rows, meters, chips, checkboxes, a real text field, pagers |
| `PadInput.lua` | virtual cursor for Switch / gamepads (stick move, A click, B close, shoulders cycle tabs) |
| `Ops.lua` | **every mutation**, behind one funnel that sets dirty + status together |
| `App.lua` | chrome (version rail, title bar, tab rail, status bar) and the panel router |
| `panels/` | one file per tab; pure layout that dispatches into Ops |
@@ -46,6 +47,15 @@ the modal species search the inspector opens, drawn by `App.draw` after the
panel rather than routed through the tab table. Kit has no z-order, so while
it is up `Kit.blockClicks` shields every widget underneath it.
### Switch / gamepad
On Nintendo Switch (and any gamepad without a mouse), the editor uses the same
virtual-cursor idea as the launcher: left stick / D-pad moves a pointer, **A**
clicks, **B** closes (with the usual unsaved confirm), L/R cycle tabs, and the
right stick scrolls lists. Touch taps forward as clicks. Without that path the
editor soft-locked until HOME — `main.lua` used to drop all pad/touch events
while `editorMode` was set.
The design reference is the `SaveEditor.dc.html` mockup that this port
transcribes; its measurements are in the same pixel space `App.lua` draws in.
@@ -70,6 +80,7 @@ luajit tests/save_editor_task6_tests.lua # Boxes + Items rules
luajit tests/save_editor_task7_tests.lua # Events + Dex rules
luajit tests/save_editor_task8_tests.lua # map browser + spawn points
luajit tests/save_editor_mod_tests.lua # modded species/items stay editable
luajit tests/save_editor_pad_input_test.lua # pad cursor / NX input routing
```
They drive `Ops.lua` rather than clicking pixel coordinates. The panels are