From 8bf99c33183850da339d9a3da7078e52c0fddc38 Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Sun, 2 Aug 2026 23:04:37 -0300 Subject: [PATCH] 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 --- docs/switch-development.md | 1 + main.lua | 70 ++++++++- scripts/pack_love.sh | 1 + scripts/test.sh | 1 + tests/save_editor_pad_input_test.lua | 106 +++++++++++++ tools/save-editor/App.lua | 83 ++++++++++- tools/save-editor/PadInput.lua | 215 +++++++++++++++++++++++++++ tools/save-editor/README.md | 11 ++ 8 files changed, 479 insertions(+), 9 deletions(-) create mode 100644 tests/save_editor_pad_input_test.lua create mode 100644 tools/save-editor/PadInput.lua diff --git a/docs/switch-development.md b/docs/switch-development.md index 4268aaec..754985c3 100644 --- a/docs/switch-development.md +++ b/docs/switch-development.md @@ -47,6 +47,7 @@ the transfer runbook). - VoxelMod OPTIONS + Switch performance tips documented (WATER / 3D-BTL / extras) - Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md` - Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail +- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md` ### Known gaps / welcome contributions diff --git a/main.lua b/main.lua index 9d6f4341..99ee7fae 100644 --- a/main.lua +++ b/main.lua @@ -369,49 +369,91 @@ end function love.gamepadpressed(joystick, button) SwitchDiagnostics.onJoystickEvent("gamepadpressed", joystick, button) - if editorMode or TouchEditor then return end + if editorMode then + if EditorApp and EditorApp.gamepadpressed then + return EditorApp.gamepadpressed(joystick, button) + end + return + end + if TouchEditor then return end if Importer then return Importer:gamepadpressed(joystick, button) end Game:gamepadpressed(joystick, button) end function love.gamepadreleased(joystick, button) SwitchDiagnostics.onJoystickEvent("gamepadreleased", joystick, button) - if editorMode or TouchEditor then return end + if editorMode then + if EditorApp and EditorApp.gamepadreleased then + return EditorApp.gamepadreleased(joystick, button) + end + return + end + if TouchEditor then return end if Importer then return Importer:gamepadreleased(joystick, button) end Game:gamepadreleased(joystick, button) end function love.gamepadaxis(joystick, axis, value) SwitchDiagnostics.onJoystickEvent("gamepadaxis", joystick, axis, { value = value }) - if editorMode or TouchEditor then return end + if editorMode then + if EditorApp and EditorApp.gamepadaxis then + return EditorApp.gamepadaxis(joystick, axis, value) + end + return + end + if TouchEditor then return end if Importer then return Importer:gamepadaxis(joystick, axis, value) end Game:gamepadaxis(joystick, axis, value) end function love.joystickpressed(joystick, button) SwitchDiagnostics.onJoystickEvent("joystickpressed", joystick, button) - if editorMode or TouchEditor then return end + if editorMode then + if EditorApp and EditorApp.joystickpressed then + return EditorApp.joystickpressed(joystick, button) + end + return + end + if TouchEditor then return end if Importer then return Importer:joystickpressed(joystick, button) end Game:joystickpressed(joystick, button) end function love.joystickreleased(joystick, button) SwitchDiagnostics.onJoystickEvent("joystickreleased", joystick, button) - if editorMode or TouchEditor then return end + if editorMode then + if EditorApp and EditorApp.joystickreleased then + return EditorApp.joystickreleased(joystick, button) + end + return + end + if TouchEditor then return end if Importer then return Importer:joystickreleased(joystick, button) end Game:joystickreleased(joystick, button) end function love.joystickaxis(joystick, axis, value) SwitchDiagnostics.onJoystickEvent("joystickaxis", joystick, axis, { value = value }) - if editorMode or TouchEditor then return end + if editorMode then + if EditorApp and EditorApp.joystickaxis then + return EditorApp.joystickaxis(joystick, axis, value) + end + return + end + if TouchEditor then return end if Importer then return Importer:joystickaxis(joystick, axis, value) end Game:joystickaxis(joystick, axis, value) end function love.joystickhat(joystick, hat, direction) SwitchDiagnostics.onJoystickEvent("joystickhat", joystick, hat, { direction = direction }) - if editorMode or TouchEditor then return end + if editorMode then + if EditorApp and EditorApp.joystickhat then + return EditorApp.joystickhat(joystick, hat, direction) + end + return + end + if TouchEditor then return end if Importer then return Importer:joystickhat(joystick, hat, direction) end Game:joystickhat(joystick, hat, direction) end @@ -459,7 +501,16 @@ function love.lowmemory() end function love.touchpressed(id, x, y, dx, dy, pressure) - if editorMode then return end + if editorMode then + -- iOS synthesizes mousepressed for the primary touch; forwarding here + -- would double-fire. Android / NX need the explicit touch → click path + -- (love-nx does not synthesize mouse for the editor the way desktop does). + if love.system.getOS() == "iOS" then return end + if EditorApp and EditorApp.mousepressed then + return EditorApp.mousepressed(x, y, 1) + end + return + end if TouchEditor then -- iOS synthesizes mousepressed for the primary touch (same as the -- launcher); Android drives the editor through love.touch directly. @@ -537,6 +588,9 @@ function love.mousepressed(x, y, button, istouch) return Importer:mousepressed(x, y, button) end if editorMode and EditorApp.mousepressed then + -- Same Android double-fire guard: touchpressed already clicked for the + -- save editor; a synthesized mouse press must not fire again. + if istouch and love.system.getOS() == "Android" then return end return EditorApp.mousepressed(x, y, button) end if mouseTouch and Game and button == 1 then diff --git a/scripts/pack_love.sh b/scripts/pack_love.sh index 89906917..08d651a6 100755 --- a/scripts/pack_love.sh +++ b/scripts/pack_love.sh @@ -62,6 +62,7 @@ if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' "$LIST fi for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \ + tools/save-editor/PadInput.lua \ tools/save-editor/panels/Party.lua \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json; do diff --git a/scripts/test.sh b/scripts/test.sh index 109eab89..62cd93d7 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -125,6 +125,7 @@ if [ -f data/generated/maps.lua ]; then run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua + run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua fi else diff --git a/tests/save_editor_pad_input_test.lua b/tests/save_editor_pad_input_test.lua new file mode 100644 index 00000000..cb65cf45 --- /dev/null +++ b/tests/save_editor_pad_input_test.lua @@ -0,0 +1,106 @@ +-- Save editor pad / Joy-Con input (NX soft-lock fix). +-- PadInput mirrors the launcher cursor; main.lua must forward editorMode +-- gamepad/touch instead of discarding them. +-- luajit tests/save_editor_pad_input_test.lua + +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" + .. ";./tools/save-editor/panels/?.lua" + +local love_stub = require("tests.love_stub") +love = love or love_stub + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local GamepadMap = require("src.core.GamepadMap") +local PadInput = require("PadInput") + +PadInput.reset() + +-- Stick deflection activates the cursor and moves it. +PadInput.gamepadaxis(nil, "leftx", 1) +PadInput.update(0.05) +check(PadInput.isActive(), "left stick activates pad cursor") +local x0 = select(1, PadInput.pointer()) +PadInput.update(0.05) +local x1 = select(1, PadInput.pointer()) +check(x1 > x0, "left stick moves cursor right") + +-- A / B via GamepadMap (desktop labels = GB a/b). +eq(PadInput.gamepadpressed(nil, "a"), "a", "gamepad a → click action") +eq(PadInput.gamepadpressed(nil, "b"), "b", "gamepad b → close action") +eq(PadInput.gamepadpressed(nil, "leftshoulder"), "tab_prev", "L cycles tab prev") +eq(PadInput.gamepadpressed(nil, "rightshoulder"), "tab_next", "R cycles tab next") + +-- NX face swap: SDL south "a" is Nintendo B → GB b (close). +GamepadMap._setForceNXForTests(true) +eq(PadInput.gamepadpressed(nil, "a"), "b", "NX SDL a (south) → close (GB b)") +eq(PadInput.gamepadpressed(nil, "b"), "a", "NX SDL b (east) → click (GB a)") +GamepadMap._setForceNXForTests(false) + +-- Dual-path gate: gamepad sticks must not also fire from raw joystick (#620). +local gamepadJoy = { + isGamepad = function() return true end, +} +eq(PadInput.joystickpressed(gamepadJoy, 1), nil, + "ignoreRaw skips second fire on isGamepad sticks") + +-- Raw (non-gamepad) stick still maps through GamepadMap. +local rawJoy = { + isGamepad = function() return false end, +} +eq(PadInput.joystickpressed(rawJoy, 1), "a", + "raw #1 clicks via shared map") + +-- Right stick accumulates wheel notches. +PadInput.reset() +PadInput.gamepadaxis(nil, "righty", -1) -- stick up +PadInput.update(1.0) +local notches = PadInput.takeWheel() +check(notches >= 1, "right stick up yields positive wheel notches") + +PadInput.reset() +check(not PadInput.isActive(), "reset clears active cursor") + +-- Touch / mouse must yield the pad so a tap is not hit-tested at the Joy-Con +-- pointer (the NX "cursor shows but touch does nothing" bug). +PadInput.gamepadaxis(nil, "leftx", 1) +PadInput.update(0.05) +check(PadInput.isActive(), "stick activates before yield") +PadInput.yieldToPointer() +check(not PadInput.isActive(), "yieldToPointer drops the virtual cursor") + +-- Source seams: main.lua must forward editorMode gamepad/touch; App must +-- own the pad handlers, prefer event click coords over the pad pointer, and +-- feed pad coords into Kit only when active and no pending click. +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("if editorMode then", 1, true) ~= nil + and mainSrc:find("EditorApp.gamepadpressed", 1, true) ~= nil, + "main.lua forwards gamepadpressed to EditorApp in editorMode") +check(mainSrc:find("EditorApp.mousepressed(x, y, 1)", 1, true) ~= nil, + "main.lua touchpressed clicks the save editor (non-iOS)") +check(mainSrc:find('istouch and love.system.getOS() == "Android"', 1, true) ~= nil, + "main.lua guards Android double-fire for editor mousepressed") + +local appSrc = read("tools/save-editor/App.lua") +check(appSrc:find('require("PadInput")', 1, true) ~= nil, + "App.lua loads PadInput") +check(appSrc:find("function App.gamepadpressed", 1, true) ~= nil, + "App.lua exposes gamepadpressed") +check(appSrc:find("PadInput.reset()", 1, true) ~= nil, + "App.unload resets PadInput") +check(appSrc:find("PadInput.pointer()", 1, true) ~= nil, + "App.draw uses pad pointer when active") +check(appSrc:find("PadInput.yieldToPointer()", 1, true) ~= nil, + "App.mousepressed yields the pad so touch uses event coords") +check(appSrc:find("mouseClicked and clickX", 1, true) ~= nil, + "App.draw prefers click event coords over pad / mouse") + +T.finish("save_editor_pad_input") diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 9f6f75fd..f7b556f2 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -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 diff --git a/tools/save-editor/PadInput.lua b/tools/save-editor/PadInput.lua new file mode 100644 index 00000000..6feb6241 --- /dev/null +++ b/tools/save-editor/PadInput.lua @@ -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 diff --git a/tools/save-editor/README.md b/tools/save-editor/README.md index 900a069b..e6461965 100644 --- a/tools/save-editor/README.md +++ b/tools/save-editor/README.md @@ -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