feat(ui): add native controller menu navigation with Y hotkey toggle and soft-glow focus

This commit is contained in:
Shane McGovern
2026-08-26 12:28:02 +01:00
parent 1d735c3291
commit 7e4c4eb46e
3 changed files with 119 additions and 57 deletions
+19
View File
@@ -6011,6 +6011,25 @@ function LauncherView.draw(imp)
Kit.endFrame() Kit.endFrame()
drawPadCursor(imp) drawPadCursor(imp)
if imp._cursorModeToast and imp._cursorModeToastTime then
local elapsed = love.timer.getTime() - imp._cursorModeToastTime
if elapsed < 2.2 then
local alpha = 1.0
if elapsed > 1.7 then alpha = math.max(0, (2.2 - elapsed) / 0.5) end
local msg = imp._cursorModeToast
local tw = Kit.textWidth("small", msg) + 36 * m.s
local th = 34 * m.s
local tx = (m.W - tw) / 2
local ty = 16 * m.s
Theme.fillRounded(tx, ty, tw, th, PAL.surface, 0.95 * alpha, 6)
Theme.strokeRounded(tx - 2, ty - 2, tw + 4, th + 4, PAL.railBlue, 0.35 * alpha, 2, 8)
Theme.strokeRounded(tx, ty, tw, th, PAL.lineStrong, 0.85 * alpha, 1.5, 6)
Kit.textCenterBold("small", msg, tx, ty + 8 * m.s, tw, PAL.heading)
else
imp._cursorModeToast = nil
end
end
end end
return LauncherView return LauncherView
+86 -52
View File
@@ -3128,6 +3128,23 @@ function RomImporter:_updatePadCursor(dt)
local ax = self._padAxis.leftx or 0 local ax = self._padAxis.leftx or 0
local ay = self._padAxis.lefty or 0 local ay = self._padAxis.lefty or 0
if not self._padCursorActive then
-- Stick navigation flick in native controller mode
if math.abs(ax) > 0.55 or math.abs(ay) > 0.55 then
if not self._stickFlicked then
if ay < -0.55 then Kit.navigate("up"); self._stickFlicked = true
elseif ay > 0.55 then Kit.navigate("down"); self._stickFlicked = true
elseif ax < -0.55 then Kit.navigate("left"); self._stickFlicked = true
elseif ax > 0.55 then Kit.navigate("right"); self._stickFlicked = true
end
end
elseif math.abs(ax) < 0.3 and math.abs(ay) < 0.3 then
self._stickFlicked = false
end
return
end
local dx, dy = 0, 0 local dx, dy = 0, 0
if math.abs(ax) > PAD_DEAD then dx = dx + ax end if math.abs(ax) > PAD_DEAD then dx = dx + ax end
if math.abs(ay) > PAD_DEAD then dy = dy + ay end if math.abs(ay) > PAD_DEAD then dy = dy + ay end
@@ -3147,14 +3164,6 @@ function RomImporter:_updatePadCursor(dt)
local ny = self._padCursor.y + dy * speed * dt local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(ox, math.min(ox + w, nx)) self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(oy, math.min(oy + h, ny)) self._padCursor.y = math.max(oy, math.min(oy + h, ny))
-- Pushing INTO the top/bottom edge scrolls the page instead of stalling.
-- The cursor is clamped to the safe area above, so on a short window the
-- rows below the fold are unreachable on a stickless handheld: no mouse
-- wheel, no touchscreen, and no right stick to feed the existing wheel
-- path. Only the OVERSHOOT scrolls -- parking the cursor at the edge does
-- nothing, it has to be actively pushed -- and this block only runs on pad
-- input, so a real mouse is unaffected. /48 matches the pixels-per-notch
-- LauncherView.draw multiplies back out.
local overY = 0 local overY = 0
if ny > oy + h then overY = ny - (oy + h) if ny > oy + h then overY = ny - (oy + h)
elseif ny < oy then overY = ny - oy end elseif ny < oy then overY = ny - oy end
@@ -3162,17 +3171,12 @@ function RomImporter:_updatePadCursor(dt)
if overY ~= 0 and self._flex then if overY ~= 0 and self._flex then
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
end end
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
-- NX: the getPosition bridge already returns pad coords -- skip setPosition.
if not self.isNX and love.mouse.setPosition then if not self.isNX and love.mouse.setPosition then
pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y) pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y)
self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y
end end
end end
-- Right stick scrolls whatever the pad pointer sits over, through the
-- view's wheel path, so the page and the modal scrollers all behave like a
-- mouse wheel would.
local ry = self._padAxis.righty or 0 local ry = self._padAxis.righty or 0
if math.abs(ry) > PAD_DEAD and self._flex then if math.abs(ry) > PAD_DEAD and self._flex then
self:_activatePadCursor() self:_activatePadCursor()
@@ -3191,45 +3195,18 @@ function RomImporter:gamepadpressed(_, button)
if Kit.FileBrowser.gamepadpressed(action) then return end if Kit.FileBrowser.gamepadpressed(action) then return end
end end
end end
self:_activatePadCursor()
if action == "a" then -- Y button toggle between Native Controller Navigation and Virtual Pointer Cursor:
-- Instant click at the virtual pointer: dispatched straight into the if action == "y" or button == "y" or button == "x" then
-- view, since the launcher no longer hit-tests presses itself. self._padCursorActive = not self._padCursorActive
if self._flex then if okKit then Kit._ringShown = not self._padCursorActive end
require("src.import.LauncherView").clickAt(self, self._cursorModeToast = self._padCursorActive and "Cursor Navigation [Y]" or "Controller Menu Navigation [Y]"
self._padCursor.x, self._padCursor.y) self._cursorModeToastTime = love.timer.getTime()
end
elseif action == "b" then
-- Cancel / dismiss active prompts
if self._indexPrompt then
self._indexPrompt = nil
self:_disarmTextInput()
return
elseif self._rename then
self._rename = nil
self:_disarmTextInput()
return
elseif self._profileRenamePrompt then
self._profileRenamePrompt = nil
self:_disarmTextInput()
return
elseif self._profileSavePrompt then
self._profileSavePrompt = nil
self:_disarmTextInput()
return
elseif self._settingsText then
self._settingsText = nil
self:_disarmTextInput()
return return
end end
elseif button == "leftshoulder" then
self:_cycleTab(-1) -- Select button: toggle Virtual Keyboard
elseif button == "rightshoulder" then if button == "back" or button == "select" or action == "select" then
self:_cycleTab(1)
elseif button == "dpup" or button == "dpdown"
or button == "dpleft" or button == "dpright" then
self._padDir[button] = true
elseif button == "back" or button == "select" or action == "select" then
if okKit and Kit.VirtualKeyboard then if okKit and Kit.VirtualKeyboard then
if Kit.VirtualKeyboard.active then if Kit.VirtualKeyboard.active then
Kit.VirtualKeyboard.close(false) Kit.VirtualKeyboard.close(false)
@@ -3301,13 +3278,70 @@ function RomImporter:gamepadpressed(_, button)
return return
end end
end end
elseif button == "start" then end
-- Start: Play if ready, else Choose ROM on the active game tab.
-- Shoulder buttons: cycle tabs
if button == "leftshoulder" then
self:_cycleTab(-1)
return
elseif button == "rightshoulder" then
self:_cycleTab(1)
return
end
-- Start button: Play / Choose ROM
if button == "start" then
if self.workState == "working" then return end if self.workState == "working" then return end
local version = self.tab local version = self.tab
if GameVersion.VERSIONS[version] then if GameVersion.VERSIONS[version] then
if self.ready[version] then self:play(version) else self:_romAction(version) end if self.ready[version] then self:play(version) else self:_romAction(version) end
end end
return
end
if not self._padCursorActive then
if okKit then Kit._ringShown = true end
if action == "a" then
if okKit then Kit.activateFocused() end
return
elseif action == "b" then
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return
elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return
end
elseif button == "dpup" or action == "dpup" then
if okKit then Kit.navigate("up") end
return
elseif button == "dpdown" or action == "dpdown" then
if okKit then Kit.navigate("down") end
return
elseif button == "dpleft" or action == "dpleft" then
if okKit then Kit.navigate("left") end
return
elseif button == "dpright" or action == "dpright" then
if okKit then Kit.navigate("right") end
return
end
else
self:_activatePadCursor()
if action == "a" then
if self._flex then
require("src.import.LauncherView").clickAt(self,
self._padCursor.x, self._padCursor.y)
end
elseif action == "b" then
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return
elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return
end
elseif button == "dpup" or button == "dpdown"
or button == "dpleft" or button == "dpright" then
self._padDir[button] = true
end
end end
end end
+12 -3
View File
@@ -568,8 +568,11 @@ function Kit.row(x, y, w, h, selected, id)
-- The focus ring is a second inset outline, so it reads on both a black -- The focus ring is a second inset outline, so it reads on both a black
-- row and a white selected one. -- row and a white selected one.
if focused then if focused then
Theme.strokeRounded(x + 2, y + 2, w - 4, h - 4, local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 4)
selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1, Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4,
PAL.railBlue, 0.35 + 0.25 * glowPulse, 2, Theme.radius() + 2)
Theme.strokeRounded(x, y, w, h,
selected and PAL.inverse or PAL.lineStrong, 0.90 + 0.10 * glowPulse, 1.5,
Theme.radius()) Theme.radius())
end end
local clicked = Kit.press(x, y, w, h) local clicked = Kit.press(x, y, w, h)
@@ -688,9 +691,15 @@ function Kit.button(x, y, w, h, label, opts)
Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius) Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius)
end end
if doRing then if doRing then
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 4)
-- Outer soft-glow highlight aura
Theme.strokeRounded(x - B.ringPad - 2, y - B.ringPad - 2,
w + 2 * (B.ringPad + 2), h + 2 * (B.ringPad + 2), PAL.railBlue,
0.30 + 0.25 * glowPulse, 2, radius + B.ringPad + 2)
-- Primary crisp focus ring
Theme.strokeRounded(x - B.ringPad, y - B.ringPad, Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong, w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
Theme.A.focus, B.ringWidth, radius + B.ringPad) 0.90 + 0.10 * glowPulse, B.ringWidth, radius + B.ringPad)
elseif glowA then elseif glowA then
Theme.strokeRounded(x - B.ringPad, y - B.ringPad, Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong, w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,