feat(ui): 4-layer vertical controller navigation, bright white glowing focus highlights, and handheld updater support

This commit is contained in:
Shane McGovern
2026-08-26 12:44:20 +01:00
parent 7e4c4eb46e
commit 247ec919ea
3 changed files with 122 additions and 43 deletions
+20 -7
View File
@@ -377,12 +377,19 @@ end
-- visible while typing, and blinks a caret on the importer's pulse clock. -- visible while typing, and blinks a caret on the importer's pulse clock.
local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, action) local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, action)
Kit._audit("control", x, y, w, h, key) Kit._audit("control", x, y, w, h, key)
Kit.focusable(key, x, y, w, h) local isFocused = Kit.focusable(key, x, y, w, h)
Theme.fill(x, y, w, h, PAL.bg, 1) Theme.fill(x, y, w, h, PAL.bg, 1)
Theme.stroke(x, y, w, h, PAL.line, if isFocused then
focused and Theme.A.focus or local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
(Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline), Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
focused and 2 or 1) Theme.strokeRounded(x, y, w, h, PAL.ink, 0.95 + 0.05 * glowPulse, 2, 2)
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, 2)
else
Theme.stroke(x, y, w, h, PAL.line,
focused and Theme.A.focus or
(Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline),
focused and 2 or 1)
end
local pad = math.floor(10 * Kit.scale) local pad = math.floor(10 * Kit.scale)
local ty = y + (h - Kit.textHeight("button")) / 2 local ty = y + (h - Kit.textHeight("button")) / 2
local text = rawText or "" local text = rawText or ""
@@ -3433,13 +3440,19 @@ local function buildFooter(imp, m, y)
local bx = m.x + math.floor((m.w - topW) / 2) local bx = m.x + math.floor((m.w - topW) / 2)
local my = cy + math.floor((rowH - dh) / 2) local my = cy + math.floor((rowH - dh) / 2)
local chipY = cy + math.floor((rowH - chipH) / 2) local chipY = cy + math.floor((rowH - chipH) / 2)
local isFocused = Kit.focusable("bcg", bx, my, dw, dh)
local hot = Kit.hover(bx, my, dw, dh) local hot = Kit.hover(bx, my, dw, dh)
if isFocused then
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
Theme.strokeRounded(bx - 3, my - 3, dw + 6, dh + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
Theme.strokeRounded(bx, my, dw, dh, PAL.ink, 0.95 + 0.05 * glowPulse, 2, 2)
end
love.graphics.setShader(imp.invertShader) love.graphics.setShader(imp.invertShader)
love.graphics.setColor(1, 1, 1, hot and 1 or 0.85) love.graphics.setColor(1, 1, 1, (hot or isFocused) and 1 or 0.85)
love.graphics.draw(imp.bcg, Theme.snap(bx), Theme.snap(my), 0, scale, scale) love.graphics.draw(imp.bcg, Theme.snap(bx), Theme.snap(my), 0, scale, scale)
love.graphics.setShader() love.graphics.setShader()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
if Kit.press(bx, my, dw, dh) then if Kit.press(bx, my, dw, dh) or Kit._activateId == "bcg" then
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end) queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
end end
local cx = bx + dw local cx = bx + dw
+6 -1
View File
@@ -1263,7 +1263,12 @@ end
-- up the background worker or reach out to the network. -- up the background worker or reach out to the network.
local function updaterAllowed() local function updaterAllowed()
if not Platform.networkValidated() then return false end if not Platform.networkValidated() then return false end
if not (love.filesystem.isFused and love.filesystem.isFused()) then return false end local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
if not (love.filesystem.isFused and love.filesystem.isFused()) and not isHandheld then
return false
end
if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
return true return true
+96 -35
View File
@@ -407,9 +407,31 @@ function Kit.setFocus(id)
end end
-- Pick the nearest focusable in `dir` from the current one. Candidates must -- Pick the nearest focusable in `dir` from the current one. Candidates must
-- lie in the half-plane of the direction; the score prefers a small step local function getNavLayer(slot)
-- along the axis of travel and penalises drift across it, which keeps a local id = tostring(slot.id or "")
-- column walk inside its column. local y = slot.y or 0
-- Layer 1: Top Bar (Settings / Gear, Close / Quit)
if id == "gear" or id == "settings" or id == "close" or id == "quit" or (y < 45 * Kit.scale and not id:match("^tab%-")) then
return 1
end
-- Layer 2: ROM Select & Feature Tabs (Red, Blue, Yellow, Gold, Silver, Crystal, Mods, Find, Skins, Bug)
if id:match("^tab%-") or (y >= 45 * Kit.scale and y < 105 * Kit.scale and (slot.h < 50 * Kit.scale)) then
return 2
end
-- Layer 4: Footer (Patch notes, Updater, BCG, Export, etc.)
local screenH = (love and love.graphics and love.graphics.getHeight and love.graphics.getHeight()) or 480
if id == "patch-notes" or id == "updater" or id == "bcg"
or id:match("^footer") or (y > screenH - 55 * Kit.scale) then
return 4
end
-- Layer 3: Main content panel (Import ROM / Play, Manage, Save slots, Mods list, Find mods, etc.)
return 3
end
function Kit._resolveNav() function Kit._resolveNav()
local dir = Kit._navQueue local dir = Kit._navQueue
Kit._navQueue = nil Kit._navQueue = nil
@@ -417,34 +439,68 @@ function Kit._resolveNav()
if not dir or n == 0 then return end if not dir or n == 0 then return end
local cur local cur
for i = 1, n do for i = 1, n do
if Kit._nav[i].id == Kit.focusId then cur = Kit._nav[i] break end if Kit._nav[i].id == Kit.focusId then cur = Kit._nav[i]; break end
end end
if not cur then if not cur then
Kit.focusId = Kit._nav[1].id Kit.focusId = Kit._nav[1].id
return return
end end
local curLayer = getNavLayer(cur)
local cx, cy = cur.x + cur.w / 2, cur.y + cur.h / 2 local cx, cy = cur.x + cur.w / 2, cur.y + cur.h / 2
local best, bestScore
for i = 1, n do if dir == "left" or dir == "right" then
local c = Kit._nav[i] -- STRICT SAME-LAYER HORIZONTAL NAVIGATION (Left/Right NEVER jumps between layers)
if c.id ~= cur.id then local best, bestDx
local dx = (c.x + c.w / 2) - cx for i = 1, n do
local dy = (c.y + c.h / 2) - cy local c = Kit._nav[i]
local along, across if c.id ~= cur.id and getNavLayer(c) == curLayer then
if dir == "left" then along, across = -dx, math.abs(dy) local cMidX = c.x + c.w / 2
elseif dir == "right" then along, across = dx, math.abs(dy) local dx = cMidX - cx
elseif dir == "up" then along, across = -dy, math.abs(dx) if dir == "left" and dx < -1 then
else along, across = dy, math.abs(dx) end local dist = -dx
-- A control merely overlapping on the travel axis is not "in that if not bestDx or dist < bestDx then
-- direction"; require real separation so a tall row's neighbours do best, bestDx = c, dist
-- not all qualify. end
if along > 1 then elseif dir == "right" and dx > 1 then
local score = along + across * 2 local dist = dx
if not bestScore or score < bestScore then best, bestScore = c, score end if not bestDx or dist < bestDx then
best, bestDx = c, dist
end
end
end end
end end
if best then
Kit.focusId = best.id
return
end
return
elseif dir == "up" or dir == "down" then
-- VERTICAL LAYER NAVIGATION (Up/Down steps between layers: 1 <-> 2 <-> 3 <-> 4)
local targetLayer = dir == "up" and (curLayer - 1) or (curLayer + 1)
targetLayer = math.max(1, math.min(4, targetLayer))
local best, bestScore
for i = 1, n do
local c = Kit._nav[i]
if c.id ~= cur.id then
local l = getNavLayer(c)
if (dir == "up" and l < curLayer) or (dir == "down" and l > curLayer) then
local layerDiff = math.abs(l - targetLayer)
local cMidX = c.x + c.w / 2
local xDist = math.abs(cMidX - cx)
local score = layerDiff * 10000 + xDist
if not bestScore or score < bestScore then
best, bestScore = c, score
end
end
end
end
if best then
Kit.focusId = best.id
return
end
end end
if best then Kit.focusId = best.id end
end end
-- ------------------------------------------------------------ input plumbing -- ------------------------------------------------------------ input plumbing
@@ -568,12 +624,15 @@ 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
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 4) local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, -- Outer white soft aura
PAL.railBlue, 0.35 + 0.25 * glowPulse, 2, Theme.radius() + 2) Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6,
PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, Theme.radius() + 3)
-- Bright white inner stroke
Theme.strokeRounded(x, y, w, h, Theme.strokeRounded(x, y, w, h,
selected and PAL.inverse or PAL.lineStrong, 0.90 + 0.10 * glowPulse, 1.5, PAL.ink, 0.95 + 0.05 * glowPulse, 2, Theme.radius())
Theme.radius()) -- Luminous white fill overlay
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, Theme.radius())
end end
local clicked = Kit.press(x, y, w, h) local clicked = Kit.press(x, y, w, h)
or (id ~= nil and Kit._activateId == id) or (id ~= nil and Kit._activateId == id)
@@ -691,15 +750,17 @@ 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) local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
-- Outer soft-glow highlight aura -- 1. Outer bright white soft glow aura
Theme.strokeRounded(x - B.ringPad - 2, y - B.ringPad - 2, Theme.strokeRounded(x - B.ringPad - 3, y - B.ringPad - 3,
w + 2 * (B.ringPad + 2), h + 2 * (B.ringPad + 2), PAL.railBlue, w + 2 * (B.ringPad + 3), h + 2 * (B.ringPad + 3), PAL.ink,
0.30 + 0.25 * glowPulse, 2, radius + B.ringPad + 2) 0.35 + 0.25 * glowPulse, 2.5, radius + B.ringPad + 3)
-- Primary crisp focus ring -- 2. Inner solid white focus border
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.ink,
0.90 + 0.10 * glowPulse, B.ringWidth, radius + B.ringPad) 0.95 + 0.05 * glowPulse, 2.5, radius + B.ringPad)
-- 3. Subtle luminous white fill overlay so the entire button body shines
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, radius)
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,