Files
gen1recomp/src/import/LauncherView.lua
T
2026-09-30 12:18:24 -04:00

6988 lines
284 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- The launcher's view, drawn with the shared immediate-mode kit
-- (src/ui/kit/). RomImporter owns every piece of state and all
-- import/platform logic; this module paints that state once per frame, so the
-- UI can never drift from the importer and every window size lays out fresh.
--
-- WHAT CHANGED, AND WHY. This used to build a retained FlexLove element tree
-- every frame. That cost ~9ms of build+draw on a real profile before a
-- single row of content existed (measure it yourself: POKEPORT_LAUNCHER_PROF=
-- 200 love .), because the engine hashed props per element, snapshotted every
-- public scalar for its immediate-mode persistence, and re-ran an O(n^2)
-- auto-size pass. Painting the same screen directly is a small fraction of
-- that, and it removes a whole class of layout bug along with it: percentage
-- widths resolving against the wrong box, auto-sized buttons measuring zero
-- height, and flex-shrink compressing text until it overlapped.
--
-- THE RULES THIS FILE FOLLOWS:
-- * Short lists paginate (Kit.pager, perPage from Kit.rowsThatFit); the
-- installed-mod list is one continuous scroll instead, drawing only the
-- rows inside the region viewport so the window bounds the frame cost.
-- * Every click handler only QUEUES work (imp._uiActions); update() drains
-- the queue, so an action that tears the view down (Play, Edit save)
-- never runs inside the frame that dispatched it.
-- * Clicks are deduped per control key: a touch tap can surface as both a
-- touch release and a synthesized mouse click, and one action must not
-- fire twice (the shape of #553's double import).
-- * Anything that waits raises a non-dismissable loader (Loader.overlay),
-- driven by imp._busy / imp.workState.
-- * Layout is explicit pixels off Layout.metrics. No percentages.
local Kit = require("src.ui.kit.Kit")
local CartShape = require("src.import.CartShape")
local Theme = require("src.ui.kit.Theme")
local Layout = require("src.ui.kit.Layout")
local Loader = require("src.ui.kit.Loader")
local Transition = require("src.ui.kit.Transition")
local GameVersion = require("src.core.GameVersion")
local SecretGames = require("src.import.SecretGames")
local Version = require("src.core.Version")
local Strings = require("src.core.Strings")
local WebClip = require("src.core.WebClip")
local PAL = Theme.PAL
local LauncherView = {}
local COMMUNITY_URL = "https://bois.icu"
-- One dedup window covers a touch release plus the mouse click SDL
-- synthesizes for the same tap.
local ACT_DEDUP = 0.35
local LONG_PRESS_SECONDS = 0.60
-- Finger travel past this (px) is a drag, not a tap.
local TAP_SLOP2 = 16 * 16
local MIN_SKIN_ROWS = 4
local SKIN_FORMAT_LABEL = {
native = "GEN1",
retroarch = "RETROARCH",
delta = "DELTA",
}
local MIN_FIND_ROWS = 3
local PANEL_OVERSCAN = 0.75
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
local function inRect(rect, x, y)
return rect ~= nil and x >= rect.x and x <= rect.x + rect.w
and y >= rect.y and y <= rect.y + rect.h
end
local function tabKeyOf(imp) return imp.tab or "red" end
local function tabScrollMax(imp)
local t = imp._tabScrollMax
return (t and t[tabKeyOf(imp)]) or 0
end
local function tabScrollAt(imp)
local t = imp._tabScroll
return clamp((t and t[tabKeyOf(imp)]) or 0, 0, tabScrollMax(imp))
end
local function setTabScroll(imp, value)
imp._tabScroll = imp._tabScroll or {}
imp._tabScroll[tabKeyOf(imp)] = clamp(value, 0, tabScrollMax(imp))
end
-- ------------------------------------------------------------- lifecycle
local function ensureState(imp)
if not imp._flex then
imp._flex = true
imp._hot = imp._hot or {}
imp._actAt = imp._actAt or {}
imp._uiActions = imp._uiActions or {}
imp._pages = imp._pages or {}
imp._tabScroll = imp._tabScroll or {}
imp._tabScrollMax = imp._tabScrollMax or {}
imp._tabContentH = imp._tabContentH or {}
-- Held backspace/arrows must repeat in the text fields; restored on
-- detach because the game's Input does its own per-step edge detection
-- and never expects repeated keypressed events.
if love.keyboard and love.keyboard.setKeyRepeat then
pcall(love.keyboard.setKeyRepeat, true)
end
end
end
-- Kept as a no-op hook: the engine tier asserts this exists, and the guards
-- it used to apply were FlexLove's (performance monitoring, GC tuning). The
-- kit has neither a profiler nor a GC strategy to tune -- it does not
-- allocate per frame -- so there is nothing left to guard.
function LauncherView.applyNxPerfGuards(imp)
return imp ~= nil
end
-- Tear down before handing the screen to the game / editor.
function LauncherView.detach(imp)
-- Restore the NX mouse shim even if _flex was never set (the bridge can
-- install on the first update before the first draw).
if imp and imp.parkNxPointerForHost then
pcall(imp.parkNxPointerForHost, imp)
elseif imp and imp._restoreNxPointerBridge then
pcall(imp._restoreNxPointerBridge, imp)
end
Transition.reset()
Kit.occlude(nil)
if imp and imp._themeVideo then
imp._themeVideo:release()
imp._themeVideo = nil
end
if not imp or not imp._flex then return end
imp._flex = nil
if love.keyboard and love.keyboard.setKeyRepeat then
pcall(love.keyboard.setKeyRepeat, false)
end
Kit.clearCaches()
end
local function markNoDrag(imp, x, y, w, h)
if Kit.blockClicks then return end
local t = imp._noDragRects
if not t then t = {}; imp._noDragRects = t end
local n = (imp._noDragN or 0) + 1
imp._noDragN = n
local r = t[n]
if not r then r = {}; t[n] = r end
r.x, r.y, r.w, r.h = x, y, w, h
end
local function noDragAt(imp, x, y)
local rects = imp._noDragRects
for i = 1, imp._noDragN or 0 do
if inRect(rects[i], x, y) then return true end
end
return false
end
local function armMouse(imp, x, y)
if noDragAt(imp, x, y) then
imp._clickPt = { x = x, y = y }
return
end
local shielded = imp._modalUpNow
imp._mouseAt = {
x = x, y = y,
region = not shielded and tabScrollMax(imp) > 0
and inRect(imp._tabRegionRect, x, y) or false,
page = not shielded and (imp._pageScrollMax or 0) > 0 or false,
}
Kit.dragBegin(x, y)
end
-- ---------------------------------------------------------------- input
-- The kit is polled, not evented: update() samples the mouse. A press arms
-- a drag that scrolls like a finger, and the click dispatches on RELEASE so
-- the drag can disqualify it, exactly like the touch path below; only the
-- cartridge (which owns its own spin-drag) keeps the press-down click.
-- Host-forwarded mousepressed stays unused, exactly as before, so Android's
-- synthesized mouse path cannot double-fire a tap (#553) -- the dedup window
-- below is the other half of that guarantee.
function LauncherView.update(imp, dt)
if not imp._flex then return end
local settingsOptions = imp._settings and imp._settings.opts
if type(settingsOptions) == "table" then
imp._themeVideoOptions = settingsOptions
elseif not imp._themeVideoOptions then
local ok, options = pcall(require("src.core.SaveData").loadOptions)
imp._themeVideoOptions = ok and type(options) == "table" and options or {}
end
local videoEnabled = imp._themeVideoOptions.themeVideoBg ~= false
if videoEnabled and not imp._themeVideo then
local ok, player = pcall(function()
return require("src.import.LauncherThemeVideo").new()
end)
if ok then imp._themeVideo = player end
elseif not videoEnabled and imp._themeVideo then
imp._themeVideo:release()
imp._themeVideo = nil
end
if imp._themeVideo then imp._themeVideo:update(dt) end
if imp._launchFade then return end
local down = false
if love.mouse and love.mouse.isDown then
down = love.mouse.isDown(1) and true or false
end
if down and not imp._prevMouseDown and not imp._padCursorActive then
-- On touch platforms SDL synthesizes a mouse button from the finger, so
-- this rising edge fires at finger-DOWN while touchreleased dispatches
-- the same tap again at finger-UP: every control acted twice per tap
-- (the pager visibly jumped two pages). While a touch is alive, or
-- inside the dedup window one just closed, the polled mouse IS that
-- finger and must not mint a second click. A real desktop mouse has no
-- touches, so its press-down click is unchanged.
-- _suppressMouseUntil, NOT _suppressClickUntil: the latter is consulted
-- by queueAction and would swallow the tap's own action along with the
-- synthesized echo.
local now = love.timer.getTime()
local touching = imp._touchAt ~= nil and next(imp._touchAt) ~= nil
if not touching and now >= (imp._suppressMouseUntil or 0)
and now >= (imp._suppressClickUntil or 0) then
local mx, my = love.mouse.getPosition()
armMouse(imp, mx, my)
end
elseif down and imp._mouseAt then
local start = imp._mouseAt
local mx, my = love.mouse.getPosition()
local ddx, ddy = mx - start.x, my - start.y
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
start.dragged = true
end
if start.dragged then
local last = start.lastY or start.y
local move = -(my - last)
if move ~= 0 and start.region then
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
tabScrollMax(imp), move)
setTabScroll(imp, at)
move = leftover
end
if move ~= 0 and start.page and (imp._pageScrollMax or 0) > 0 then
local at, leftover = Kit.scrollHandoff(imp._pageScroll or 0,
imp._pageScrollMax, move)
imp._pageScroll = at
move = leftover
end
if move ~= 0 then Kit.dragAdd(move) end
end
start.lastY = my
elseif not down and imp._mouseAt then
local start = imp._mouseAt
imp._mouseAt = nil
Kit.dragEnd()
if not start.dragged then
imp._clickPt = { x = start.x, y = start.y }
end
end
imp._prevMouseDown = down
-- Drain the action queue OUTSIDE the draw, so an action is free to destroy
-- the view (Play/Edit) or block in a native picker. The batch is resolved
-- by RomImporter:runActions so the drop/disarm rules stay testable without
-- a live view (#780).
local queue = imp._uiActions
if queue and #queue > 0 then
imp._uiActions = {}
imp:runActions(queue)
end
end
function LauncherView.wheelmoved(imp, dx, dy)
if not imp._flex then return end
imp._wheelY = (imp._wheelY or 0) + (dy or 0)
end
function LauncherView.touchpressed(imp, id, x, y)
if not imp._flex then return end
-- Leftover finger from EXIT GAME / Close editor: do not start a tap.
if imp._ignoreTouch and imp._ignoreTouch[tostring(id)] then
return
end
imp._touchAt = imp._touchAt or {}
local ms = imp._modalScroll
local settings = imp._modalKey == "_settings" and imp._settings
local picker = imp._modalKey == "_savePicker" and imp._savePicker
or imp._modalKey == "_modGames" and imp._modGames
or imp._modalKey == "_cartPopup" and imp._cartPicker
or settings and (settings.confirm and (ms and ms._settingsConfirm or false) or settings)
or ms and imp._modalKey and ms[imp._modalKey]
local shielded = imp._modalUpNow
imp._touchAt[tostring(id)] = {
x = x, y = y, started = love.timer.getTime(),
region = not shielded and tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y),
page = not shielded,
picker = picker and inRect(picker.rect, x, y) and picker or nil,
}
end
function LauncherView.touchmoved(imp, id, x, y)
if not imp._flex then return end
local start = imp._touchAt and imp._touchAt[tostring(id)]
if start then
local ddx, ddy = x - start.x, y - start.y
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
start.dragged = true
end
if start.dragged then
local last = start.lastY or start.y
local move = -(y - last)
if move ~= 0 and start.picker then
start.picker.scroll = Kit.scrollClamp((start.picker.scroll or 0) + move, start.picker.maxScroll)
move = 0
end
if move ~= 0 and start.region then
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
tabScrollMax(imp), move)
setTabScroll(imp, at)
move = leftover
end
if move ~= 0 and start.page and (imp._pageScrollMax or 0) > 0 then
imp._pageScroll = (imp._pageScroll or 0) + move
end
end
start.lastY = y
end
end
-- A tap dispatches on RELEASE (not press) so a drag can disqualify it.
function LauncherView.touchreleased(imp, id, x, y)
if not imp._flex then return end
local tid = tostring(id)
if imp._ignoreTouch and imp._ignoreTouch[tid] then
imp._ignoreTouch[tid] = nil
if imp._touchAt then imp._touchAt[tid] = nil end
imp._suppressMouseUntil = love.timer.getTime() + ACT_DEDUP
return
end
local start = imp._touchAt and imp._touchAt[tid]
if imp._touchAt then imp._touchAt[tid] = nil end
-- A release with no matching press is leftover from the previous host
-- (game / save editor), not a launcher tap (#2079).
if not start then
imp._suppressMouseUntil = love.timer.getTime() + ACT_DEDUP
return
end
if start and (start.dragged or start.longPressed) then
-- Suppress the mouse click SDL will synthesize for this same gesture.
imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP
return
end
-- The tap dispatches HERE, once: suppress update()'s rising-edge path for
-- the mouse press SDL synthesizes from this same gesture. Mouse-only
-- suppression -- _suppressClickUntil would also make queueAction drop the
-- tap's own action.
imp._suppressMouseUntil = love.timer.getTime() + ACT_DEDUP
imp._clickPt = { x = x, y = y }
end
local function triggerLongPress(imp, x, y, w, h, version)
if not imp.ios or imp._modalUpNow then return false end
local touches = imp._touchAt
if not touches then return false end
local now = love.timer.getTime()
for _, touch in pairs(touches) do
if not touch.dragged and not touch.longPressed
and inRect({ x = x, y = y, w = w, h = h }, touch.x, touch.y)
and now - (touch.started or now) >= LONG_PRESS_SECONDS then
touch.longPressed = true
imp._suppressClickUntil = now + ACT_DEDUP
imp._clickPt = nil
imp._gameManage = version
return true
end
end
return false
end
-- Synthetic click for the gamepad virtual cursor.
function LauncherView.clickAt(imp, x, y)
if not imp._flex then return end
imp._clickPt = { x = x, y = y }
end
-- Event-driven press: a macOS trackpad tap delivers press+release inside one
-- frame, so update()'s love.mouse.isDown poll never sees it. Arm the drag
-- from the press event under the poll's own suppression rules -- the poll's
-- release branch then mints the tap, still within the same frame for a
-- one-frame tap -- and mark the press seen so the poll cannot arm a second
-- one when isDown does catch it.
function LauncherView.mousepressed(imp, x, y)
if not imp._flex then return end
local now = love.timer.getTime()
local touching = imp._touchAt ~= nil and next(imp._touchAt) ~= nil
if touching or now < (imp._suppressMouseUntil or 0)
or now < (imp._suppressClickUntil or 0) then
return
end
if not imp._mouseAt then armMouse(imp, x, y) end
imp._prevMouseDown = true
end
-- Keyboard focus ring. Returns true when the key was consumed. Arrows arm
-- the ring; Enter only activates a focused control once the user has actually
-- used the arrows this session, so the long-standing "Enter plays the visible
-- game" shortcut keeps working for anyone who never touches the ring.
function LauncherView.keypressed(imp, key)
if not imp._flex then return false end
if key == "up" or key == "down" or key == "left" or key == "right" then
imp._ringArmed = true
Kit.navigate(key)
return true
end
if key == "return" or key == "kpenter" or key == "space" then
if imp._ringArmed then
Kit.activateFocused()
return true
end
if imp._tradeModal then
require("src.import.OnlinePanel").tradeModalAction(imp, "a")
return true
end
if imp._pinModal then
require("src.import.OnlinePanel").pinAction(imp, "a")
return true
end
end
return false
end
-- ------------------------------------------------------------- actions
local function queueAction(imp, key, fn, keepArm)
local now = love.timer.getTime()
local last = imp._actAt[key]
if last and now - last < ACT_DEDUP then return end
local untilT = imp._suppressClickUntil
if untilT and now < untilT then return end
imp._actAt[key] = now
-- Any press that is not a Delete's own second click disarms the pending
-- delete confirm (#433's rule). The disarm is applied by runActions when
-- the batch drains, not here: one touch lands on a row AND on the chip
-- inside it, and clearing the arm as the row queued left Delete stuck on
-- its first press (#780).
imp._uiActions[#imp._uiActions + 1] = { key = key, fn = fn, keepArm = keepArm }
end
-- Every interactive control in this file goes through one of these two, so
-- the queueing and dedup rules cannot be forgotten at a call site.
local function btn(imp, x, y, w, h, key, label, opts)
opts = opts or {}
opts.id = key
if Kit.button(x, y, w, h, label, opts) and opts.action then
queueAction(imp, key, opts.action, opts.keepArm)
end
end
local function rowHit(imp, x, y, w, h, selected, key, action)
local clicked, ink = Kit.row(x, y, w, h, selected, key)
if clicked and action then queueAction(imp, key, action) end
return ink
end
LauncherView.btn = btn
LauncherView.rowHit = rowHit
LauncherView.queueAction = queueAction
-- ------------------------------------------------------- shared widgets
-- Read-only text field. The importer owns the string (its textinput /
-- keypressed routing writes it); this only renders it, keeps the TAIL
-- 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)
Kit._audit("control", x, y, w, h, key)
local isFocused = Kit.focusable(key, x, y, w, h)
Theme.fill(x, y, w, h, PAL.bg, 1)
if isFocused then
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
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 ty = y + (h - Kit.textHeight("button")) / 2
local text = rawText or ""
if text == "" and not focused then
Kit.text("button", Kit.ellipsize("button", placeholder or "", w - 2 * pad),
x + pad, ty, PAL.faint)
else
local shown = Kit.ellipsizeLeft("button", text, w - 2 * pad)
local tw = Kit.text("button", shown, x + pad, ty, PAL.heading)
if focused and (imp.pulse * 2 % 1) < 0.5 then
Theme.fill(x + pad + tw + 2, ty, math.max(1, Kit.scale),
Kit.textHeight("button"), PAL.ink, 1)
end
end
if (Kit.press(x, y, w, h) or Kit._activateId == key) then
local osk = false
if Kit.VirtualKeyboard then
osk = Kit.VirtualKeyboard.open({
text = text,
targetId = key,
title = placeholder or "Enter Text",
onDone = function(newText, confirmed)
if confirmed then
if imp._indexPrompt and key:find("index") then
imp._indexPrompt.text = newText
elseif imp._rename and key:find("rename") then
imp._rename.text = newText
elseif imp._profileRenamePrompt and key:find("profren") then
imp._profileRenamePrompt.text = newText
elseif imp._profileSavePrompt and key:find("profsave") then
imp._profileSavePrompt.text = newText
elseif imp._settingsText and key:find("settext") then
imp._settingsText.text = newText
elseif imp.tab == "find" then
imp.findQuery = newText
if imp._refreshFind then imp:_refreshFind() end
end
end
end
}) and true or false
end
if action and not osk then
queueAction(imp, key, action)
end
end
end
local CART_COLOR = {
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
gold = PAL.railAmber, silver = PAL.railSilver,
crystal = PAL.railCrystal, firered = PAL.railFireRed, leafgreen = PAL.railLeafGreen,
emerald = PAL.railEmerald,
}
local function cartColor(version)
return CART_COLOR[version] or PAL.green
end
local function shellColor(hex)
local r, g, b = tostring(hex):match("^#(%x%x)(%x%x)(%x%x)$")
if not r then return nil end
return { tonumber(r, 16), tonumber(g, 16), tonumber(b, 16) }
end
local STOCK_FINISH = { crystal = "sparkle+holo", emerald = "holo" }
local function finishFlags(name)
name = tostring(name or "")
return name:find("sparkle", 1, true) ~= nil, name:find("holo", 1, true) ~= nil
end
local function cartSkin(imp, version)
local info = GameVersion.info(version) or {}
local shape = imp.cartShape or GameVersion.cartShape(version)
local prefix = shape == "gba" and "gba:" or ""
local shellOverride = shape == "gba"
and shellColor(os.getenv("POKEPORT_CART_SHELL")) or nil
local row = imp.activeCartRow and imp:activeCartRow(version) or nil
local color = shellOverride or shellColor(info.cartShell)
or (shape == "gba" and { 38, 162, 78 }) or cartColor(version)
if not row then
local sparkle, holo = finishFlags(STOCK_FINISH[version])
return { cacheKey = prefix .. version, shape = shape,
color = color,
sparkle = sparkle, holo = holo,
labelPath = info.cartLabel or (shape == "gba"
and "assets/labels/gba-green-blue.png"
or "assets/labels/" .. tostring(version) .. ".png") }
end
local sparkle, holo = finishFlags(row.finish)
return { cacheKey = prefix .. "cart:" .. tostring(row.id), shape = shape,
color = shellOverride or shellColor(row.shell) or color,
sparkle = sparkle, holo = holo,
name = row.title, cart = row, cartId = row.id }
end
local CART_DRAG_SLOP = 8
local TAU = math.pi * 2
local function cartridgeState(imp, key)
imp._cartridge = imp._cartridge or {}
local state = imp._cartridge[key]
if not state then
state = { spin = 0, lastTime = Kit.time }
imp._cartridge[key] = state
end
return state
end
local function cartLabelImage(cartId)
local CartStore = require("src.carts.CartStore")
local got, bytes = pcall(CartStore.labelArt, cartId)
if not got or type(bytes) ~= "string" or bytes == "" then return nil end
if not (love.filesystem and love.filesystem.newFileData) then return nil end
local made, image = pcall(function()
return love.graphics.newImage(
love.filesystem.newFileData(bytes, tostring(cartId) .. ".png"))
end)
if not made then return nil end
return image
end
local function cartridgeLabel(imp, key, path, cartId)
imp._cartridgeLabels = imp._cartridgeLabels or {}
local label = imp._cartridgeLabels[key]
if label ~= nil then return label or nil end
local image
if cartId then
image = cartLabelImage(cartId)
elseif path then
local ok, art = pcall(love.graphics.newImage, path)
if ok then image = art end
end
local sized, iw, ih = false, nil, nil
if image then sized, iw, ih = pcall(image.getDimensions, image) end
if not (sized and type(iw) == "number" and type(ih) == "number"
and iw > 0 and ih > 0) then
imp._cartridgeLabels[key] = false
return nil
end
label = { image = image, width = iw, height = ih }
imp._cartridgeLabels[key] = label
return label
end
local function cartProject(cx, cy, yaw, pitch, x, y, z)
local cyaw, syaw = math.cos(yaw), math.sin(yaw)
local cpitch, spitch = math.cos(pitch), math.sin(pitch)
local rx = x * cyaw + z * syaw
local rz = -x * syaw + z * cyaw
local ry = y * cpitch - rz * spitch
rz = y * spitch + rz * cpitch
local perspective = 620 / (620 - rz)
return cx + rx * perspective, cy + ry * perspective
end
local function cartPolygon(points, color, alpha)
if not love.graphics.polygon then return end
local flat = {}
for i = 1, #points do
flat[#flat + 1], flat[#flat + 2] = points[i][1], points[i][2]
end
Theme.col(color, alpha or 1)
love.graphics.polygon("fill", flat)
end
local function cartQuad(project, x, y, w, h, z)
return {
{ project(x, y, z) }, { project(x + w, y, z) },
{ project(x + w, y + h, z) }, { project(x, y + h, z) },
}
end
local OUTLINE_DIRS = {
{ 1, 0 }, { 0.7071, 0.7071 }, { 0, 1 }, { -0.7071, 0.7071 },
{ -1, 0 }, { -0.7071, -0.7071 }, { 0, -1 }, { 0.7071, -0.7071 },
}
local function hullCross(o, a, b)
return (a[1] - o[1]) * (b[2] - o[2]) - (a[2] - o[2]) * (b[1] - o[1])
end
local function cartHull(quads)
local pts = {}
for i = 1, #quads do
local q = quads[i]
for j = 1, #q do pts[#pts + 1] = q[j] end
end
if #pts < 3 then return nil end
table.sort(pts, function(a, b)
if a[1] == b[1] then return a[2] < b[2] end
return a[1] < b[1]
end)
local hull, k = {}, 0
for i = 1, #pts do
while k >= 2 and hullCross(hull[k - 1], hull[k], pts[i]) <= 0 do k = k - 1 end
k = k + 1
hull[k] = pts[i]
end
local lower = k + 1
for i = #pts - 1, 1, -1 do
while k >= lower and hullCross(hull[k - 1], hull[k], pts[i]) <= 0 do
k = k - 1
end
k = k + 1
hull[k] = pts[i]
end
if k < 4 then return nil end
local flat = {}
for i = 1, k - 1 do
flat[#flat + 1], flat[#flat + 2] = hull[i][1], hull[i][2]
end
return flat
end
local function cartOutline(groups, thickness)
if not (love.graphics.polygon and love.graphics.push) then return end
local hulls = {}
for i = 1, #groups do
hulls[#hulls + 1] = cartHull(groups[i])
end
if #hulls == 0 then return end
Theme.col(PAL.lineStrong, 1)
for i = 1, #OUTLINE_DIRS do
local d = OUTLINE_DIRS[i]
love.graphics.push()
love.graphics.translate(d[1] * thickness, d[2] * thickness)
for j = 1, #hulls do love.graphics.polygon("fill", hulls[j]) end
love.graphics.pop()
end
end
local function cartFacing(points)
local area = 0
for i = 1, #points do
local a, b = points[i], points[i % #points + 1]
area = area + a[1] * b[2] - b[1] * a[2]
end
return area > 0
end
local function cartPill(project, x, y, w, h, z, color, alpha)
local points, radius = {}, h / 2
for i = 0, 10 do
local a = -math.pi / 2 + math.pi * i / 10
points[#points + 1] = { project(x + w - radius + math.cos(a) * radius,
y + radius + math.sin(a) * radius, z) }
end
for i = 0, 10 do
local a = math.pi / 2 + math.pi * i / 10
points[#points + 1] = { project(x + radius + math.cos(a) * radius,
y + radius + math.sin(a) * radius, z) }
end
cartPolygon(points, color, alpha)
end
-- Project rounded corners with the label.
local function cartRoundedQuad(project, x, y, w, h, z, radius)
local points = {}
for corner = 0, 3 do
local cx = (corner == 0 or corner == 3) and w - radius or radius
local cy = corner < 2 and h - radius or radius
for i = 0, 5 do
local angle = (corner + i / 5) * math.pi / 2
local px, py = cx + math.cos(angle) * radius, cy + math.sin(angle) * radius
local sx, sy = project(x + px, y + py, z)
points[#points + 1] = { sx, sy, px / w, py / h }
end
end
return points
end
local function cartLabelMesh(imp, key, label, points)
if not love.graphics.newMesh then return nil end
local corners = { { 0, 0 }, { 1, 0 }, { 1, 1 }, { 0, 1 } }
local vertices = {}
for i, p in ipairs(points) do
vertices[i] = { p[1], p[2], p[3] or corners[i][1],
p[4] or corners[i][2], 255, 255, 255, 255 }
end
imp._cartridgeLabelMeshes = imp._cartridgeLabelMeshes or {}
local mesh = imp._cartridgeLabelMeshes[key]
if not mesh then
mesh = love.graphics.newMesh(vertices, "fan", "dynamic")
imp._cartridgeLabelMeshes[key] = mesh
else
mesh:setVertices(vertices)
end
mesh:setTexture(label.image)
return mesh
end
local CART_HOVER_SHADER = [[
#if defined(GL_ES) && defined(PIXEL) && !defined(GL_FRAGMENT_PRECISION_HIGH)
#define CART_HP mediump
#else
#define CART_HP highp
#endif
varying CART_HP vec2 cart_screen_pos;
#ifdef VERTEX
extern vec2 mouse_screen_pos;
extern float hovering;
extern float screen_scale;
vec4 position(mat4 transform_projection, vec4 vertex_position) {
vec4 clip = transform_projection * vertex_position;
cart_screen_pos = (clip.xy / clip.w * 0.5 + 0.5) * love_ScreenSize.xy;
if (hovering <= 0.) {
return clip;
}
float mid_dist = length(vertex_position.xy - 0.5 * love_ScreenSize.xy)
/ length(love_ScreenSize.xy);
vec2 mouse_offset = (vertex_position.xy - mouse_screen_pos.xy) / screen_scale;
float scale = 0.2 * (-0.03 - 0.3 * max(0., 0.3 - mid_dist))
* hovering * (length(mouse_offset) * length(mouse_offset)) / (2. - mid_dist);
return clip + vec4(0.0, 0.0, 0.0, scale);
}
#endif
#ifdef PIXEL
// finish: 0 plain, 1 sparkle (glitter suspended in the shell), 2 holographic
// label sweep. Both are ours, not ported from anywhere.
extern float finish;
extern float finish_twinkle;
extern float finish_band;
extern float finish_wave;
const float CART_TAU = 6.2831853;
CART_HP float sparkHash(CART_HP vec2 p) {
CART_HP float h = fract(p.x * 0.1031 + p.y * 0.3711 + 0.137);
h = fract(h * (h + 47.13));
h = fract(h * (h + 19.77));
return h;
}
// Flecks live on a jittered lattice so they read as suspended grains rather
// than a regular grid, and each one twinkles on its own phase.
vec3 sparkle(CART_HP vec2 sc) {
CART_HP vec2 cell = sc / 19.0;
CART_HP vec2 id = mod(floor(cell), 512.0);
CART_HP vec2 f = fract(cell);
float peak = 0.0;
for (int oy = -1; oy <= 1; oy++) {
for (int ox = -1; ox <= 1; ox++) {
CART_HP vec2 n = vec2(float(ox), float(oy));
CART_HP vec2 h = vec2(sparkHash(id + n), sparkHash(id + n + 17.0));
CART_HP float phase = sparkHash(id + n + 71.0) * CART_TAU;
float tw = sin(finish_twinkle + phase) * 0.5 + 0.5;
CART_HP float d = length(f - (n + h));
peak = max(peak, (1.0 - smoothstep(0.0, 0.13, d)) * pow(max(tw, 0.0), 16.0));
}
}
return vec3(peak);
}
// Angle-dependent spectral sweep: a diagonal band whose hue walks with view
// angle, so tilting the cart moves the rainbow the way real foil does.
vec3 holo(vec2 uv) {
float band = uv.x * 0.9 + uv.y * 0.6 + finish_band;
vec3 hue = 0.5 + 0.5 * cos(CART_TAU * (band + vec3(0.0, 0.33, 0.67)));
float interference = 0.5 + 0.5 * sin((uv.x - uv.y) * 62.0 + finish_wave);
return hue * (0.55 + 0.45 * interference);
}
vec4 effect(vec4 color, Image tex, vec2 texture_coords, vec2 screen_coords) {
vec4 px = Texel(tex, texture_coords) * color;
if (finish > 1.5) {
vec3 sheen = holo(texture_coords);
float lum = dot(px.rgb, vec3(0.299, 0.587, 0.114));
px.rgb = mix(px.rgb, px.rgb * (0.65 + sheen), 0.30 * (0.35 + 0.65 * lum));
px.rgb += sheen * 0.05 * px.a;
} else if (finish > 0.5) {
px.rgb += sparkle(cart_screen_pos) * 0.70 * px.a;
}
return px;
}
#endif
]]
local cartShaderError = nil
local function cartShaderIsEs()
if not (love.system and love.system.getOS) then return false end
local osName = love.system.getOS()
return osName == "Android" or osName == "iOS"
end
local function cartHoverShader(imp)
if imp._cartHoverShader ~= nil then
return imp._cartHoverShader or nil
end
if not (love.graphics and love.graphics.newShader) then
imp._cartHoverShader = false
return nil
end
local ok, err = true, nil
if love.graphics.validateShader then
ok, err = love.graphics.validateShader(cartShaderIsEs(), CART_HOVER_SHADER)
end
local sh
if ok then
ok, sh = pcall(love.graphics.newShader, CART_HOVER_SHADER)
if not ok then err, sh = sh, nil end
end
if not ok then
cartShaderError = tostring(err)
imp._cartShaderError = cartShaderError
require("src.core.Logger").error("cart shader: %s", cartShaderError)
end
imp._cartHoverShader = ok and sh or false
return imp._cartHoverShader or nil
end
local function cartSendHover(shader, mx, my, hovering, screenScale)
if not shader or not shader.send then return false end
local ok = pcall(shader.send, shader, "mouse_screen_pos", { mx, my })
ok = pcall(shader.send, shader, "hovering", hovering) and ok
ok = pcall(shader.send, shader, "screen_scale", screenScale) and ok
return ok
end
-- FINISH_* match the shader's `finish` uniform.
local FINISH_NONE, FINISH_SPARKLE, FINISH_HOLO = 0, 1, 2
local function cartFinishPhases(spin)
local t = Kit.time or 0
spin = spin or 0
return (t * 2.6 + spin * 3.0) % TAU,
(t * 0.06 + spin * 0.55) % 1.0,
(t * 0.9) % TAU
end
local function cartSendFinish(shader, mode, spin)
if not shader or not shader.send then return end
local twinkle, band, wave = cartFinishPhases(spin)
pcall(shader.send, shader, "finish", mode)
pcall(shader.send, shader, "finish_twinkle", twinkle)
pcall(shader.send, shader, "finish_band", band)
pcall(shader.send, shader, "finish_wave", wave)
end
LauncherView.CART_HOVER_SHADER = CART_HOVER_SHADER
LauncherView.cartShaderError = function() return cartShaderError end
LauncherView.cartFinishPhases = cartFinishPhases
LauncherView.cartHull = cartHull
-- GBA lip and grip recess.
local function drawGbaMolding(project, w, h, z, shell, side)
local highlight = { math.min(255, shell[1] * 1.12),
math.min(255, shell[2] * 1.12), math.min(255, shell[3] * 1.12) }
for i = 0, 27 do
local a, b = i / 28, (i + 1) / 28
local function edge(t, lower)
local arch = math.sin(t * math.pi)
return { project((t - 0.5) * w * 0.76,
h * (-0.355 - arch * (lower and 0.045 or 0.105)), z + 0.5) }
end
cartPolygon({ edge(a, false), edge(b, false), edge(b, true), edge(a, true) },
side, 0.9)
end
-- Rim and rail seams.
cartPolygon(cartQuad(project, -w * 0.45, -h * 0.483,
w * 0.90, h * 0.008, z), highlight, 0.7)
for _, sign in ipairs({ -1, 1 }) do
cartPolygon(cartQuad(project, sign * w * 0.452, -h * 0.30,
w * 0.004, h * 0.76, z), side, 0.5)
end
end
local function cartridgeButton(imp, x, y, w, h, key, skin, action, version)
local state = cartridgeState(imp, skin.cacheKey)
markNoDrag(imp, x, y, w, h)
local focused = Kit.focusable(key, x, y, w, h)
local hot = Kit.hover(x, y, w, h)
local active = state.active
local cx, cy = x + w / 2, y + h / 2
triggerLongPress(imp, x, y, w, h, version)
if Kit.mouseClicked and Kit.hit(x, y, w, h) and not Kit.blockClicks then
if Kit.mouseDown then
state.active = true
state.startX, state.startY = Kit.mouseX, Kit.mouseY
state.lastDragX, state.lastDragY = Kit.mouseX, Kit.mouseY
state.dragged = false
active = true
else
queueAction(imp, key, action)
end
end
if state.active then
active = true
if Kit.mouseDown then
local movedX, movedY = Kit.mouseX - state.startX, Kit.mouseY - state.startY
if movedX * movedX + movedY * movedY > CART_DRAG_SLOP * CART_DRAG_SLOP then
state.dragged = true
end
if state.dragged then
local dragX = Kit.mouseX - (state.lastDragX or Kit.mouseX)
local dragY = Kit.mouseY - (state.lastDragY or Kit.mouseY)
state.spin = state.spin + dragX * 0.018
state.pitchDrag = clamp((state.pitchDrag or 0) + dragY * 0.010,
-1.20, 1.20)
end
state.lastDragX, state.lastDragY = Kit.mouseX, Kit.mouseY
else
if not state.dragged then queueAction(imp, key, action) end
state.active, active = nil, false
state.dragged = nil
end
end
local dt = math.min(0.08, math.max(0, Kit.time - (state.lastTime or Kit.time)))
state.lastTime = Kit.time
if not state.active then
local upright = math.floor(state.spin / TAU + 0.5) * TAU
state.spin = state.spin + (upright - state.spin) * math.min(1, dt * 4)
state.pitchDrag = (state.pitchDrag or 0)
* (1 - math.min(1, dt * 4))
end
local pointerX = clamp((Kit.mouseX - cx) / math.max(1, w / 2), -1, 1)
local pointerY = clamp((Kit.mouseY - cy) / math.max(1, h / 2), -1, 1)
local hoverFx = hot or focused
if hoverFx and not state.wasHot then
state.juiceStart = Kit.time
state.juiceScaleAmt = 0.02
state.juiceRAmt = (math.random() > 0.5 and 1 or -1) * 0.012
state.visScale = 1 - 0.6 * 0.02
end
state.wasHot = hoverFx
local juiceScale, juiceR = 0, 0
if state.juiceStart then
local juiceT = Kit.time - state.juiceStart
if juiceT >= 0.4 then
state.juiceStart = nil
else
local remain = (0.4 - juiceT) / 0.4
juiceScale = state.juiceScaleAmt * math.sin(50.8 * juiceT) * remain ^ 3
juiceR = state.juiceRAmt * math.sin(40.8 * juiceT) * remain ^ 2
end
end
state.visScale = state.visScale or 1
local desScale = (hoverFx and 1.05 or 1) + juiceScale
local ease = math.exp(-60 * dt)
state.visScale = ease * state.visScale + (1 - ease) * desScale
if not state.animId then
local n, s = 0, tostring(skin.cacheKey)
for i = 1, #s do n = n + s:byte(i) * i end
state.animId = n
end
local hoverMx, hoverMy
if hot then
hoverMx, hoverMy = Kit.mouseX, Kit.mouseY
elseif focused then
hoverMx, hoverMy = cx, cy
else
local tiltAngle = Kit.time * (1.56 + (state.animId / 1.14212) % 1)
+ state.animId / 1.35122
hoverMx = x + (0.5 + 0.1 * math.cos(tiltAngle)) * w
hoverMy = y + (0.5 + 0.1 * math.sin(tiltAngle)) * h
end
local pressX = active and pointerX * w * 0.025 or 0
local pressY = active and pointerY * h * 0.018 or 0
local yaw = -0.42 + state.spin
local pitch = 0.14 + (state.pitchDrag or 0)
local pressedScale = (active and 0.965 or 1) * state.visScale
Kit._audit("control", x, y, w, h, key)
local halfW, halfH = w / 2, h / 2
local gba = skin.shape == "gba"
local depth = math.max(gba and 3 or 6, w * (gba and 0.035 or 0.10))
local project = function(px, py, pz)
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
px * pressedScale, py * pressedScale, pz * pressedScale)
end
local shader = cartHoverShader(imp)
local useHover = shader
and cartSendHover(shader, hoverMx, hoverMy, 1,
math.max(1, 0.4 * math.min(w, h)))
if not useHover then
yaw = yaw + (hoverMx - cx) / math.max(1, w / 2) * 0.08
pitch = pitch + (hoverMy - cy) / math.max(1, h / 2) * 0.05
end
love.graphics.push("all")
love.graphics.translate(cx, cy)
love.graphics.rotate(juiceR * 2)
love.graphics.translate(-cx, -cy)
local capH = h * 3 / 65
local mainTop = -halfH + capH
local main, cap = CartShape.outlines(skin.shape)
local function face(outline, z)
local points = {}
for i, p in ipairs(outline) do
points[i] = { project(p[1] * w, p[2] * h, z) }
end
return points
end
local mainFront, mainBack = face(main, depth), face(main, -depth)
local capFront, capBack = face(cap, depth), face(cap, -depth)
if shader then love.graphics.setShader(shader) end
if focused then
cartSendFinish(shader, FINISH_NONE, state.spin)
cartOutline({ { mainFront, mainBack }, { capFront, capBack } },
math.max(2, math.min(w, h) * 0.012))
end
cartSendFinish(shader, skin.sparkle and FINISH_SPARKLE or FINISH_NONE,
state.spin)
local shell = skin.color
local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54),
math.floor(shell[3] * 0.54) }
local frontFacing = cartFacing(mainFront)
if frontFacing then
cartPolygon(mainBack, side, 1)
cartPolygon(capBack, side, 1)
else
cartPolygon(mainFront, shell, 1)
cartPolygon(capFront, shell, 1)
end
local function walls(front, back)
for i = 1, #front do
local j = i % #front + 1
cartPolygon({ front[i], front[j], back[j], back[i] }, side, 1)
end
end
walls(mainFront, mainBack)
walls(capFront, capBack)
if frontFacing then
cartPolygon(mainFront, shell, 1)
cartPolygon(capFront, shell, 1)
else
cartPolygon(mainBack, side, 1)
cartPolygon(capBack, side, 1)
-- The tri-wing security screw: a domed brass head with three teardrop
-- recesses pinwheeled at 120 degrees.
local backZ = -(depth + 0.8)
local sd = math.min(w, h) * 0.11
cartPill(project, -sd * 0.62, -sd * 0.62, sd * 1.24, sd * 1.24, backZ,
{ math.floor(shell[1] * 0.4), math.floor(shell[2] * 0.4),
math.floor(shell[3] * 0.4) }, 0.9)
cartPill(project, -sd / 2, -sd / 2, sd, sd, backZ - 0.4,
{ 196, 186, 148 }, 1)
cartPill(project, -sd * 0.32, -sd * 0.32, sd * 0.64, sd * 0.64,
backZ - 0.6, { 220, 212, 178 }, 0.8)
local r = sd / 2
for k = 0, 2 do
local a = -math.pi / 2 + k * (2 * math.pi / 3)
local ux, uy = math.cos(a), math.sin(a)
local vx, vy = -uy, ux
local r0, r1 = r * 0.16, r * 0.82
local w0, w1 = r * 0.13, r * 0.3
cartPolygon({
{ project(ux * r0 + vx * w0, uy * r0 + vy * w0, backZ - 0.8) },
{ project(ux * r0 - vx * w0, uy * r0 - vy * w0, backZ - 0.8) },
{ project(ux * r1 - vx * w1, uy * r1 - vy * w1, backZ - 0.8) },
{ project(ux * r1 + vx * w1, uy * r1 + vy * w1, backZ - 0.8) },
}, { 112, 104, 76 }, 1)
end
end
if frontFacing then
local faceZ = depth + 0.8
if gba then
drawGbaMolding(project, w, h, faceZ, shell, side)
else
-- GB grip grooves.
local grooveW = w * 0.115
local grooveH = math.max(1, h * 0.009)
local grooveScale = { 1.22, 1.10, 1.00, 1.00, 1.10, 1.22 }
local grooveInset = w * 0.02
for i = 0, 5 do
local ry = mainTop + h * 0.014 + i * h * 0.021
local gw = grooveW * grooveScale[i + 1]
cartPolygon(cartQuad(project, -halfW + grooveInset, ry,
gw, grooveH, faceZ), side, 0.7)
cartPolygon(cartQuad(project, halfW - gw - grooveInset, ry,
gw, grooveH, faceZ), side, 0.7)
end
-- Side ridges.
local function diagonal(x0, y0, x1, y1)
local dx, dy = x1 - x0, y1 - y0
local len = math.sqrt(dx * dx + dy * dy)
local nx, ny = -dy / len, dx / len
local t = math.max(0.6, h * 0.004)
cartPolygon({
{ project(x0 + nx * t, y0 + ny * t, faceZ) },
{ project(x0 - nx * t, y0 - ny * t, faceZ) },
{ project(x1 - nx * t, y1 - ny * t, faceZ) },
{ project(x1 + nx * t, y1 + ny * t, faceZ) },
}, side, 0.7)
end
local dgY = mainTop + h * 0.25
diagonal(-halfW + w * 0.006, dgY, -halfW + w * 0.085, dgY + h * 0.038)
diagonal(halfW - w * 0.006, dgY, halfW - w * 0.085, dgY + h * 0.038)
-- Pill recess.
local pillX, pillW = -halfW + w * 0.19, w * 0.62
local pillY, pillH = mainTop + h * 0.015, h * 0.115
cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55)
local inX, inY = w * 0.008, h * 0.008
cartPill(project, pillX + inX, pillY + inY,
pillW - 2 * inX, pillH - 2 * inY, faceZ + 0.8,
{ math.floor(shell[1] * 0.92), math.floor(shell[2] * 0.92),
math.floor(shell[3] * 0.92) }, 1)
end
local rect = CartShape.labelRect(skin.shape)
local labelX, labelY = rect[1] * w, rect[2] * h
local labelW, labelH = rect[3] * w, rect[4] * h
local labelQuad = gba and cartRoundedQuad or cartQuad
local plate = labelQuad(project, labelX - 2, labelY - 2,
labelW + 4, labelH + 4, faceZ + 0.8, w * 0.018)
cartPolygon(plate, side, 0.95)
local labelPoints = labelQuad(project, labelX, labelY, labelW, labelH,
faceZ + 1.2, w * 0.014)
local label = cartridgeLabel(imp, skin.cacheKey, skin.labelPath, skin.cartId)
if gba then
label = require("src.import.CartLabelArt").label(imp, skin, label)
end
local mesh = label and cartLabelMesh(imp, skin.cacheKey, label, labelPoints)
if skin.holo then
cartSendFinish(shader, FINISH_HOLO, state.spin)
end
if mesh then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(mesh)
elseif label then
local artScale = math.min(labelW / label.width, labelH / label.height)
local labelLeft, labelTop = project(labelX, labelY, faceZ + 1.2)
love.graphics.draw(label.image, labelLeft, labelTop, 0, artScale, artScale)
end
if skin.holo then
cartSendFinish(shader, skin.sparkle and FINISH_SPARKLE or FINISH_NONE,
state.spin)
end
cartPolygon({
{ project(-w * (gba and 0.045 or 0.07), h * (gba and 0.425 or 0.37), faceZ + 1) },
{ project(w * (gba and 0.045 or 0.07), h * (gba and 0.425 or 0.37), faceZ + 1) },
{ project(0, h * (gba and 0.475 or 0.43), faceZ + 1) },
}, side, 0.70)
end
love.graphics.pop()
if not state.active and (Kit._activateId == key) then
queueAction(imp, key, action)
end
end
local function modStatusColor(status)
if status == "ok" then return Strings("Ready"), PAL.green end
if status == "safe_mode" then return Strings("Safe mode"), PAL.yellow end
if status == "needs_import" then return Strings("Import required"), PAL.yellow end
if status == "conflict" then return Strings("Conflict"), PAL.red end
-- a cart pins it, but nothing on this install provides it
if status == "missing" then return Strings("Not installed"), PAL.red end
-- not a fault: the mod is intact, this is simply not a game it is for
-- (src/mods/ModTargets.lua)
if status == "other_game" then return Strings("Not for this game"), PAL.muted end
return Strings("Incompatible"), PAL.yellow
end
-- MODS panel scope row: which game the list is answering for, plus dedicated Profile control (cycle + gear).
local function modScopeOptions(imp)
local GameVersion = require("src.core.GameVersion")
local options = { { id = nil, label = Strings("All games") } }
for _, version in ipairs(SecretGames.order(imp)) do
if imp.ready and imp.ready[version] then
options[#options + 1] =
{ id = version, label = GameVersion.info(version).label }
end
end
return options
end
local function modScopeCurrentLabel(imp, options)
for _, opt in ipairs(options) do
if imp.modScope == opt.id then return opt.label end
end
return options[1] and options[1].label or Strings("All games")
end
local function modScopeChipsWidth(options, gap, m)
local need = 0
for i, opt in ipairs(options) do
need = need + Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
if i < #options then need = need + gap end
end
return need
end
local function profileState(imp)
if imp._profileCache == nil then
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local list, cur = LauncherMods.getProfiles(options)
imp._profileCache = { options = options, list = list, active = cur }
end
return imp._profileCache
end
local function buildModScopeRow(imp, x, y, w, m)
local LauncherMods = require("src.mods.LauncherMods")
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
local gap = math.floor(6 * m.s)
local label = Strings("Show for:")
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
local options = modScopeOptions(imp)
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
local activeProf = profileState(imp).active
local isCompact = (w < math.floor(500 * m.s))
local nameText = tostring(activeProf or "Default")
local profLabel = isCompact and nameText or Strings("Profile: %s", nameText)
local profW = Kit.textWidth("micro", profLabel) + math.floor(20 * m.s)
local gearW = h
local gearX = x + w - gearW
local profX = gearX - profW - math.floor(4 * m.s)
btn(imp, profX, y, profW, h, "mod-scope-profile", profLabel, {
face = "invert", font = "micro",
action = function()
local list, cur = LauncherMods.getProfiles()
local nextIdx = 1
for i, p in ipairs(list) do
if p.name == cur then
nextIdx = (i % #list) + 1
break
end
end
local nextProf = list[nextIdx] and list[nextIdx].name
if nextProf then
LauncherMods.applyProfile(nextProf)
if imp._refreshMods then imp:_refreshMods() end
end
end,
})
btn(imp, gearX, y, gearW, gearW, "mod-profile-gear", "", {
face = "invert", icon = "pencil",
action = function() imp._profilesPopup = true end,
})
if #options >= 2 then
local avail = profX - gap - cx
-- Chips stay when they all fit; otherwise they used to be skipped and
-- vanish off the portrait edge. Collapse to one menu in that case only.
if modScopeChipsWidth(options, gap, m) <= avail then
for _, opt in ipairs(options) do
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
"mod-scope-" .. tostring(opt.id or "all")) then
local want = opt.id
queueAction(imp, "mod-scope-" .. tostring(want or "all"),
function() imp:_setModScope(want) end)
end
cx = cx + cw + gap
end
elseif avail > 0 then
local shown = Kit.ellipsize("micro", modScopeCurrentLabel(imp, options),
math.max(0, avail - math.floor(18 * m.s)))
local cw = math.min(avail,
Kit.textWidth("micro", shown) + math.floor(18 * m.s))
if Kit.chip(cx, y, cw, h, shown, true, PAL.lineStrong, "mod-scope-menu") then
queueAction(imp, "mod-scope-menu",
function() imp._modScopePopup = true end)
end
end
end
return h + math.floor(8 * m.s)
end
-- Says whose mod list is on screen when a cart owns it, and what its seal
-- lets the player do with it.
local function buildModCartRow(imp, x, y, w, m, cartId, report)
if not cartId then return 0 end
local title = (report and report.title) or cartId
local seal = (report and report.seal) or "sealed"
local line
if seal == "sealed+" then
line = Strings("%s pins these mods. You may switch any of them on or off, but not add or remove any.",
title)
elseif seal == "open" then
line = Strings("%s pins these mods. Anything it ships switched off is yours to switch on.",
title)
else
line = Strings("%s is sealed: these are its mods, and they run exactly as pinned. Break the seal on the cart's own page to change that.",
title)
end
local h = Kit.textWrapped("small", line, x, y, w,
seal == "sealed" and PAL.yellow or PAL.blue, 3)
return h + math.floor(8 * m.s)
end
local function buildSaveCartRow(imp, x, y, w, m)
local version = imp.modScope
local h = m.btnH
local gap = math.floor(8 * m.s)
local label = Strings("Save as cart")
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(28 * m.s))
local enabled = version ~= nil and imp:_cartCaptureCount(version) > 0
btn(imp, x, y, bw, h, "mods-save-cart", label, {
kind = "accent", font = "small", enabled = enabled,
action = enabled and function() imp:_beginCartSave(version) end or nil })
local hint
if version == nil then
hint = Strings("Pick one game above to save its enabled mods as a cart.")
elseif not enabled then
hint = Strings("Enable a mod for this game first.")
else
local info = GameVersion.info(version)
hint = Strings("Freeze the mods enabled for %s into a cart.",
(info and (info.launcherName or info.displayName)) or tostring(version))
end
local hx = x + bw + gap
local hw = math.max(0, x + w - hx)
if hw > 0 then
Kit.text("small", Kit.ellipsize("small", hint, hw), hx,
y + (h - Kit.textHeight("small")) / 2, PAL.muted)
end
return h + gap
end
-- The launcher's name for a game id, where all a row has is the id.
local function gameLabel(version)
local info = GameVersion.info(version)
return (info and (info.launcherName or info.displayName)) or tostring(version)
end
local function findActionFor(entry, installedVersion)
local ModIndex = require("src.mods.ModIndex")
if not ModIndex.canInstall(entry) then
return nil, Strings("Not installable from this index")
end
if not installedVersion then return Strings("Install"), nil end
local listed = ModIndex.displayVersion(entry)
local ModUpdate = require("src.mods.ModUpdate")
if type(installedVersion) == "string"
and ModUpdate.isNewer(installedVersion, listed) then
return Strings("Update"), "Installed v" .. installedVersion
end
return Strings("Reinstall"), "Installed v" .. tostring(installedVersion)
end
local function DELETE_LABEL(armed)
return armed and Strings("Sure?") or Strings("Delete")
end
local function deleteArmed(imp, kind, id, version)
local a = imp._confirmDelete
return a ~= nil and a.kind == kind and a.id == id and a.version == version
end
-- Page state lives on the importer keyed by list, so switching tabs and
-- coming back keeps your place -- the one thing scrolling did better.
local function page(imp, key)
return imp._pages[key] or 1
end
local function setPage(imp, key, v)
imp._pages[key] = v
end
local function drawCheck(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
love.graphics.setLineJoin("bevel")
love.graphics.line(
x + size * 0.02, y + size * 0.52,
x + size * 0.38, y + size * 0.80,
x + size * 1.015, y + size * 0.18)
love.graphics.pop()
end
-- ------------------------------------------------------------- header
-- Rail, logo row (settings and quit on the right), tab bar.
-- Returns the y at which content may start. Its vertical arithmetic is
-- mirrored by headerHeight() at the bottom of this file (the short-window
-- scroll decision needs the height before anything draws) -- keep in sync.
-- Header chrome is fixed: the same six tabs, the same gear and Quit, every
-- frame. Their tab rows, opts tables and action closures are built once
-- instead of 60 times a second -- only `active`, `image` and the queued
-- action are written per frame.
-- The four cartridges used to be four tabs of their own. They are one
-- dropdown now: the tab row was seven controls wide and wrapped to two rows on
-- anything narrow, and only ever one game is being looked at.
local GAME_TABS = {
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed,
label = "Red" },
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue,
label = "Blue" },
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold,
label = "Yellow" },
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber,
label = "Gold" },
{ id = "silver", key = "tab-silver", letter = "S", color = PAL.railSilver,
label = "Silver" },
{ id = "crystal", key = "tab-crystal", letter = "C",
color = PAL.railCrystal, label = "Crystal" },
{ id = "firered", key = "tab-firered", letter = "F",
color = PAL.railFireRed, label = "Fire Red" },
{ id = "leafgreen", key = "tab-leafgreen", letter = "L",
color = PAL.railLeafGreen, label = "Leaf Green" },
{ id = "emerald", key = "tab-emerald", letter = "E",
color = PAL.railEmerald, label = "Emerald" },
}
local HEADER_TABS = {
{ id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" },
{ id = "online", key = "tab-online", glyph = true, beta = true },
{ id = "skins", key = "tab-skins", glyph = true, beta = true },
{ id = "importers", key = "tab-importers", glyph = true },
}
LauncherView.HEADER_TABS = HEADER_TABS
local BETA_TAG_OPTS = { fill = true, bold = true, ink = PAL.inverse }
local function drawBetaTag(x, y, w, h)
Kit.tag(x, y, w, h, "BETA", PAL.yellow, BETA_TAG_OPTS)
end
local function overlayBeta(tx, ty, w, tabH, m)
local bh = math.floor(11 * m.s)
local bw = math.min(w, Kit.textWidth("micro", "BETA") + math.floor(10 * m.s))
drawBetaTag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh)
end
local TAB_ICONS = { mods = "puzzle", find = "search", online = "globe",
skins = "paintbrush", importers = "download" }
local TAB_LABELS = { mods = "MODS", find = "FIND", online = "ONLINE",
skins = "SKINS", importers = "IMPORT" }
for _, t in ipairs(HEADER_TABS) do
t.opts = { face = "tab", font = "tab", icon = TAB_ICONS[t.id] }
end
local function headerTabMetrics(m)
local gap = math.floor(6 * m.s)
local width = m.chip
for _, label in pairs(TAB_LABELS) do
width = math.max(width, Kit.textWidth("micro", Strings(label)) + 4)
end
local dropW = width + math.floor(24 * m.s)
local used, rows = dropW, 1
for _ = 1, #HEADER_TABS do
if used + gap + width > m.contentW then rows, used = rows + 1, width
else used = used + gap + width end
end
local labelH = Kit.textHeight("micro") + math.floor(5 * m.s)
return width, dropW, labelH, rows
end
-- Which cartridge the dropdown is showing: the open game tab, else the last
-- one visited, else Red. Kept as a function so the mods/find/skins panels
-- still answer "for which game" without a game tab being open.
local function gameTabs(imp)
SecretGames.update()
local launcher = type(imp) ~= "table" or imp.launcher and true or false
local c = imp and imp._gameTabs
if c and c.rev == SecretGames.rev and c.launcher == launcher then
return c.list, c.colors
end
local list, colors = {}, {}
for _, g in ipairs(GAME_TABS) do
if SecretGames.shown(imp, g.id) then
list[#list + 1] = g
colors[#colors + 1] = g.color
end
end
if type(imp) == "table" then
imp._gameTabs = { rev = SecretGames.rev, launcher = launcher,
list = list, colors = colors }
end
return list, colors
end
local function currentGame(imp)
local tabs = gameTabs(imp)
for _, g in ipairs(tabs) do
if imp.tab == g.id then return g end
end
for _, g in ipairs(tabs) do
if imp.modScope == g.id then return g end
end
return tabs[1]
end
LauncherView.GAME_TABS = GAME_TABS
LauncherView.gameTabs = gameTabs
LauncherView.currentGame = currentGame
-- Keyed off the launcher instance so the closures die with it.
local function headerChrome(imp)
local c = imp._headerChrome
if c then return c end
c = {
gear = { face = "invert", icon = "settings",
action = function() imp:_openSettings() end },
quit = { face = "invert", icon = "x",
action = function() imp:_quitApp() end },
tab = {},
sync = { face = "invert", icon = "arrow-left-right",
action = function() imp:_openSync() end },
game = { face = "tab", font = "tab",
action = function()
local g = currentGame(imp)
if imp.tab == g.id then
imp._gamePopup = true
Kit.setFocus("gamepop-" .. g.id)
else
imp:_switchTab(g.id)
end
end },
}
for _, t in ipairs(HEADER_TABS) do
local id = t.id
c.tab[id] = function() imp:_switchTab(id) end
end
for _, g in ipairs(GAME_TABS) do
local id = g.id
c.tab[id] = function()
imp._gamePopup = nil
imp:_switchTab(id)
end
end
imp._headerChrome = c
return c
end
local function buildHeader(imp, m)
local y = m.top
local _, railColors = gameTabs(imp)
Theme.versionRail(m.x, y, m.w, m.railH, railColors)
y = y + m.railH
-- logo row
local rowH = m.logoH + math.floor(12 * m.s)
local gear = m.chip
-- The wordmark is centred in the row MINUS the right cluster, mirrored on
-- the left so it still reads as centred in the window. Centring it in the
-- FULL row (what this used to do) let a phone-width wordmark run straight
-- under the gear and the quit X -- "the settings is covering the logo".
-- Reserving the space on both sides costs a little width and cannot
-- overlap at any window size.
-- iOS has no quit button (the OS owns app exit), so the cluster is the
-- gear alone and the wordmark gets that width back
local clusterN = imp.ios and 2 or 3
local clusterW = clusterN * gear + (clusterN - 1) * math.floor(6 * m.s) + m.pad
local mobile = not imp.isNX or not m.twoCol
local boxX = mobile and (m.x + m.pad) or (m.x + clusterW)
local boxW = mobile and math.max(0, m.w - clusterW - m.pad)
or math.max(0, m.w - 2 * clusterW)
if imp.logo and boxW > 0 then
local lw, lh = imp.logo:getDimensions()
local maxW = math.min(320 * m.s, boxW)
local scale = math.min(maxW / lw, m.logoH / lh)
local dw, dh = lw * scale, lh * scale
local lx = mobile and boxX or (boxX + (boxW - dw) / 2)
local ly = y + (rowH - dh) / 2
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(imp.logo, Theme.snap(lx), Theme.snap(ly), 0, scale, scale)
local r = imp._logoRect or {}
r.x, r.y, r.w, r.h = lx, ly, dw, dh
imp._logoRect = r
if Kit.press(lx, ly, dw, dh) and imp._logoTap then
queueAction(imp, "logo-tap-" .. tostring(imp._logoTaps or 0),
function() imp:_logoTap() end)
end
end
local rx = m.x + m.w - m.pad
local by = y + (rowH - gear) / 2
-- Switch-only: show the running app version opposite the settings gear so
-- players can confirm which build is on the microSD (OTA / zip updates).
if imp.isNX then
local label = "v" .. tostring(Version.engine or "?")
local tw = Kit.textWidth("small", label)
local padX = math.floor(12 * m.s)
local chipW = math.max(tw + 2 * padX, gear)
local lx = m.x + m.pad
Kit.card(lx, by, chipW, gear, "badge")
local th = Kit.textHeight("small")
Kit.text("small", label, lx + math.floor((chipW - tw) / 2),
by + math.floor((gear - th) / 2), PAL.yellow)
end
-- The right cluster is laid out right to left -- Quit outermost, the gear
-- inboard of it -- but the two are REGISTERED gear first, because the first
-- focusable of the first frame adopts the keyboard ring and that must not be
-- the button that exits the app.
local quitX = not imp.ios and rx - gear or nil
if quitX then rx = quitX - math.floor(6 * m.s) end
-- Settings gear. It now also owns the CONTROL settings (touch overlay
-- editor, reset rebinds), which used to be buttons stacked in the game
-- panel -- see LauncherSettings.coreRows.
rx = rx - gear
local chrome = headerChrome(imp)
btn(imp, rx, by, gear, gear, "gear", "", chrome.gear)
rx = rx - math.floor(6 * m.s) - gear
btn(imp, rx, by, gear, gear, "tab-sync", "", chrome.sync)
do
local eng = imp._sync
if eng and eng.busy and eng:busy() then
Kit.spinner(rx + gear - math.floor(8 * m.s), by + math.floor(8 * m.s),
math.max(2, math.floor(4 * m.s)))
end
end
if quitX then btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit) end
-- The self-update control lives in the FOOTER next to the BCG mark (small,
-- out of the wordmark's way -- it used to overlap the logo on a phone). It
-- still GLOWS through Kit.button when there is something to act on.
y = y + rowH
local tabs = HEADER_TABS
local tabW, dropW, labelH = headerTabMetrics(m)
local tabH = m.chip
local tx = m.x + m.pad
local ty = y + math.floor(6 * m.s)
local tabLeft = tx
local tabRight = m.x + m.w - m.pad
local tabGap = math.floor(6 * m.s)
local tabRowGap = math.floor(4 * m.s)
-- the cartridge dropdown: just the game's initial and the caret; the
-- popup list carries the full names
local chrome0 = headerChrome(imp)
local game = currentGame(imp)
dropW = math.min(tabRight - tabLeft, dropW)
chrome0.game.color = game.color
chrome0.game.letter = game.letter
chrome0.game.letterBold = true
chrome0.game.active = imp.tab == game.id
local gameHot = Kit.hover(tx, ty, dropW, tabH)
local gameDown = gameHot and Kit.mouseDown
local gameInvert = chrome0.game.active
chrome0.game.ring = nil
btn(imp, tx, ty, dropW, tabH, "tab-game", "", chrome0.game)
do
local cw = math.floor(7 * m.s)
local ccx = tx + dropW - math.floor(14 * m.s)
local ccy = ty + tabH / 2 + (gameDown and math.floor(1 * m.s) or 0)
if love.graphics.polygon then
Theme.col(gameInvert and PAL.inverse or PAL.ink, gameDown and 1 or 0.9)
love.graphics.polygon("fill",
ccx - cw, ccy - cw * 0.5, ccx + cw, ccy - cw * 0.5, ccx, ccy + cw * 0.8)
love.graphics.setColor(1, 1, 1, 1)
end
end
Kit.textCenter("micro", Strings("GAMES"), tx, ty + tabH + 3 * m.s, dropW, PAL.muted)
tx = tx + dropW + tabGap
local function headerTab(t)
local w = tabW
if tx > tabLeft and tx + w > tabRight then
tx = tabLeft
ty = ty + tabH + labelH + tabRowGap
end
local o = t.opts
o.active = imp.tab == t.id
o.image = t.icon
o.action = chrome.tab[t.id]
btn(imp, tx, ty, w, tabH, t.key, "", o)
if t.beta then overlayBeta(tx, ty, w, tabH, m) end
Kit.textCenter("micro", Strings(TAB_LABELS[t.id]), tx, ty + tabH + 3 * m.s, w,
o.active and PAL.text or PAL.muted)
tx = tx + w + tabGap
end
for _, t in ipairs(tabs) do headerTab(t) end
-- `ty` has walked down with the wraps, so this stays correct at one row too.
y = ty + tabH + labelH + math.floor(8 * m.s)
Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline)
return y + 1
end
LauncherView.textField = textField
-- The state of the self-updater, shown in the launcher footer.
-- Returns status, label, action, glow.
function LauncherView._updateControl(imp)
if not imp.Check then return nil end
local ok, st = pcall(imp.Check.state)
st = (ok and type(st) == "table") and st or nil
local status = st and st.status or "idle"
if status == "checking" then
return status, Strings("Checking..."), nil, false
elseif status == "downloading" then
local pct = st.progress and math.floor(st.progress * 100) or 0
return status, Strings("Updating %d%%", pct), nil, false
elseif status == "full_downloading" then
local pct = st.progress and math.floor(st.progress * 100) or 0
return status, Strings("Downloading app %d%%", pct), nil, false
elseif status == "available" then
return status, st.latest and (Strings("Update v") .. st.latest)
or Strings("Update"), function() pcall(imp.Check.download) end, true
elseif status == "ready" then
return status, Strings("Restart to update"),
function() require("src.core.HostShell").restart() end, true
elseif status == "needs_full" or status == "full_ready" then
local action = imp.Check.fullUpdateAction and imp.Check.fullUpdateAction()
local label = action and action.label or "Open releases"
local url = action and action.url or imp.Check.releaseUrl()
return status, Strings(label),
function()
if action and action.kind and imp.Check.performFullUpdate then
pcall(imp.Check.performFullUpdate)
else
love.system.openURL(url)
end
end, true
end
-- idle / uptodate / error: offer a manual check, with no glow.
return status, Strings("Check for updates"),
function() pcall(imp.Check.start, true) end, false
end
-- ------------------------------------------------------------ game panel
-- What this version's ROM situation is, as a plain table. The panel and the
-- per-game manage modal both read it, so the two can never disagree about
-- whether a ROM is present or what the import button should say.
-- state a headline, or nil when there is nothing to report (ready)
-- detail the paragraph under it
-- label the import button's caption
-- enabled whether that button may be pressed
-- progress 0-1 while an import for THIS version is running
local function romModel(imp, version, info, ready, locked)
local importLabel = imp.isNX and Strings("Scan again") or Strings("Import ROM")
if locked then
return { state = Strings("Not supported yet"),
detail = Strings("Support for this game is on the way."),
label = Strings("Import unavailable"), enabled = false }
end
local ext = GameVersion.generation(version) == 3 and ".gba" or ".gb/.gbc"
local dropHint = imp.isNX and Strings("Copy the %s via MTP into imports/.", ext)
or (imp.baseRomDiscovery and Strings("Or copy the %s into baseroms/.", ext)
or (imp.android and Strings("Copy the %s via USB.", ext)
or Strings("Or drop the %s file here.", ext)))
local importing = imp.importing == version
local erroring = imp.workState == "error" and imp.errorVersion == version
local notice = imp.notice and imp.notice.version == version and imp.notice
local baseRom = imp.baseRoms and imp.baseRoms[version]
local scanning = imp.baseRomDiscovery and imp.baseRomScan
and imp.baseRomScan.state ~= "done"
if importing and (imp.workState == "working" or imp.workState == "complete") then
return { state = imp.status or Strings("Importing"),
detail = imp.detail or "", progress = imp.progress or 0 }
elseif erroring then
-- An import that FAILED is reported even on a ready game (a re-import
-- that could not read the new file): the failure is the only reason the
-- library still holds the old cache, and it must not be silent (the
-- "Import failed with no explanation" report).
return { state = Strings("Import failed"),
detail = imp.detail or Strings("That ROM could not be imported."),
label = importLabel, enabled = true }
elseif ready then
return { label = Strings("Re-import ROM"), enabled = true }
elseif notice then
return { state = Strings("No ROM imported"),
detail = ((notice.status or "") .. " " .. (notice.detail or ""))
:gsub("^%s+", ""):gsub("%s+$", ""),
label = importLabel, enabled = true }
elseif baseRom then
return { state = Strings("Compatible ROM found"),
detail = Strings("Found in baseroms/: %s", baseRom.name),
label = Strings("Import detected ROM"), enabled = true }
elseif scanning then
return { state = Strings("Checking baseroms..."),
detail = Strings("Looking for compatible Red, Blue, and Yellow ROMs."),
label = Strings("Import ROM"), enabled = false }
elseif imp.returning[version] then
return { state = Strings("Update required"),
detail = Strings("This build needs a few more things from your ")
.. info.label .. Strings(" ROM. Re-import to continue."),
label = Strings("Re-import ROM"), enabled = true }
end
return { state = Strings("No ROM imported"),
detail = Strings("The ROM is verified before any files are created. ")
.. dropHint,
label = importLabel, enabled = true }
end
-- The import action behind whichever button carries it.
local function romAction(imp, version, mdl)
if not mdl.enabled then return nil end
return function()
if imp.ready[version] then imp:reimport(version)
else imp:choose(version) end
end
end
-- The ROM card: the state headline, its paragraph, and the Import button.
-- It exists ONLY while there is something to report -- a game with a verified
-- ROM shows Play, not a card of file management (that moved behind the manage
-- button next to Play, and the save file controls moved into the slot card).
-- Returns the height it consumed, 0 when it drew nothing.
local function buildRomCard(imp, x, y, w, m, version, mdl, maxH)
if not (mdl.state or mdl.progress) then return 0 end
local pad = math.floor(14 * m.s)
local iw = w - 2 * pad
local lineH = Kit.textHeight("small")
local hasButton = mdl.progress == nil and mdl.label ~= nil
-- Pads and the button are fixed furniture that always fits; the detail
-- paragraph is the elastic part and gets trimmed to whatever lines the
-- budget leaves. Without that trim the card overflowed and got clipped
-- mid-button, which is the failure a no-scroll layout must design out.
local fixedH = pad + Kit.textHeight("button") + math.floor(4 * m.s)
+ math.floor(10 * m.s)
+ ((hasButton or mdl.progress) and (m.btnH + math.floor(2 * m.s)) or 0)
+ pad
local detailLines = 3
if maxH then
detailLines = math.max(0,
math.min(detailLines, math.floor((maxH - fixedH) / lineH)))
end
local detailH = Kit.wrapHeight("small", mdl.detail or "", iw, detailLines)
local h = fixedH + detailH
Kit.card(x, y, w, h)
local cy = y + pad
Kit.text("button", Kit.ellipsize("button", mdl.state or "", iw), x + pad, cy,
PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
cy = cy + Kit.textWrapped("small", mdl.detail or "", x + pad, cy, iw,
PAL.detail, detailLines)
cy = cy + math.floor(10 * m.s)
if mdl.progress ~= nil then
Kit.progress(x + pad, cy + (m.btnH - math.floor(10 * m.s)) / 2, iw,
math.floor(10 * m.s), mdl.progress)
elseif hasButton then
btn(imp, x + pad, cy, iw, m.btnH, "rom-" .. version, mdl.label, {
kind = "accent", enabled = mdl.enabled,
action = romAction(imp, version, mdl),
})
end
return h
end
-- Width of a small text action, including horizontal padding.
local function chipWidth(label, m)
return Kit.textWidth("small", label) + math.ceil(2 * Theme.BUTTON.labelPad * Kit.scale) + 2
end
local function slotMeta(slot)
local text = Strings("empty slot")
if slot.exists and slot.meta then
text = Strings("%d badges - %s - %d caught", slot.meta.badges or 0,
slot.meta.timeText or "0:00", slot.meta.dexCount or 0)
end
if slot.sealBroken then text = Strings("%s - seal broken", text) end
return text
end
local function saveActions(imp, scope, version, slot)
local key = "slot-" .. scope .. "-" .. slot.id
local actions = {}
if slot.exists then
actions[#actions + 1] = { label = Strings("Export"), icon = "upload", key = key .. "-export",
action = function()
imp._saveExport = { scope = scope, version = version, slotId = slot.id,
label = slot.label or slot.name or Strings("NEW GAME") }
end }
end
if not imp.android then
actions[#actions + 1] = { label = Strings("Rename"), icon = "pencil", key = key .. "-rename",
action = function() imp:_beginRename(scope, slot.id) end }
end
if imp.onEditSave and slot.exists and scope == version then
actions[#actions + 1] = { label = Strings("Edit save"), icon = "file-pen-line", key = key .. "-edit",
action = function() imp.onEditSave(version, slot.id) end }
end
actions[#actions + 1] = {
label = DELETE_LABEL(deleteArmed(imp, "slot", slot.id, scope)),
key = key .. "-del", kind = "danger", icon = "trash", keepArm = true,
action = function()
imp:pressDelete("slot", slot.id, scope, function() imp:_deleteSlot(scope, slot.id) end)
end,
}
return actions
end
local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
local scope = imp.slotScope and imp:slotScope(version) or version
imp:_ensureSlots(scope)
local slots = imp.slots[scope] or {}
local active = imp.activeSlot[scope]
local slot
for _, entry in ipairs(slots) do if entry.id == active then slot = entry break end end
local pad, gap = math.floor(14 * m.s), math.floor(8 * m.s)
local iw, bh = w - 2 * pad, m.btnH
local importLabel = imp.isNX and Strings("Scan again") or Strings("Import")
local browseLabel = Strings("Other saves (%d)", #slots)
local iconExtra = math.floor(bh * 0.42) + math.floor(7 * Kit.scale)
local importW = chipWidth(importLabel, m) + iconExtra
local browseW = chipWidth(browseLabel, m) + iconExtra
if importW + browseW + gap > iw then
browseLabel = Strings("Other saves")
browseW = chipWidth(browseLabel, m) + iconExtra
end
local capH = Kit.textHeight("button")
local headerInline = Kit.textWidth("button", Strings("SAVE")) + gap * 2 + importW + browseW <= iw
local headerCols = importW + gap + browseW <= iw and 2 or 1
local headH = headerInline and bh or capH + gap + bh * (headerCols == 2 and 1 or 2)
+ (headerCols == 1 and gap or 0)
local inner = iw - 2 * gap
local actions = slot and saveActions(imp, scope, version, slot) or {}
local actionMin = 0
for _, action in ipairs(actions) do
actionMin = math.max(actionMin, chipWidth(action.label, m) + iconExtra)
end
actionMin = math.max(actionMin, chipWidth(DELETE_LABEL(true), m) + iconExtra)
local cols = inner >= 2 * actionMin + gap and 2 or 1
local actionRows = math.ceil(#actions / cols)
local actionH = actionRows * bh + math.max(0, actionRows - 1) * gap
local side = slot and inner >= math.floor(400 * m.s)
local actionW = side and math.max(2 * actionMin + gap, math.floor(inner * 0.46)) or inner
local textW = side and inner - actionW - gap or inner
local hasStats = slot and slot.exists and slot.meta
local metaH = hasStats and Kit.textHeight("button") + math.floor(3 * m.s) + Kit.textHeight("small")
or slot and Kit.wrapHeight("small", slotMeta(slot), textW) or 0
if hasStats and slot.sealBroken then metaH = metaH + gap + Kit.textHeight("small") end
local textH = Kit.textHeight("button") + gap + metaH
local rowH = slot and (2 * gap + (side and math.max(textH, actionH) or textH + gap + actionH))
or math.max(math.floor(70 * m.s), Kit.wrapHeight("small",
Strings("No saves yet - start a new game or import one."), inner) + 2 * gap)
local notice = imp.saveNotice[scope]
local noticeH = notice and (Kit.wrapHeight("small", notice.text, iw, 2) + gap) or 0
if notice and notice.dir then noticeH = noticeH + bh + gap end
local h = 2 * pad + headH + gap + rowH + gap + noticeH + bh
Kit.card(x, y, w, h)
local px, cy = x + pad, y + pad
Kit.textBold("button", Strings("SAVE"), px, cy + (headerInline and (bh - capH) / 2 or 0), PAL.heading)
local hy = headerInline and cy or cy + capH + gap
local hx = headerInline and x + w - pad - importW - gap - browseW or px
local firstW = headerInline and importW or (headerCols == 2 and importW or iw)
btn(imp, hx, hy, firstW, bh, "sav-import-" .. scope, importLabel, {
font = "small", icon = "download", enabled = ready and scope == version,
action = function() imp:chooseSaveImport(version) end,
})
local bx = headerCols == 2 and hx + firstW + gap or px
local by = headerCols == 2 and hy or hy + bh + gap
local bw = headerInline and browseW or (headerCols == 2 and iw - firstW - gap or iw)
btn(imp, bx, by, bw, bh, "sav-browse-" .. scope, browseLabel, {
font = "small", icon = "folder", enabled = #slots > 0,
action = function() imp._savePicker = { scope = scope, version = version, scroll = 0 } end,
})
cy = cy + headH + gap
Kit.card(px, cy, iw, rowH, "row")
if slot then
Theme.strokeRounded(px, cy, iw, rowH, PAL.green, 0.65, 1, Theme.cardRadius())
local tx, ty = px + gap, cy + gap
local tagW = chipWidth(Strings("LOADED"), m)
local titleW = math.max(0, textW - tagW - gap)
Kit.textBold("button", Kit.ellipsize("button", slot.label or slot.name or Strings("NEW GAME"), titleW),
tx, ty, PAL.heading)
Kit.tag(tx + textW - tagW, ty, tagW, Kit.textHeight("button"), Strings("LOADED"), PAL.green,
{ fill = true, ink = PAL.inverse })
local my = ty + Kit.textHeight("button") + gap
if hasStats then
local values = { tostring(slot.meta.badges or 0), slot.meta.timeText or "0:00", tostring(slot.meta.dexCount or 0) }
local labels = { Strings("badges"), Strings("played"), Strings("caught") }
local statW = textW / 3
for i = 1, 3 do
local sx = tx + (i - 1) * statW
Kit.textCenter("button", Kit.ellipsize("button", values[i], statW - gap), sx, my, statW, PAL.heading)
Kit.textCenter("small", Kit.ellipsize("small", labels[i], statW - gap), sx,
my + Kit.textHeight("button") + math.floor(3 * m.s), statW, PAL.muted)
if i > 1 then Theme.fill(sx, my + 3 * m.s, 1, metaH - 6 * m.s, PAL.line, 0.35) end
end
if slot.sealBroken then
Kit.text("small", Strings("seal broken"), tx, my + metaH - Kit.textHeight("small"), PAL.yellow)
end
else
Kit.textWrapped("small", slotMeta(slot), tx, my, textW, PAL.detail)
end
local ax = side and px + iw - gap - actionW or tx
local ay = side and cy + gap or ty + textH + gap
local cw = (actionW - (cols - 1) * gap) / cols
for i, action in ipairs(actions) do
btn(imp, ax + ((i - 1) % cols) * (cw + gap), ay + math.floor((i - 1) / cols) * (bh + gap),
cw, bh, action.key, action.label, {
kind = action.kind, icon = action.icon, font = "small", keepArm = action.keepArm, action = action.action,
})
end
else
Kit.textWrapped("small", #slots > 0 and Strings("Choose a save from Other saves.")
or Strings("No saves yet - start a new game or import one."), px + gap, cy + gap, inner, PAL.muted)
end
cy = cy + rowH + gap
if notice then
cy = cy + Kit.textWrapped("small", notice.text, px, cy, iw, notice.ok and PAL.green or PAL.red, 2) + gap
if notice.dir then
btn(imp, px, cy, iw, bh, "sav-folder-" .. scope, Strings("Open folder"), {
font = "small", action = function() love.system.openURL(imp:fileUrl(notice.dir)) end,
})
cy = cy + bh + gap
end
end
btn(imp, px, cy, iw, bh, "slot-new-" .. scope, Strings("+ New save slot"), {
kind = "good", action = function() imp:_newSlot(scope) end,
})
return h
end
local function SEAL_LABEL(armed)
return armed and Strings("Break it") or Strings("Break the seal")
end
local function FILL_LABEL(count)
if count > 1 then return Strings("Install %d required mods", count) end
return Strings("Install required mods")
end
local function sealSlotName(slot)
if not slot then return nil end
if type(slot.label) == "string" and slot.label ~= "" then return slot.label end
return tostring(slot.id):match("^slot(%d+)$") or tostring(slot.id)
end
local function buildCartCard(imp, x, y, w, m, version)
if not imp.cartPlan then return 0 end
local report, slot = imp:cartPlan(version)
if not report then return 0 end
local title = tostring(report.title or report.id or "")
local broken = (slot and slot.sealBroken == true) or false
local fillCount = imp.cartFillRows and #imp:cartFillRows(version) or 0
local state, stateCol, body = nil, PAL.green, {}
if report.refused then
state, stateCol = Strings("This cart will not start"), PAL.red
body[#body + 1] = { report.message, PAL.detail }
if fillCount > 0 then
body[#body + 1] = { Strings("Install the mods it pins to play it the way its author built it."),
PAL.detail }
end
body[#body + 1] = { Strings("Break the seal to play it with the mods you have."),
PAL.detail }
elseif broken then
state, stateCol = Strings("Seal broken"), PAL.yellow
body[#body + 1] = { Strings("This save loads the cart's pinned mods first, then your other enabled mods. It is marked modified."),
PAL.detail }
elseif report.seal == "sealed+" then
state = Strings("Sealed - ready to play")
body[#body + 1] = { Strings("This cart loads only the mods it pins. You can switch any of them on or off."),
PAL.detail }
elseif report.sealed then
state = Strings("Sealed - ready to play")
body[#body + 1] = { Strings("This cart loads only the mods it pins."),
PAL.detail }
else
state = Strings("Open cart - ready to play")
body[#body + 1] = { Strings("This cart's pinned mods load first, then your other enabled mods."),
PAL.detail }
end
local scope = imp:slotScope(version)
local offer = report.sealed and not broken
local armed = offer and deleteArmed(imp, "seal", slot and slot.id or nil, scope)
if armed then
local name = sealSlotName(slot)
body[#body + 1] = { name
and Strings("Break the seal on %s, save slot %s?", title, name)
or Strings("Break the seal on %s, on a new save slot?", title),
PAL.yellow }
body[#body + 1] = { Strings("This is permanent and cannot be undone. That save is marked modified from then on."),
PAL.yellow }
body[#body + 1] = { Strings("%s still loads its pinned mods first, with your other enabled mods on top.", title),
PAL.yellow }
body[#body + 1] = { Strings("Press Break it again to do it."), PAL.yellow }
end
-- What the last install run managed, and per mod what it could not.
local fillNotice = imp.cartFillNotice
if fillNotice then
body[#body + 1] = { fillNotice.text,
fillNotice.ok and PAL.green or PAL.red }
for _, line in ipairs(fillNotice.failures or {}) do
body[#body + 1] = { line, PAL.red }
end
end
local pad = math.floor(14 * m.s)
local iw = w - 2 * pad
local fillLabel = FILL_LABEL(fillCount)
local chipGap = math.floor(8 * m.s)
local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local fillW = fillCount > 0
and math.min(iw, chipWidth(fillLabel, m)) or 0
local sealW = offer and math.min(iw, math.max(chipWidth(SEAL_LABEL(false), m),
chipWidth(SEAL_LABEL(true), m))) or 0
-- Both chips share a row when they fit; a narrow card stacks them instead.
local sideBySide = fillW > 0 and sealW > 0
and (fillW + chipGap + sealW) <= iw
local chipRows = 0
if fillW > 0 then chipRows = chipRows + 1 end
if sealW > 0 then chipRows = chipRows + 1 end
if sideBySide then chipRows = 1 end
if chipRows == 0 then chipH = 0 end
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
for _, line in ipairs(body) do
h = h + Kit.wrapHeight("small", line[1] or "", iw, 3)
end
if chipRows > 0 then
h = h + math.floor(8 * m.s) + chipRows * chipH + (chipRows - 1) * chipGap
end
h = h + pad
Kit.card(x, y, w, h)
local cy = y + pad
Kit.text("button", Kit.ellipsize("button", state, iw), x + pad, cy, stateCol)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
for _, line in ipairs(body) do
cy = cy + Kit.textWrapped("small", line[1] or "", x + pad, cy, iw,
line[2], 3)
end
if chipRows > 0 then
cy = cy + math.floor(8 * m.s)
local bx = x + pad
if fillW > 0 then
btn(imp, bx, cy, fillW, chipH, "cart-fill-" .. scope, fillLabel, {
kind = "primary", font = "small",
action = function() imp:pressInstallCartMods(version) end,
})
if sideBySide then bx = bx + fillW + chipGap
else cy = cy + chipH + chipGap end
end
if sealW > 0 then
btn(imp, bx, cy, sealW, chipH, "seal-" .. scope, SEAL_LABEL(armed), {
kind = "danger", font = "small", keepArm = true,
action = function() imp:pressBreakSeal(version) end,
})
end
end
return h
end
local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH)
imp.panelVersion = version
local info = GameVersion.info(version)
local locked = info == nil
local skin = cartSkin(imp, version)
local gameName = skin.name or (info and (info.launcherName or info.displayName))
or tostring(version)
local ready = (not locked) and imp.ready[version] or false
local gap = math.floor(8 * m.s)
local titleH = Kit.textHeight("title")
local tagH = Kit.textHeight("micro") + math.floor(10 * m.s)
local betaW = info and info.beta and (Kit.textWidth("micro", "BETA") + 12 * m.s) or 0
local status = ready and Strings("Ready")
or imp.baseRoms and imp.baseRoms[version] and Strings("ROM FOUND")
or locked and Strings("COMING SOON") or Strings("ROM REQUIRED")
local statusColor = ready and PAL.green or locked and PAL.steel or PAL.yellow
local statusW = Kit.textWidth("micro", status) + 18 * m.s
local nameW = Kit.textWidth("title", gameName)
local inline = nameW + betaW + statusW + 4 * gap + 150 * m.s <= w
local titleW = inline and w - betaW - statusW - 4 * gap - 150 * m.s
or w - (betaW > 0 and betaW + gap or 0)
local title = Kit.ellipsize("title", gameName, titleW)
Kit.textBold("title", title, x, y, PAL.heading)
local tx = x + Kit.textWidth("title", title) + gap
if betaW > 0 then
drawBetaTag(tx, y + (titleH - tagH) / 2, betaW, tagH)
tx = tx + betaW + gap
end
local sy = y + (titleH - tagH) / 2
if not inline then tx, sy = x, y + titleH + math.floor(4 * m.s) end
Kit.tag(tx, sy, statusW, tagH, status, statusColor)
if ready then
local hx = tx + statusW + gap
Kit.text("micro", Kit.ellipsize("micro", Strings("(PRESS THE CART TO PLAY)"), math.max(0, x + w - hx)),
hx, sy + (tagH - Kit.textHeight("micro")) / 2, PAL.muted)
end
if not inline then titleH = titleH + math.floor(4 * m.s) + tagH end
-- Extra gap under the title when the cart is showing: 12px left the 3D
-- shell sitting on the hairline. Scaled, and still small on a phone.
local afterTitle = math.floor((ready and 22 or 12) * m.s)
local cy = y + titleH + afterTitle
local remaining = availH - (titleH + afterTitle)
local budgetLeft = math.max(remaining,
(budgetH or availH) - (titleH + afterTitle))
local gap = m.gap
local lx, lw, rx2, rw
if m.twoCol then
local colW = math.floor((w - m.colGap) / 2)
lx, lw = x, colW
rx2, rw = x + colW + m.colGap, colW
else
lx, lw, rx2, rw = x, w, x, w
end
-- LEFT COLUMN, laid out DOWNWARD from the top. It used to pin Play and a
-- Touch-Controls/Reset-rebinds pair to the BOTTOM and fill the cards
-- downward into whatever was left, which meant the column's height was
-- whatever its text happened to need -- and on any window shorter than
-- that pile the pinned block simply left the window (Play was measurably
-- off-screen at 1280x720 and on every phone shape). The controls pair has
-- moved behind the gear (they are global settings, not per-game), the ROM
-- and save file management moved into the manage modal and the slot card,
-- and what is left is short enough to lay out top-down and always fit.
local mdl = romModel(imp, version, info, ready, locked)
local ly = cy
if ready then
-- Preserve shell proportions.
local playH = math.floor(clamp(remaining * 0.52, 112 * m.s, 260 * m.s))
local mgW = math.max(Kit.tapMin(), math.floor(34 * m.s))
local bgap = math.floor(8 * m.s)
local cartAreaW = lw - mgW - bgap
local cartH = playH
local cartW
if skin.shape == "gba" then
-- GBA carts are wider (aspect 1.74:1) but have a smaller physical footprint than GB carts.
-- Scale height to ~62% of column height budget so visual mass is balanced and doesn't overwhelm the column.
local targetH = playH * 0.62
cartW = math.min(cartAreaW * 0.72, targetH * CartShape.GBA_ASPECT)
cartH = cartW / CartShape.GBA_ASPECT
ly = ly + math.floor((playH - cartH) * 0.35)
else
cartW = math.min(cartAreaW, math.floor(playH * 0.88))
end
local cartX = lx + math.floor((cartAreaW - cartW) / 2)
cartridgeButton(imp, cartX, ly, cartW, cartH, "play-" .. version,
skin, function() imp:play(version, true) end, version)
btn(imp, lx + lw - mgW, ly, mgW, mgW, "manage-" .. version, "", {
face = "invert", icon = "pencil",
action = function() imp._gameManage = version end,
})
ly = ly + cartH + gap
btn(imp, lx, ly, lw, m.btnH, "carts-" .. version,
Strings("Custom Carts"), {
kind = "accent", font = "small",
action = function()
imp._cartPopup = version
imp._cartNotice = nil
end,
})
ly = ly + m.btnH + gap
local sealH = buildCartCard(imp, lx, ly, lw, m, version)
if sealH > 0 then ly = ly + sealH + gap end
end
-- The ROM card, which now only exists while there is something to report:
-- no ROM, a failed import, an import in flight, or an unsupported game.
local romH = buildRomCard(imp, lx, ly, lw, m, version, mdl,
m.twoCol and remaining or math.floor(remaining * 0.5))
if romH > 0 then ly = ly + romH + gap end
-- Save slots. Two columns put them beside the left stack; ONE column
-- stacks them underneath. Either way the card is clipped to the room it
-- actually has, and sizes its own list to that budget.
local bottom = ly
if not locked then
local slotY = m.twoCol and cy or ly
local slotAvail = m.twoCol and budgetLeft or (cy + budgetLeft - ly)
local slotH = buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version, ready)
bottom = math.max(bottom, slotY + (slotH or 0))
end
return bottom - y
end
-- --------------------------------------------------------------- mods panel
-- One line of { text, color } segments, ellipsized as a whole: each segment
-- gets whatever width the previous ones left, and the first segment that has
-- to ellipsize ends the line. Lets the download count sit green inside an
-- otherwise muted stats line without two competing ellipsis passes.
-- A row's control key is a pure function of its id, but concatenating it per
-- visible row per frame is ~1200 strings a second. Memoised on the launcher,
-- NOT on the entry: index entries are the same tables ModIndex.writeCache
-- persists into options.modIndexCache, and view state must not ride along.
local function rowKeyFor(imp, prefix, id)
local keys = imp._rowKeys
if not keys then keys = {}; imp._rowKeys = keys end
local byPrefix = keys[prefix]
if not byPrefix then byPrefix = {}; keys[prefix] = byPrefix end
local key = byPrefix[id]
if not key then key = prefix .. tostring(id); byPrefix[id] = key end
return key
end
local function segLine(fontName, segs, x, y, maxW)
local sx = x
for _, seg in ipairs(segs) do
local text = seg[1]
local avail = maxW - (sx - x)
if avail <= 0 then break end
local shown = Kit.ellipsize(fontName, text, avail)
Kit.text(fontName, shown, sx, y, seg[2])
if shown ~= text then break end
sx = sx + Kit.textWidth(fontName, text)
end
end
-- The persisted sort choice both mod panels share. The chooser itself is a
-- popup (buildSortModal); panels just read the current key and offer a
-- "Sort" button, which is what freed the chip row's two lines of space.
local function sortDefs(scope)
local defs = {
{ key = "name", label = Strings("Name") },
{ key = "popularity", label = Strings("Most downloaded") },
}
if scope == "find" then
defs[#defs + 1] = { key = "trending", label = Strings("Trending") }
end
defs[#defs + 1] = { key = "release", label = Strings("Release date") }
defs[#defs + 1] = { key = "updated", label = Strings("Last updated") }
if scope == "mods" then
defs[#defs + 1] = { key = "order", label = Strings("Load order") }
end
return defs
end
-- Sorting is decorate-sort-undecorate: the key is computed once per entry
-- instead of the 2*n*log(n) times a comparator that derives it would, and the
-- comparator itself is a module-level function so no closure is allocated per
-- comparison. Measured on a synthetic index: 500 entries went from 8,964 key
-- computations and 4,482 closures to 500 and none.
local sortAsc = true
local function decCompare(a, b)
if a.k ~= b.k then
if sortAsc then return a.k < b.k end
return a.k > b.k -- data sorts newest / most popular first
end
return a.tie < b.tie
end
-- Fill `scratch` with one { e, k, tie } slot per entry, reusing the slots.
local function decorate(scratch, src, keyOf, tieOf)
local n = #src
for i = 1, n do
local e = src[i]
local slot = scratch[i]
if not slot then slot = {}; scratch[i] = slot end
slot.e, slot.tie = e, tieOf(e)
slot.k = keyOf(e, slot.tie)
end
for i = #scratch, n + 1, -1 do scratch[i] = nil end
return n
end
local function undecorate(scratch, n)
local out = {}
for i = 1, n do out[i] = scratch[i].e end
return out
end
-- While results are still streaming in, re-ordering on every arrival re-sorts
-- the whole list every frame and makes rows jump under the reader. Hold the
-- current order this long and take the change in one pass.
local RESORT_DEBOUNCE = 0.25
-- True when the cached order is still good. `rev` is only part of the key
-- for a stats-dependent sort: Name order does not depend on release data, so
-- a stats arrival used to invalidate a sort whose result could not change.
local function sortCacheOk(cache, src, key, rev, pending)
if not (cache and cache.src == src and cache.key == key) then return false end
if cache.rev == rev then return true end
return pending and (Kit.time - (cache.at or 0)) < RESORT_DEBOUNCE
end
local function currentSort(imp, scope)
local sortKey = imp.modSort
if sortKey == nil then
local ok, opts = pcall(require("src.core.SaveData").loadOptions)
if ok and type(opts) == "table" and type(opts.modSort) == "string" then
sortKey = opts.modSort
end
sortKey = sortKey or "popularity"
imp.modSort = sortKey
end
if sortKey == "trending" and scope ~= "find" then return "popularity" end
if sortKey == "order" and scope ~= "mods" then return "popularity" end
return sortKey
end
-- One compact coloured checkbox for each game. The cartridge colour carries
-- the game identity even when the row is narrow.
local function cartsWithUpdates(imp)
if not imp._updateAllCartRows then return {} end
local ok, rows = pcall(imp._updateAllCartRows, imp)
return (ok and type(rows) == "table") and rows or {}
end
local function updateAllRows(imp)
if not imp._updateAllRows then return {} end
local ok, rows = pcall(imp._updateAllRows, imp)
return (ok and type(rows) == "table") and rows or {}
end
local function modsWithUpdatesCount(imp)
local mods = imp.mods or {}
local rev = imp._modUpdateRev or 0
local cartCache = imp._cartUpdateCache
local cache = imp._modUpdateCountCache
if cache and cache.src == mods and cache.n == #mods and cache.rev == rev
and cartCache and cache.carts == cartCache.rows then
return cache.count
end
local count = #updateAllRows(imp)
cartCache = imp._cartUpdateCache
imp._modUpdateCountCache = { src = mods, n = #mods, rev = rev,
carts = cartCache and cartCache.rows, count = count }
return count
end
local function askUpdateAllMods(imp)
imp:pressUpdateAllMods()
end
local function buildModsPanel(imp, x, y, w, availH, m)
imp:_ensureMods()
local ModUpdate = require("src.mods.ModUpdate")
local safeMode = imp.safeMode == true
local mods = imp.mods or {}
local gap = m.gap
local cy = y
local cartId, cartReport
if imp.modCartPlan then cartId, cartReport = imp:modCartPlan() end
-- a cart owns its mod set: only the pins it already ships may be switched
local bulkOk = not safeMode and cartId == nil
local updateOk = (imp.updateAllAvailable and imp:updateAllAvailable()) == true
-- header: progressive action cluster. Surfaces primary/frequent actions
-- (Import, Updates, Sort) directly on the bar across screen sizes, placing
-- bulk actions (Enable all / Disable all) into More... on compact viewports.
local bh = m.btnH
local importLabel = imp:_modsImportButtonLabel()
local importW = Kit.textWidth("small", importLabel) + math.floor(24 * m.s)
if #mods > 0 then
local disableW = Kit.textWidth("small", Strings("Disable all")) + math.floor(20 * m.s)
local enableW = Kit.textWidth("small", Strings("Enable all")) + math.floor(20 * m.s)
local checkFullW = Kit.textWidth("small", Strings("Check for updates")) + math.floor(20 * m.s)
local checkShortW = Kit.textWidth("small", Strings("Updates")) + math.floor(20 * m.s)
local updateAllW = Kit.textWidth("small", Strings("Update all")) + math.floor(20 * m.s)
local sortW = Kit.textWidth("small", Strings("Sort")) + math.floor(24 * m.s)
local moreW = Kit.textWidth("small", Strings("More...")) + math.floor(20 * m.s)
local fullReq = importW + disableW + enableW + checkFullW + updateAllW
+ sortW + math.floor(36 * m.s)
local medReq = importW + checkShortW + sortW + moreW + math.floor(24 * m.s)
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
if fullReq <= w then
-- Tier 1 (Desktop / Wide): Show all 5 full-text buttons
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), {
kind = "warn", font = "small",
enabled = bulkOk,
action = function() imp:_setAllMods(false) end })
btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), {
kind = "good", font = "small",
enabled = bulkOk,
action = function() imp:_setAllMods(true) end })
btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), {
font = "small",
action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(updateAllW), cy, updateAllW, bh, "mods-update-all",
Strings("Update all"), {
kind = (modsWithUpdatesCount(imp) > 0) and "warn" or "ghost",
font = "small", enabled = updateOk,
action = function() askUpdateAllMods(imp) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = "mods" end })
elseif medReq <= w then
-- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
btn(imp, place(checkShortW), cy, checkShortW, bh, "mods-check-updates", Strings("Updates"), {
font = "small",
action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = "mods" end })
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small",
action = function() imp._modHeaderActionsPopup = true end })
else
-- Tier 3 (Ultra-Compact Mobile): Surface Import, Sort + More...
local importShortLabel = Strings("Import")
local importShortW = Kit.textWidth("small", importShortLabel) + math.floor(20 * m.s)
local miniReq = importShortW + sortW + moreW + math.floor(18 * m.s)
local useImportW = (miniReq <= w) and importShortW or importW
btn(imp, place(useImportW), cy, useImportW, bh, "mods-import", (miniReq <= w) and importShortLabel or importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = "mods" end })
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small",
action = function() imp._modHeaderActionsPopup = true end })
end
else
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
if #cartsWithUpdates(imp) > 0 then
local updateAllW = Kit.textWidth("small", Strings("Update all"))
+ math.floor(20 * m.s)
btn(imp, place(updateAllW), cy, updateAllW, bh, "mods-update-all",
Strings("Update all"), {
kind = "warn", font = "small",
enabled = updateOk,
action = function() askUpdateAllMods(imp) end })
end
end
cy = cy + bh + math.floor(8 * m.s)
-- notice line
local noticeText, noticeCol
if safeMode then
noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in the Bug tab to change mod toggles.", PAL.yellow
elseif imp.modNotice then
noticeText = imp.modNotice.text
noticeCol = imp.modNotice.ok and PAL.green or PAL.red
else
noticeText, noticeCol = imp:_modsDefaultHint(), PAL.muted
end
cy = cy + Kit.textWrapped("small", noticeText, x, cy, w, noticeCol, 2)
+ math.floor(8 * m.s)
if not safeMode and imp.modNotice then
for _, line in ipairs(imp.modNotice.failures or {}) do
cy = cy + Kit.textWrapped("small", line, x, cy, w, PAL.red, 2)
+ math.floor(2 * m.s)
end
end
cy = cy + buildModScopeRow(imp, x, cy, w, m)
cy = cy + buildModCartRow(imp, x, cy, w, m, cartId, cartReport)
cy = cy + buildSaveCartRow(imp, x, cy, w, m)
if #mods == 0 then
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
return (cy - y) + math.floor(110 * m.s)
end
local sortKey = currentSort(imp, "mods")
-- Immediate mode paints this panel every frame; re-sorting the whole list
-- per frame (with lowercased-string allocations in the comparator) fed the
-- GC for nothing. Cache the sorted array, keyed on the list identity, the
-- sort mode, and the update-info revision the fetch pump bumps.
local orderSort = sortKey == "order"
local statsSort = sortKey ~= "name" and not orderSort
local rev = statsSort and (imp._modUpdateRev or 0) or 0
local cache = imp._modSortCache
if cache and cache.n == #mods
and sortCacheOk(cache, mods, sortKey, rev, imp._modInfoFetch ~= nil) then
mods = cache.list
else
local scratch = imp._modSortScratch or {}
imp._modSortScratch = scratch
local n = decorate(scratch, mods,
function(mod, tie)
if sortKey == "name" then return tie end
if orderSort then return tonumber(mod.loadRank) or math.huge end
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
if sortKey == "popularity" then
return info and info.downloads and info.downloads.total or -1
end
local date = info and info.dates
if sortKey == "release" then return date and date.first or "0000-00-00" end
return date and date.latest or "0000-00-00"
end,
function(mod) return (mod.name or ""):lower() end)
sortAsc = sortKey == "name" or orderSort
table.sort(scratch, decCompare)
local sorted = undecorate(scratch, n)
imp._modSortCache = { src = mods, n = #mods, key = sortKey,
rev = rev, at = Kit.time, list = sorted, names = {} }
mods = sorted
end
local orderNames = imp._modSortCache and imp._modSortCache.names or {}
-- A mod row is a fixed height: its details first, then a dedicated second
-- line of per-game checkboxes. Fixed row heights are what make the
-- cull below plain arithmetic.
local togH = m.btnH
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
+ 2 * Kit.textHeight("small") + Kit.textHeight("micro") + math.floor(8 * m.s)
local rowH = math.floor(10 * m.s) + textH + math.floor(8 * m.s) + togH
+ m.btnH + math.floor(18 * m.s)
local listTop = cy
-- One continuous list: derive the rows that can touch the viewport before
-- entering the loop. Drawing was already culled, but scanning every
-- installed row to discover that defeats the point on a large mod library.
local view = imp._tabRegionRect
local viewTop = view and view.y or listTop
local viewBot = view and (view.y + view.h) or (listTop + availH)
local stride = rowH + gap
local first = math.max(1,
math.ceil((viewTop - rowH - listTop) / stride) + 1)
local last = math.min(#mods,
math.floor((viewBot - listTop) / stride) + 1)
for i = first, last do
local mod = mods[i]
local ry = listTop + (i - 1) * (rowH + gap)
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
local isFullyDisabled = true
if mod.enabledByVersion then
for _, on in pairs(mod.enabledByVersion) do
if on then isFullyDisabled = false; break end
end
else
isFullyDisabled = not mod.enabled
end
local hot = Kit.hover(x, ry, w, rowH)
if isFullyDisabled then
Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted")
else
Kit.card(x, ry, w, rowH, hot)
end
local pad = math.floor(12 * m.s)
local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s)
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
local gamesY = ry + math.floor(10 * m.s) + textH + math.floor(8 * m.s)
local summary
if mod.cartPin then
summary = mod.cartTogglable and (mod.enabled and Strings("In this cart: Enabled")
or Strings("In this cart: Disabled")) or Strings("Pinned, sealed:")
else
local count, names = 0, {}
for _, game in ipairs(gameTabs(imp)) do
if mod.enabledByVersion and mod.enabledByVersion[game.id] then
count = count + 1
names[#names + 1] = Strings(game.label)
end
end
summary = count == 0 and Strings("Enable for games")
or count <= 2 and Strings("Enabled for: %s", table.concat(names, ", "))
or Strings("Enabled for: %d games", count)
end
local selectorKey = "mod-games-" .. mod.id
btn(imp, px, gamesY, inner, togH, selectorKey, summary, {
font = "small", align = "left", trailingIcon = "chevron-right", enabled = not safeMode,
action = function()
if mod.cartPin then imp:_toggleMod(mod.id, nil, nil)
else imp._modGames = { id = mod.id, scroll = 0 } end
end,
})
local detailsY = gamesY + togH + math.floor(8 * m.s)
local detailsW = inner
if orderSort then
local canMove = not safeMode and cartId == nil
local mgap = math.floor(6 * m.s)
local upLabel, downLabel = Strings("Up"), Strings("Down")
local ob = imp._modOrderBtn
if not ob or ob.s ~= m.s or ob.up ~= upLabel or ob.down ~= downLabel then
ob = { s = m.s, up = upLabel, down = downLabel, keys = {},
w = math.max(Kit.textWidth("small", upLabel),
Kit.textWidth("small", downLabel)) + math.floor(24 * m.s) }
imp._modOrderBtn = ob
end
local keys = ob.keys[mod.id]
if not keys then
keys = { "mod-up-" .. mod.id, "mod-down-" .. mod.id }
ob.keys[mod.id] = keys
end
local mw = math.min(ob.w, math.floor((inner - 2 * mgap) / 4))
detailsW = inner - 2 * (mw + mgap)
local mx = px + detailsW + mgap
btn(imp, mx, detailsY, mw, m.btnH, keys[1], upLabel, {
font = "small", enabled = canMove and i > 1,
action = function() imp:_moveMod(mod.id, -1) end })
btn(imp, mx + mw + mgap, detailsY, mw, m.btnH, keys[2],
downLabel, { font = "small", enabled = canMove and i < #mods,
action = function() imp:_moveMod(mod.id, 1) end })
end
btn(imp, px, detailsY,
detailsW, m.btnH,
rowKey, Strings("Details"), { font = "small", icon = "folder",
action = function() imp._modActions = mod.id end,
})
local textW = inner
local shownName = mod.name
if orderSort then
shownName = orderNames[i]
if not shownName then
shownName = "#" .. i .. " " .. tostring(mod.name)
orderNames[i] = shownName
end
end
Kit.text("button", Kit.ellipsize("button", shownName, textW - 16 * m.s), px, ly,
isFullyDisabled and PAL.muted or PAL.heading)
ly = ly + Kit.textHeight("button") + math.floor(4 * m.s)
local tags = (mod.badge or "MOD") .. (mod.targets and (" / " .. mod.targets) or "")
if mod.cartPin then tags = tags .. " / " .. Strings("PINNED") end
Kit.text("micro", Kit.ellipsize("micro", tags, textW), px, ly,
mod.experimental and PAL.yellow or PAL.muted)
ly = ly + Kit.textHeight("micro") + math.floor(4 * m.s)
-- version + status + update state
local statusText, statusCol = modStatusColor(mod.status)
local line = "v" .. tostring(mod.version or "?") .. " " .. statusText
Kit.text("small", Kit.ellipsize("small", line, textW), px, ly, statusCol)
local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s)
if imp:_modInfoPending(mod.id) then
-- An inline spinner, because this row's release check is genuinely in
-- flight -- the list stays usable while it resolves.
Loader.dot(lx, ly, Kit.textHeight("small"))
Kit.text("small", Strings("Checking..."),
lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted)
elseif info and info.status == "available" then
Kit.text("small", Kit.ellipsize("small", Strings("v%s available", tostring(info.latest)), math.max(0, px + inner - lx)),
lx, ly, PAL.yellow)
elseif info and info.status == "current" then
Kit.text("small", Kit.ellipsize("small", Strings("up to date"), math.max(0, px + inner - lx)), lx, ly, PAL.muted)
elseif info and info.status == "error" then
Kit.text("small", Kit.ellipsize("small", Strings("check failed"), math.max(0, px + inner - lx)), lx, ly, PAL.red)
end
ly = ly + Kit.textHeight("small") + math.floor(2 * m.s)
-- one line of description, or the download stats when we have them
-- (download count in green so popularity reads at a glance)
if info and info.downloads then
local d = info.dates
local dl = ModUpdate.downloadsLine(info.downloads.total)
local dates = ModUpdate.datesLine(d and d.first, d and d.latest)
local segs = {}
if dl then segs[#segs + 1] = { dl, PAL.green } end
if dates then
segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail }
end
segLine("small", segs, px, ly, textW)
elseif orderSort and mod.orderNote then
Kit.text("small", Kit.ellipsize("small", Strings(mod.orderNote), textW),
px, ly, PAL.yellow)
elseif (mod.description or "") ~= "" then
Kit.text("small", Kit.ellipsize("small", mod.description, textW),
px, ly, PAL.detail)
end
end
local contentH = #mods * rowH + (#mods - 1) * gap
return (listTop + contentH + gap) - y
end
-- --------------------------------------------------------- importers panel
local IMPORTER_STATE_TEXT = {
planned = { "Not available yet", "steel" },
missing = { "No dump imported", "yellow" },
partial = { "Partly imported", "yellow" },
ready = { "Imported", "green" },
}
local function importerStateLine(state, have, total)
local row = IMPORTER_STATE_TEXT[state] or IMPORTER_STATE_TEXT.planned
local text = Strings(row[1])
if state == "partial" or state == "ready" then
text = text .. Strings(" %d of %d packs", have, total)
end
return text, PAL[row[2]] or PAL.steel
end
local IMPORTER_RESCAN = 3
local function importerRows(imp)
local rows = imp._importerRows
if rows and (Kit.time - (imp._importerRowsAt or 0)) < IMPORTER_RESCAN then
return rows
end
local Importers = require("src.import.Importers")
rows = {}
for _, desc in ipairs(Importers.all()) do
local installed = Importers.installed(desc.id)
local have, total = 0, 0
for _, pack in ipairs(desc.packs) do
if not pack.planned then
total = total + 1
if installed[pack.id] then have = have + 1 end
end
end
rows[#rows + 1] = { desc = desc, state = Importers.state(desc.id),
have = have, total = total }
end
imp._importerRows, imp._importerRowsAt = rows, Kit.time
return rows
end
local function buildImportersPanel(imp, x, y, w, availH, m)
local gap = m.gap
local cy = y
Kit.text("title", Strings("Extra Importers"), x, cy, PAL.heading)
cy = cy + Kit.textHeight("title") + math.floor(10 * m.s)
local listTop = cy
local pad = math.floor(12 * m.s)
for _, row in ipairs(importerRows(imp)) do
local desc, state = row.desc, row.state
local packsText = {}
for _, p in ipairs(desc.packs) do
packsText[#packsText + 1] = p.planned
and (p.name .. Strings(" (soon)")) or p.name
end
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(2 * m.s)
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small")
local running0 = imp._importerJob ~= nil and imp._importerJob.id == desc.id
local rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + m.btnH
+ math.floor(8 * m.s)
+ (running0 and math.floor(8 * m.s) or 0)
local ry = cy
Kit.card(x, ry, w, rowH, "muted")
local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s)
local badge = Strings(desc.status:upper())
local badgeW = Kit.textWidth("micro", badge) + math.floor(12 * m.s)
local nameShown = Kit.ellipsize("button", desc.name,
inner - badgeW - math.floor(12 * m.s))
Kit.text("button", nameShown, px, ly, PAL.muted)
Kit.tag(px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s), ly,
badgeW, Kit.textHeight("button"), badge,
state == "ready" and PAL.green or PAL.steel)
ly = ly + Kit.textHeight("button") + math.floor(4 * m.s)
local stateText, stateCol = importerStateLine(state, row.have, row.total)
Kit.text("small", stateText, px, ly, stateCol)
ly = ly + Kit.textHeight("small") + math.floor(2 * m.s)
Kit.text("small", Kit.ellipsize("small", Strings(desc.summary), inner),
px, ly, PAL.detail)
ly = ly + Kit.textHeight("small") + math.floor(2 * m.s)
Kit.text("small", Kit.ellipsize("small",
Strings("Packs: ") .. table.concat(packsText, ", "), inner),
px, ly, PAL.muted)
ly = ly + Kit.textHeight("small") + math.floor(8 * m.s)
local job = imp._importerJob
local running = job ~= nil and job.id == desc.id
local runnable = desc.status ~= "planned" and not imp._importerJob
local label = running and Strings("Importing...") or Strings("Import dump")
local bw = math.min(inner,
Kit.textWidth("small", label) + math.floor(28 * m.s))
btn(imp, px, ly, bw, m.btnH, "importer-" .. desc.id, label, {
kind = runnable and "accent" or "ghost", font = "small",
enabled = runnable,
action = runnable
and function() imp:_beginImporterImport(desc.id) end or nil })
local hx = px + bw + math.floor(8 * m.s)
local hw = math.max(0, px + inner - hx)
if hw > 0 then
local hint, hintCol
if running then
hint, hintCol = job.status or Strings("Working..."), PAL.yellow
elseif imp._importerNotice then
hint = imp._importerNotice.text
hintCol = imp._importerNotice.ok and PAL.green or PAL.red
elseif desc.status == "planned" then
hint, hintCol = Strings("Not wired up yet."), PAL.muted
else
hint, hintCol = Strings("Reads %s.", desc.source.name), PAL.muted
end
Kit.text("small", Kit.ellipsize("small", hint, hw), hx,
ly + (m.btnH - Kit.textHeight("small")) / 2, hintCol)
end
if running then
ly = ly + m.btnH + math.floor(4 * m.s)
Kit.progress(px, ly, inner, math.floor(4 * m.s), job.progress or 0)
end
cy = ry + rowH + gap
end
return (cy - y)
end
-- ---------------------------------------------------------- find mods panel
-- SKINS tab: pick the on-screen skin, import one, or open Skin Studio.
local function buildSkinsPanelLegacy(imp, x, y, w, availH, m)
local skins = imp:_ensureSkins()
local active = imp:_activeSkin()
local gap = m.gap
local cy = y
local title = Strings("Skins/Borders")
local bh = m.btnH
local importLabel = imp:_skinsImportButtonLabel()
local importW = Kit.textWidth("small", importLabel) + math.floor(24 * m.s)
if Kit.textWidth("button", title) + importW + math.floor(24 * m.s) > w then
importLabel = Strings("Import")
importW = Kit.textWidth("small", importLabel) + math.floor(20 * m.s)
end
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
btn(imp, place(importW), cy, importW, bh, "skins-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseSkin() end })
Kit.text("button", Kit.ellipsize("button", title,
math.max(0, w - importW - math.floor(12 * m.s))), x,
cy + math.floor((bh - Kit.textHeight("button")) / 2), PAL.heading)
cy = cy + bh + math.floor(8 * m.s)
if imp._skinNotice then
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w,
imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s)
end
local urlH = m.btnH
local addLabel = Strings("Add")
local addW = Kit.textWidth("small", addLabel) + math.floor(24 * m.s)
local pasteLabel = Strings("Paste")
local pasteW = Kit.textWidth("small", pasteLabel) + math.floor(20 * m.s)
if imp._skinFetch then
Loader.inline(x, cy, w, urlH,
Strings("Downloading %s...", tostring(imp._skinFetch.name or "")))
else
local urlPlace = Layout.rightCluster(x, w, math.floor(6 * m.s))
btn(imp, urlPlace(addW), cy, addW, urlH, "skins-url-add", addLabel, {
kind = "accent", font = "small",
action = function() imp:_addSkinFromUrl() end })
if w - addW - pasteW > math.floor(140 * m.s) then
btn(imp, urlPlace(pasteW), cy, pasteW, urlH, "skins-url-paste",
pasteLabel, {
font = "small", action = function() imp:_pasteSkinUrl() end })
end
local fieldW = math.max(0, urlPlace(0) - x - math.floor(6 * m.s))
textField(imp, x, cy, fieldW, urlH, "skins-url", imp.skinUrl or "",
Strings("Paste a skin link (.zip, .cfg, .deltaskin)"),
imp._skinUrlFocus == true,
function() imp:_toggleSkinUrlFocus() end)
end
cy = cy + urlH + math.floor(8 * m.s)
-- The Studio reflows to a touch-first canvas plus inspector on phones.
if imp.onOpenSkinStudio then
local label = Strings("Open Skin Studio")
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s))
btn(imp, x, cy, bw, m.btnH, "skins-studio", label, {
kind = "accent", font = "small",
action = function()
-- the studio boots the game on Play, so hand it a real cartridge
imp.onOpenSkinStudio(imp.modScope or "red")
end })
local hint = Strings("Design bezels and button layouts, then test them.")
Kit.text("small", Kit.ellipsize("small", hint,
w - bw - math.floor(12 * m.s)), x + bw + math.floor(12 * m.s),
cy + math.floor((m.btnH - Kit.textHeight("small")) / 2), PAL.muted)
cy = cy + m.btnH + gap
end
Kit.caption(x, cy, Strings("INSTALLED"))
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
-- Keep the actionable list below for detailed metadata and exports, but
-- lead with a visual picker: skins are much easier to recognize by their
-- bezel than by a folder name. Cards use the same loaded art that the
-- runtime draws, so they cannot drift from the selected skin.
local previewGap = math.floor(8 * m.s)
local previewCols = w >= math.floor(420 * m.s) and 2 or 1
local previewW = (w - previewGap * (previewCols - 1)) / previewCols
local previewH = math.max(108 * m.s, Kit.tapMin() * 2)
local previewCount = #skins
for i, entry in ipairs(skins) do
local n = i - 1
local px = x + (n % previewCols) * (previewW + previewGap)
local py = cy + math.floor(n / previewCols) * (previewH + previewGap)
local key = "skin-preview-" .. entry.id
local selected = active == entry.id
local focused = Kit.focusable(key, px, py, previewW, previewH)
Kit.card(px, py, previewW, previewH, selected and "selected"
or (focused or Kit.hover(px, py, previewW, previewH)))
local pad = math.floor(8 * m.s)
local artH = math.floor(previewH * 0.60)
Theme.fillRounded(px + pad, py + pad, previewW - pad * 2, artH,
PAL.bg, 1, Theme.cardRadius() * 0.6)
local art = entry.preview
if art and art.getDimensions then
local iw, ih = art:getDimensions()
if iw > 0 and ih > 0 then
local scale = math.min((previewW - pad * 4) / iw, (artH - pad * 2) / ih)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(art, px + (previewW - iw * scale) * 0.5,
py + pad + (artH - ih * scale) * 0.5, 0, scale, scale)
end
else
Theme.strokeRounded(px + previewW * 0.23, py + pad + artH * 0.15,
previewW * 0.54, artH * 0.42, PAL.line, Theme.A.hairline, 1, 2)
Theme.fillRounded(px + previewW * 0.20, py + pad + artH * 0.64,
previewW * 0.22, artH * 0.17, PAL.steel, 0.75, 3)
Theme.fillRounded(px + previewW * 0.60, py + pad + artH * 0.61,
previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11)
Theme.fillRounded(px + previewW * 0.75, py + pad + artH * 0.56,
previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11)
end
Kit.text("mono", Kit.ellipsize("mono", entry.id, previewW - pad * 2),
px + pad, py + pad + artH + math.floor(5 * m.s),
selected and PAL.green or PAL.heading)
if selected then
Kit.text("micro", Strings("IN USE"), px + pad,
py + previewH - pad - Kit.textHeight("micro"), PAL.green)
end
if Kit.press(px, py, previewW, previewH) or Kit._activateId == key then
queueAction(imp, key, function() imp:_useSkin(entry.id) end)
end
end
if previewCount > 0 then
cy = cy + math.ceil(previewCount / previewCols) * previewH
+ math.max(0, math.ceil(previewCount / previewCols) - 1) * previewGap
+ gap
end
local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s))
imp._skinGear = imp._skinGear
or love.graphics.newImage("assets/launcher/gear.png")
-- The row itself is "use this skin"; the gear beside it configures that
-- entry -- the built-in pad opens the drag-a-button layout editor, a skin
-- opens the studio, so neither lands on a screen that cannot edit it.
local function skinRow(key, id, title, detail, selected, configure, format)
local gearW = configure and rowH or 0
local rowW = w - (gearW > 0 and (gearW + math.floor(6 * m.s)) or 0)
local ink = rowHit(imp, x, cy, rowW, rowH, selected, key,
function() imp:_useSkin(id) end)
local tagW = selected
and (Kit.textWidth("small", Strings("IN USE")) + math.floor(20 * m.s))
or math.floor(12 * m.s)
local badge = format and SKIN_FORMAT_LABEL[format] or nil
local badgeW = 0
if badge then
badgeW = Kit.textWidth("micro", badge) + math.floor(16 * m.s)
local badgeH = math.floor(16 * m.s)
Kit.tag(x + rowW - tagW - badgeW - math.floor(12 * m.s),
cy + (rowH - badgeH) / 2, badgeW, badgeH, badge,
format == "native" and PAL.green or PAL.blue)
badgeW = badgeW + math.floor(10 * m.s)
end
local textW = math.max(0, rowW - math.floor(24 * m.s) - tagW - badgeW)
local tx = x + math.floor(12 * m.s)
local ty = cy + math.floor(7 * m.s)
Kit.text("mono", Kit.ellipsize("mono", title, textW), tx, ty,
ink or PAL.heading)
Kit.text("small", Kit.ellipsize("small", detail, textW),
tx, ty + Kit.textHeight("mono"), ink or PAL.muted)
if selected then
Kit.textRight("small", Strings("IN USE"), x + rowW - math.floor(12 * m.s),
cy + math.floor((rowH - Kit.textHeight("small")) / 2), ink or PAL.green)
end
if configure then
btn(imp, x + w - gearW, cy, gearW, gearW, key .. "-cfg", "", {
face = "invert", image = imp._skinGear, action = configure })
end
cy = cy + rowH + math.floor(4 * m.s)
end
local entries = { false }
for _, entry in ipairs(skins) do entries[#entries + 1] = entry end
local TouchSkin = require("src.core.TouchSkin")
local hint = Strings(
"You can also drop a skin .zip or .deltaskin on this window, or put a folder in %s/ of your save directory. RetroArch overlay .cfg files and Delta skins work as-is.",
TouchSkin.USER_ROOT)
local hintH = Kit.wrapHeight("small", hint, w, 3)
local importH = math.floor(10 * m.s) + hintH
local rowGap = math.floor(4 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listTop = cy
local listH = availH - (cy - y) - importH
local perPage = Kit.rowsThatFit(listH, rowH, rowGap, MIN_SKIN_ROWS, 20)
if #entries > perPage then
perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap,
MIN_SKIN_ROWS, 20)
end
local first, last, cur, pages = Kit.pageBounds(page(imp, "skins"),
#entries, perPage)
setPage(imp, "skins", cur)
setPage(imp, "skins",
Kit.wheelPage(x, listTop, w, listH, cur, #entries, perPage))
for i = first, last do
local entry = entries[i]
if not entry then
skinRow("skin-none", nil, Strings("Built-in pad"),
Strings("The default on-screen buttons."), active == nil,
imp.onEditTouchControls and function()
imp.onEditTouchControls(imp.modScope or "red")
end or nil)
else
local bits = {}
bits[#bits + 1] = entry.source == "user" and Strings("installed")
or Strings("bundled")
if entry.controls > 0 then
bits[#bits + 1] = entry.controls .. " " .. Strings("buttons")
else
bits[#bits + 1] = Strings("bezel only")
end
if entry.pages > 1 then
bits[#bits + 1] = entry.pages .. " " .. Strings("pages")
end
if entry.screen then bits[#bits + 1] = Strings("screen cutout") end
local configure = function() imp._skinActions = { id = entry.id } end
skinRow("skin-" .. entry.id, entry.id, entry.id,
table.concat(bits, " \194\183 "), active == entry.id, configure,
entry.format)
end
end
if #skins == 0 then
Kit.emptyBox(x, cy, w, math.floor(72 * m.s),
Strings("No skins installed yet."))
cy = cy + math.floor(72 * m.s) + gap
end
if pages > 1 then
setPage(imp, "skins",
Kit.pager(x, cy, w, cur, #entries, perPage, "skins"))
cy = cy + pagerH + gap
end
cy = cy + math.floor(10 * m.s)
Kit.textWrapped("small", hint, x, cy, w, PAL.muted, 3)
return cy + hintH - y
end
-- The launcher is the short path: bring a skin in, see what is enabled, or
-- turn skin use off. Browsing, pagination and per-skin editing/export live
-- together in My Skins, where they are useful instead of competing here.
local function buildSkinsPanel(imp, x, y, w, availH, m)
local cy, gap, bh = y, m.gap, m.btnH
local active = imp:_activeSkin()
Kit.text("title", Strings("Skins"), x, cy, PAL.heading)
local importW = math.min(w * 0.46,
Kit.textWidth("small", imp:_skinsImportButtonLabel()) + math.floor(24 * m.s))
btn(imp, x + w - importW, cy, importW, bh, "skins-import",
imp:_skinsImportButtonLabel(), { kind = "accent", font = "small",
action = function() imp:chooseSkin() end })
cy = cy + bh + gap
if imp._skinNotice then
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w,
imp._skinNotice.ok and PAL.green or PAL.red, 2) + gap
end
local addW = Kit.textWidth("small", Strings("Add")) + math.floor(24 * m.s)
if imp._skinFetch then
Loader.inline(x, cy, w, bh, Strings("Downloading %s...",
tostring(imp._skinFetch.name or "")))
else
btn(imp, x + w - addW, cy, addW, bh, "skins-url-add", Strings("Add"), {
kind = "accent", font = "small", action = function() imp:_addSkinFromUrl() end })
textField(imp, x, cy, w - addW - gap, bh, "skins-url", imp.skinUrl or "",
Strings("Paste a skin link (.zip, .cfg, .deltaskin)"),
imp._skinUrlFocus == true, function() imp:_toggleSkinUrlFocus() end)
end
cy = cy + bh + gap
local currentH = active and (bh * 2 + gap * 2) or (bh + gap * 2)
Kit.card(x, cy, w, currentH)
Kit.caption(x + gap, cy + gap, "CURRENT SKIN")
local current = active and tostring(active) or Strings("No skin enabled")
Kit.text("mono", Kit.ellipsize("mono", current, w - gap * 2), x + gap,
cy + gap + Kit.textHeight("small") + math.floor(4 * m.s),
active and PAL.green or PAL.muted)
local buttonY = cy + bh + gap
local half = (w - gap * 3) * 0.5
if active then
btn(imp, x + gap, buttonY, half, bh, "skins-export-current",
Strings("Export current"), { font = "small",
action = function() imp:_exportSkin(active, "native") end })
btn(imp, x + gap * 2 + half, buttonY, half, bh, "skins-off",
Strings("Turn skins off"), { kind = "danger", font = "small",
action = function() imp:_disableSkins() end })
end
cy = cy + currentH + gap
if imp.onOpenSkinStudio then
btn(imp, x, cy, w, bh, "skins-my-skins", Strings("My Skins"), {
kind = "accent", font = "small",
action = function() imp.onOpenSkinStudio(imp.modScope or "red", active) end })
cy = cy + bh + gap
end
Kit.textWrapped("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."),
x, cy, w, PAL.muted, 3)
return cy + Kit.wrapHeight("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."), w, 3) - y
end
local function buildBugPanel(imp, x, y, w, availH, m)
local SaveData = require("src.core.SaveData")
local gap = m.gap
local pad = math.floor(16 * m.s)
local cy = y
local safeMode = imp:_safeModeEnabled()
Kit.text("button", Strings("Troubleshooting"), x, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + gap
if imp.issueNotice then
cy = cy + Kit.textWrapped("small", imp.issueNotice.text, x, cy, w,
imp.issueNotice.ok and PAL.green or PAL.red, 2) + gap
end
local switchW = math.floor(92 * m.s)
local switchH = math.max(m.btnH, Kit.tapMin())
local detail = safeMode
and Strings("All mods are disabled and their toggles are locked until safe mode is turned off.")
or Strings("Temporarily disable every mod while you reproduce a bug.")
local textW = math.max(0, w - 2 * pad - switchW - gap)
local detailH = Kit.wrapHeight("small", detail, textW, 3)
local safeH = math.max(switchH, Kit.textHeight("small") + math.floor(4 * m.s) + detailH)
+ 2 * pad
Kit.card(x, cy, w, safeH)
local textX = x + pad
local textY = cy + pad
Kit.text("small", Strings("Safe mode"), textX, textY, PAL.heading)
Kit.textWrapped("small", detail, textX,
textY + Kit.textHeight("small") + math.floor(4 * m.s), textW,
PAL.muted, 3)
local toggleX = x + w - pad - switchW
local toggleY = cy + math.floor((safeH - switchH) / 2)
local _, changed = Kit.toggle(toggleX, toggleY, switchW, switchH, safeMode,
"bug-safe-mode")
if changed then
queueAction(imp, "bug-safe-mode", function() imp:_toggleSafeMode() end)
end
cy = cy + safeH + gap
local reportLabel = Strings("Report a bug")
local reportW = math.min(w - 2 * pad,
Kit.textWidth("small", reportLabel) + math.floor(32 * m.s))
local reportDetail = Strings("Fill out the GitHub form with the available system information.")
local reportTextW = math.max(0, w - 2 * pad - reportW - gap)
local reportDetailH = Kit.wrapHeight("small", reportDetail, reportTextW, 3)
local reportH = math.max(m.btnH, Kit.textHeight("small") + math.floor(4 * m.s) + reportDetailH)
+ 2 * pad
Kit.card(x, cy, w, reportH)
Kit.text("small", Strings("Something not working?"), textX, cy + pad, PAL.heading)
Kit.textWrapped("small", reportDetail, textX,
cy + pad + Kit.textHeight("small") + math.floor(4 * m.s), reportTextW,
PAL.muted, 3)
btn(imp, x + w - pad - reportW,
cy + math.floor((reportH - m.btnH) / 2), reportW, m.btnH,
"bug-report", reportLabel, {
kind = "accent", font = "small",
action = function()
imp:_ensureMods()
imp:_reportIssue(SaveData.loadOptions(), nil)
end })
end
-- FIND tab: which half of the feed is on screen, in the same chip idiom the
-- MODS tab's scope row uses. The counts say which half is worth pressing.
local function buildFindKindRow(imp, x, y, w, m)
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
local gap = math.floor(6 * m.s)
local label = Strings("Browse:")
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
local options = {
{ id = "mods", label = Strings("Mods"),
n = #((imp.findIndex and imp.findIndex.mods) or {}) },
{ id = "carts", label = Strings("Carts"),
n = #((imp.findIndex and imp.findIndex.carts) or {}) },
}
for _, opt in ipairs(options) do
local text = ("%s (%d)"):format(opt.label, opt.n)
local cw = Kit.textWidth("micro", text) + math.floor(18 * m.s)
if cx + cw > x + w then break end
if Kit.chip(cx, y, cw, h, text, imp.findKind == opt.id, PAL.lineStrong,
"find-kind-" .. opt.id) then
local want = opt.id
queueAction(imp, "find-kind-" .. want,
function() imp:_setFindKind(want) end)
end
cx = cx + cw + gap
end
return h + math.floor(8 * m.s)
end
local function buildFindPanel(imp, x, y, w, availH, m)
imp._findVisibleEntries = nil
imp:_ensureFind()
local ModIndex = require("src.mods.ModIndex")
local ModUpdate = require("src.mods.ModUpdate")
local sources = imp.findSources or {}
local carts = imp.findKind == "carts"
local rows = imp:_findRows()
local total = #((imp.findIndex
and (carts and imp.findIndex.carts or imp.findIndex.mods)) or {})
local gap = m.gap
local cy = y
-- No headline, no disclaimer paragraph: the active tab already names this
-- panel, and the index list, the category filter and the sort choice all
-- moved into popups (Indexes / Filter / Sort) so the space goes to rows.
-- Only a live action-feedback notice (Installed X / errors) earns a line.
if imp.findNotice then
cy = cy + Kit.textWrapped("small", imp.findNotice.text, x, cy, w,
imp.findNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s)
end
if #sources == 0 then
local h = math.floor(140 * m.s)
Kit.card(x, cy, w, h)
Kit.textCenter("button", Strings("No mod index added"), x,
cy + math.floor(24 * m.s), w, PAL.heading)
Kit.textWrapped("small", Strings(
"Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."),
x + math.floor(24 * m.s), cy + math.floor(54 * m.s),
w - math.floor(48 * m.s), PAL.muted, 2)
local aw = Kit.textWidth("small", Strings("Add an index"))
+ math.floor(28 * m.s)
btn(imp, x + math.floor((w - aw) / 2), cy + h - m.btnH - math.floor(14 * m.s),
aw, m.btnH, "find-add", Strings("Add an index"), {
kind = "accent", font = "small",
action = function() imp._indexManage = true end })
return (cy - y) + h
end
cy = cy + buildFindKindRow(imp, x, cy, w, m)
-- One row: the search field, then Filter / Sort / Indexes popup buttons.
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local bgap = math.floor(6 * m.s)
local place = Layout.rightCluster(x, w, bgap)
local xw = Kit.textWidth("small", Strings("Indexes")) + math.floor(20 * m.s)
btn(imp, place(xw), cy, xw, fieldH, "find-indexes", Strings("Indexes"), {
font = "small",
action = function() imp._indexManage = true end })
local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(20 * m.s)
btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = "find" end })
local activeFilter = (carts and imp.findBase or imp.findCategory)
or imp.findGame
local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s)
btn(imp, place(fw), cy, fw, fieldH, "find-filter", Strings("Filter"), {
kind = activeFilter and "accent" or "ghost", font = "small",
action = function() imp._filterPopup = true end })
local searchW = place(0) - x - bgap
textField(imp, x, cy, searchW, fieldH, "find-search", imp.findQuery or "",
carts and Strings("Search carts") or Strings("Search mods"),
imp._findSearchFocus == true,
function() imp:_toggleFindSearchFocus() end)
cy = cy + fieldH + math.floor(8 * m.s)
if #rows == 0 then
local empty
if imp._findFetch then
empty = Strings("Loading mod index...")
elseif carts then
empty = (total == 0) and Strings("This index lists no carts yet.")
or Strings("No carts match that search.")
else
empty = (total == 0) and Strings("This index lists no mods yet.")
or Strings("No mods match that search.")
end
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), empty)
return (cy - y) + math.floor(110 * m.s)
end
local sortKey = currentSort(imp, "find")
-- Same caching rule as the MODS tab: the comparator allocates, so only
-- re-sort when the inputs actually change.
local statsSort = sortKey ~= "name"
local rev = statsSort and (imp._findStatsRev or 0) or 0
local fcache = imp._findSortCache
if sortCacheOk(fcache, rows, sortKey, rev, imp._findStatsPending ~= nil) then
rows = fcache.list
else
local scratch = imp._findSortScratch or {}
imp._findSortScratch = scratch
local n = decorate(scratch, rows,
function(entry, tie)
if sortKey == "name" then return tie end
-- The CACHED read, never the requesting one: a sort must not queue a
-- fetch for every entry in the index (see _findStatsCached).
local stats = imp:_findStatsCached(entry)
if sortKey == "popularity" then return stats and stats.total or -1 end
if sortKey == "trending" then return stats and stats.recent or -1 end
if sortKey == "release" then return stats and stats.first or "0000-00-00" end
return stats and stats.latest or "0000-00-00"
end,
function(entry) return (entry.title or entry.id or ""):lower() end)
sortAsc = sortKey == "name"
table.sort(scratch, decCompare)
local sorted = undecorate(scratch, n)
imp._findSortCache = { src = rows, key = sortKey, rev = rev,
at = Kit.time, list = sorted }
rows = sorted
end
local installed = imp:_findInstalledMap()
-- The thumbnail sits BESIDE the text and the action chips share the title
-- line's row, so a card is only as tall as its text block. The old layout
-- stacked chips under a 64px thumbnail and got ~2 rows per screen; this
-- fits roughly twice as many without shrinking a single tap target.
local thumb = math.floor(44 * m.s)
local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s))
-- TWO text lines, not three: the version/author/category meta and the
-- download stats share a line. A third line cost every row ~20px, which
-- at this UI scale was the difference between one and two rows per page.
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small")
local rowH = math.floor(8 * m.s) + math.max(thumb, textH, chipH)
+ math.floor(8 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listH = availH - (cy - y) - pagerH - gap
local perPage = Kit.rowsThatFit(listH, rowH, gap, MIN_FIND_ROWS, 20)
local first, last, cur, pages = Kit.pageBounds(page(imp, "find"), #rows, perPage)
setPage(imp, "find", cur)
local listTop = cy
setPage(imp, "find", Kit.wheelPage(x, listTop, w, listH, cur, #rows, perPage))
local visible = imp._findVisibleEntries or {}
for i = #visible, 1, -1 do visible[i] = nil end
for i = first, last do visible[#visible + 1] = rows[i] end
imp._findVisibleEntries = visible
for i = first, last do
local entry = rows[i]
local ry = listTop + (i - first) * (rowH + gap)
local rowKey = rowKeyFor(imp, "find-row-", entry.id)
-- The whole row is the control: it opens the per-mod popup where
-- Install / Details / Source moved. The only inline signal left is a
-- green check when the mod is already installed.
local hot = Kit.hover(x, ry, w, rowH)
Kit.card(x, ry, w, rowH, hot)
local pad = math.floor(12 * m.s)
local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(8 * m.s)
if Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey then
local e = entry
queueAction(imp, rowKey, function() imp._findEntry = e end)
end
local _, note = findActionFor(entry, installed[entry.id])
local chipsW = 0
if installed[entry.id] then
local ck = math.floor(20 * m.s)
drawCheck(px + inner - ck, ry + (rowH - ck) / 2, ck, PAL.green)
chipsW = ck + math.floor(6 * m.s)
end
-- thumbnail (or its placeholder while the async fetch is in flight)
local image = imp:_findThumb(entry)
if image then
local iw3, ih3 = image:getDimensions()
local s = math.min(thumb / iw3, thumb / ih3)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(image, Theme.snap(px), Theme.snap(ly), 0, s, s)
else
Theme.stroke(px, ly, thumb, thumb, PAL.line, Theme.A.hairline, 1)
-- A thumbnail still downloading and one that will never arrive drew the
-- same dead box, so a slow index looked broken. Spin while it is in
-- flight; only fall back to the wordmark once it has resolved.
if imp:_findThumbPending(entry.id) then
Kit.spinner(px + thumb / 2, ly + thumb / 2, thumb * 0.28)
else
Kit.textCenter("micro", carts and "CART" or "MOD", px,
ly + (thumb - Kit.textHeight("micro")) / 2, thumb, PAL.faint)
end
end
local bx = px + thumb + math.floor(10 * m.s)
local bw = inner - thumb - math.floor(10 * m.s) - chipsW
local targets = (not carts) and ModIndex.targetLabel(entry) or nil
local targetsW = targets
and Kit.textWidth("micro", targets) + math.floor(12 * m.s) or 0
local titleShown = Kit.ellipsize("button", entry.title or entry.id,
bw - targetsW - (targets and math.floor(8 * m.s) or 0))
Kit.text("button", titleShown, bx, ly, PAL.heading)
if targets then
Kit.tag(bx + Kit.textWidth("button", titleShown) + math.floor(8 * m.s),
ly, targetsW, Kit.textHeight("button"), targets, PAL.blue)
end
local by2 = ly + Kit.textHeight("button") + math.floor(4 * m.s)
-- meta and stats on one line, the download count first (and green)
-- because it is what the default Most-downloaded sort is ordering by: a
-- narrow window ellipsizes the tail, and the count must survive that.
local stats = imp:_findStats(entry)
local baseCol = note and PAL.green or PAL.detail
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
if note then lead = lead .. " - " .. note end
local dl = stats and ModUpdate.downloadsShort(stats.total) or nil
local hasCount = stats ~= nil and stats.total ~= nil
local dates = stats and ModUpdate.datesLine(stats.first, stats.latest)
or nil
local rest = {}
if entry.author then rest[#rest + 1] = entry.author end
if carts then
-- A cart has no categories; the game it plays as and its seal are what
-- a reader is actually choosing between.
rest[#rest + 1] = gameLabel(entry.base)
if entry.seal then rest[#rest + 1] = entry.seal end
elseif entry.categories and entry.categories[1] then
rest[#rest + 1] = entry.categories[1]
end
if not carts then
for i = 1, math.min(2, #(entry.tags or {})) do
rest[#rest + 1] = entry.tags[i]
end
end
if dates then
rest[#rest + 1] = dates
elseif not hasCount and (entry.summary or "") ~= "" then
rest[#rest + 1] = entry.summary
end
local segs = { { lead, baseCol } }
if dl then
segs[#segs + 1] = { " - " .. dl, hasCount and PAL.green or PAL.faint }
end
if #rest > 0 then
segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol }
end
segLine("small", segs, bx, by2, bw)
-- The stats line used to simply be absent until the release check landed,
-- so rows silently changed under the reader and a slow check was
-- indistinguishable from a mod with no data. Say which it is, the way
-- the MODS tab already does on its own rows.
if not stats and imp:_findStatsPendingFor(entry.id) then
local sw = Kit.textWidth("small", segs[1][1]) + math.floor(12 * m.s)
local dh = Kit.textHeight("small")
Loader.dot(bx + sw, by2, dh)
Kit.text("small", Strings("Checking..."),
bx + sw + dh + math.floor(6 * m.s), by2, PAL.muted)
end
end
local pagerY = listTop + (last - first + 1) * (rowH + gap)
local findPage, findPagerH = Kit.pager(x, pagerY, w, cur, #rows, perPage,
"find")
setPage(imp, "find", findPage)
local bottom = pagerY + findPagerH
-- Aggregate progress. Enrichment happens a page at a time and each row says
-- so for itself, but with nothing summarising it the panel looked idle while
-- work was in flight. Only drawn while something is actually pending.
local waiting = imp:_findStatsPendingCount()
if waiting > 0 then
local py = pagerY + math.max(Kit.tapMin(), math.floor(30 * m.s))
+ math.floor(4 * m.s)
local dh = Kit.textHeight("micro")
Loader.dot(x, py, dh)
Kit.text("micro", Strings("Checking %d of %d on this page...",
waiting, last - first + 1),
x + dh + math.floor(6 * m.s), py, PAL.muted)
bottom = math.max(bottom, py + dh)
end
return bottom - y
end
-- ------------------------------------------------------------------ footer
local TRUST_WARNING = "if you did not get this from bryanthaboi's github "
.. "or a link from the discord that bryanthaboi himself posted, just know "
.. "it might have been tampered with. go to the discord to verify "
.. COMMUNITY_URL .. " (or click the logo above)"
-- Mark + optional updater + Patch notes. Chips match the mark's 22px
-- height so they do not read as bigger than the logo; the row can still
-- be tapMin tall for spacing. On a phone the notes chip drops onto a
-- second row rather than overflowing the mark.
local function footerLayout(imp, m, markW)
local markH = math.floor(22 * m.s)
local rowH = math.max(markH, Kit.tapMin())
local notesLabel = Strings("Patch notes")
-- Tight chip, still enough for Kit.button's labelInset so the words survive.
local chipPad = math.floor(24 * m.s)
local nw = Kit.textWidth("micro", notesLabel) + chipPad
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
local uw = upStatus
and (Kit.textWidth("micro", upLabel) + chipPad) or 0
local gap = math.floor(10 * m.s)
local inner = m.w - 2 * m.pad
local topW = (markW or 0) + (upStatus and (gap + uw) or 0) + gap + nw
return {
rowH = rowH, chipH = markH, gap = gap,
notesLabel = notesLabel, nw = nw,
upStatus = upStatus, upLabel = upLabel, upAction = upAction, upGlow = upGlow,
uw = uw, wrap = topW > inner,
}
end
-- Pinned to the bottom of the window; returns the y it starts at, so the
-- panels above know how much room they have.
-- Deliberately compact: at a large UI scale the footer is pure overhead
-- competing with the panel for a short window's height, so the mark and the
-- link share one line and the trust warning is capped at a single line.
local function footerHeight(imp, m)
-- Top pad + mark/update row + optional notes wrap row + gap + the FULL
-- wrapped trust message + bottom pad. The message wraps to as many lines
-- as it needs: truncating a trust warning defeats its purpose, and the
-- bottom pad is not optional either (without it the last line sits flush
-- on the window edge and its lower half clips off). The row is tapMin
-- tall because the small update button rides beside the mark.
local f = footerLayout(imp, m, math.floor(130 * m.s))
local h = math.floor(8 * m.s) + f.rowH + math.floor(6 * m.s)
if f.wrap then h = h + f.chipH + math.floor(6 * m.s) end
return h + Kit.wrapHeight("micro", TRUST_WARNING, m.contentW)
+ math.floor(8 * m.s)
end
local function buildFooter(imp, m, y)
Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline)
local cy = y + math.floor(8 * m.s)
-- The BCG mark is dark ink; invert it for the black field.
imp.invertShader = imp.invertShader or love.graphics.newShader([[
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 p = Texel(tex, tc);
return vec4((vec3(1.0) - p.rgb) * color.rgb, p.a * color.a);
}
]])
local bw, bh = imp.bcg:getDimensions()
local scale = math.min((130 * m.s) / bw, (22 * m.s) / bh)
local dw, dh = bw * scale, bh * scale
local f = footerLayout(imp, m, dw)
local rowH, gap, chipH = f.rowH, f.gap, f.chipH
-- The mark, the small self-update control, and Patch notes share the row,
-- centred as a group. The updater moved down here from the header, where
-- it overlapped the wordmark on a phone; small on purpose, its glow still
-- carries the "act on me" signal. Notes wrap under the mark on a phone.
local topW = dw + (f.upStatus and (gap + f.uw) or 0)
if not f.wrap then topW = topW + gap + f.nw end
local bx = m.x + math.floor((m.w - topW) / 2)
local my = cy + math.floor((rowH - dh) / 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)
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.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.setShader()
love.graphics.setColor(1, 1, 1, 1)
if Kit.press(bx, my, dw, dh) or Kit._activateId == "bcg" then
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
end
local cx = bx + dw
if f.upStatus then
cx = cx + gap
btn(imp, cx, chipY, f.uw, chipH, "updater",
f.upLabel, {
kind = f.upGlow and "warn" or "ghost", font = "micro",
glow = f.upGlow, action = f.upAction,
})
cx = cx + f.uw
end
local function notesBtn(x, y)
btn(imp, x, y, f.nw, chipH, "patch-notes", f.notesLabel, {
kind = "ghost", font = "micro",
action = function() imp._appPatchNotes = true end,
})
end
if f.wrap then
cy = cy + rowH + gap
notesBtn(m.x + math.floor((m.w - f.nw) / 2), cy)
cy = cy + chipH + math.floor(6 * m.s)
else
notesBtn(cx + gap, chipY)
cy = cy + rowH + math.floor(6 * m.s)
end
-- The trust message wraps in full, each line centred under the mark, and
-- the URL inside it IS the link -- no separate link floating elsewhere.
-- font:getWrap never splits an unspaced word, so the URL stays whole on
-- one line and a plain substring find locates it.
local lines = Kit.wrapLines("micro", TRUST_WARNING, m.contentW)
local lh = Kit.textHeight("micro")
for i, line in ipairs(lines or {}) do
local lw = Kit.textWidth("micro", line)
local lx = m.contentX + math.floor((m.contentW - lw) / 2)
local ly = cy + (i - 1) * lh
local s0, e0 = line:find(COMMUNITY_URL, 1, true)
if s0 then
local pre = line:sub(1, s0 - 1)
local url = line:sub(s0, e0)
Kit.text("micro", pre, lx, ly, PAL.muted)
local ux = lx + Kit.textWidth("micro", pre)
local uw = Kit.textWidth("micro", url)
Kit.text("micro", url, ux, ly, PAL.blue)
Theme.fill(ux, ly + lh - 1, uw, 1, PAL.blue, 0.6)
if Kit.press(ux, ly, uw, lh) then
queueAction(imp, "bois", function()
love.system.openURL(COMMUNITY_URL)
end)
end
Kit.text("micro", line:sub(e0 + 1), ux + uw, ly, PAL.muted)
else
Kit.text("micro", line, lx, ly, PAL.muted)
end
end
end
-- ------------------------------------------------------------------ modals
-- A modal draws its own scrim, then raises Kit.blockClicks so everything
-- underneath is inert, then lowers it for its own panel. There is no
-- z-ordered hit test, so this ordering IS the z-order.
local modalRect = { x = 0, y = 0, w = 0, h = 0 }
local modalTransform = false
local function modalAmount()
local L = Transition.get("modal")
if not L then return 1 end
local p = Transition.progress("modal")
if L.kind == "out" then return 1 - p end
return p
end
local function modalPanel(m, w, h)
-- A near-opaque scrim, not a tint. At 0.82 the header and the wordmark
-- still read through the settings panel and the screen looked like two
-- layouts fighting rather than one panel on top ("the settings is covering
-- the logo"); at this weight the page behind is present but plainly out of
-- play, which is what a modal is supposed to say.
local amt = modalAmount()
Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.93 * amt)
Kit.blockClicks = true
local pw = math.floor(math.min(w, m.W - 2 * m.pad))
local ph = math.floor(math.min(h, m.H - 2 * m.pad))
local px = math.floor((m.W - pw) / 2)
local py = math.floor((m.H - ph) / 2)
modalRect.x, modalRect.y, modalRect.w, modalRect.h = px, py, pw, ph
if amt < 1 and not modalTransform and love.graphics and love.graphics.push then
local s = 0.96 + 0.04 * amt
love.graphics.push()
love.graphics.translate(m.W / 2, m.H / 2)
love.graphics.scale(s, s)
love.graphics.translate(-m.W / 2, -m.H / 2)
modalTransform = true
end
Kit.card(px, py, pw, ph, true)
Kit.blockClicks = Transition.active()
return px, py, pw, ph
end
local function endModalDraw(m)
if not modalTransform then return end
modalTransform = false
local fade = 1 - modalAmount()
if fade > 0 then
local pad = math.floor(6 * m.s)
Theme.fillRounded(modalRect.x - pad, modalRect.y - pad,
modalRect.w + 2 * pad, modalRect.h + 2 * pad, PAL.bg, fade, 10)
end
love.graphics.pop()
end
LauncherView.modalPanel = modalPanel
-- Shared prompt: title, read-only field over the importer's text, buttons.
-- Pickers keep their controls outside the scroll clip; only the rows move.
local function pickerFrame(imp, m, state, key, title, count, rowH)
local pad, gap = math.floor(16 * m.s), math.floor(8 * m.s)
local headH = m.btnH + gap
local wantH = 2 * pad + headH + math.min(count, 6) * (rowH + gap) + m.btnH + gap
local px, py, pw, ph = modalPanel(m, math.floor(460 * m.s), wantH)
local closeW = m.btnH
Kit.text("button", Kit.ellipsize("button", title, pw - 2 * pad - closeW - gap),
px + pad, py + pad + (closeW - Kit.textHeight("button")) / 2, PAL.heading)
btn(imp, px + pw - pad - closeW, py + pad, closeW, closeW, key .. "-close", "", {
face = "invert", icon = "x", action = function() imp[key] = nil end,
})
local x, y, w = px + pad, py + pad + headH, pw - 2 * pad
local footerY = py + ph - pad - m.btnH
local h = math.max(0, footerY - gap - y)
local maxAt = Kit.scrollExtent(count * (rowH + gap) - gap, h)
state.rect = state.rect or {}
state.rect.x, state.rect.y, state.rect.w, state.rect.h = x, y, w, h
state.maxScroll = maxAt
state.scroll = Kit.scrollInput(state.scroll, maxAt, x, y, w, h)
local at = state.scroll
-- A focus move can land below the visible rows; scroll it into view next frame.
local focus = Kit.focusId
local index = focus and tonumber(focus:match("^" .. key .. "%-row%-(%d+)$"))
if index and Kit._ringShown and state.lastFocus ~= focus then
local top = (index - 1) * (rowH + gap)
at = Kit.scrollClamp(math.max(top + rowH - h, math.min(at, top)), maxAt)
state.scroll = at
end
state.lastFocus = focus
return x, y, w, h, at, maxAt, footerY, gap
end
local function modalBody(imp, key, x, y, w, h, contentH)
local all = imp._modalScroll
if not all then all = {} imp._modalScroll = all end
local state = all[key]
if not state then state = { scroll = 0, rows = {}, rect = {} } all[key] = state end
h = math.max(0, h)
local maxAt = Kit.scrollExtent(contentH, h)
local rect = state.rect
rect.x, rect.y, rect.w, rect.h = x, y, w, h
state.maxScroll = maxAt
local at = Kit.scrollInput(state.scroll, maxAt, x, y, w, h)
local focus = Kit.focusId
local row = focus and state.rows[focus]
if row and Kit._ringShown and state.lastFocus ~= focus then
at = Kit.scrollClamp(math.max(row[1] + row[2] - h, math.min(at, row[1])), maxAt)
end
state.scroll, state.lastFocus = at, focus
local top = Kit.scrollBegin(x, y, w, h, at, maxAt)
local rw = maxAt > 0 and w - Kit.scrollGutter() or w
local function place(id, ry, rh)
local r = state.rows[id]
if not r then r = {} state.rows[id] = r end
r[1], r[2] = ry, rh
return top + ry
end
local function done() Kit.scrollEnd(x, y, w, h, at, maxAt, PAL.surface) end
return top, rw, place, done
end
local function modalFrame(imp, m, key, w, pad, headH, contentH, footH, gap, viewH)
local px, py, pw, ph = modalPanel(m, w,
2 * pad + headH + (viewH or contentH) + (footH > 0 and gap + footH or 0))
local x, bodyY = px + pad, py + pad + headH
local footY = py + ph - pad - footH
local function open()
return modalBody(imp, key, x, bodyY, pw - 2 * pad,
footY - (footH > 0 and gap or 0) - bodyY, contentH)
end
return px, py, pw, footY, open
end
function LauncherView.modalTextW(m, w, pad)
return math.floor(math.min(w, m.W - 2 * m.pad)) - 2 * pad - Kit.scrollGutter()
end
local function buildSaveExport(imp, m)
local state = imp._saveExport
local supported = state.scope == state.version
and require("src.save_convert.SaveConvert").exportSupported(state.version)
local pad, gap = math.floor(18 * m.s), math.floor(10 * m.s)
local w = math.min(math.floor(440 * m.s), m.W - 2 * m.pad)
local inner = w - 2 * pad - Kit.scrollGutter()
local hint = Strings("Choose a format for %s.", state.label)
local original = Strings("The original launcher save, with all of its data preserved.")
local converted = supported and Strings("Converted for the original game or an emulator.")
or Strings("Cartridge export is not available for this game or custom cart yet.")
local contentH = Kit.wrapHeight("small", hint, inner) + gap
+ m.btnH + gap + Kit.wrapHeight("small", original, inner) + gap
+ (supported and m.btnH + gap or 0) + Kit.wrapHeight("small", converted, inner) + gap
local px, py, pw, _, open = modalFrame(imp, m, "_saveExport", w, pad,
m.btnH + gap, contentH, 0, gap)
local x, cy = px + pad, py + pad
Kit.textBold("button", Strings("Export save"), x, cy + (m.btnH - Kit.textHeight("button")) / 2, PAL.heading)
btn(imp, px + pw - pad - m.btnH, cy, m.btnH, m.btnH, "export-save-close", "", {
face = "invert", icon = "x", action = function() imp._saveExport = nil end,
})
local top, _, place, done = open()
cy = top
cy = cy + Kit.textWrapped("small", hint, x, cy, inner, PAL.detail) + gap
cy = place("export-save-lua", cy - top, m.btnH)
btn(imp, x, cy, inner, m.btnH, "export-save-lua", Strings("Original save (.lua)"), {
icon = "upload", font = "small", kind = "accent",
action = function()
imp._saveExport = nil
imp:exportSave(state.version, "lua", state.scope, state.slotId)
end,
})
cy = cy + m.btnH + gap
cy = cy + Kit.textWrapped("small", original, x, cy, inner, PAL.muted) + gap
if supported then
cy = place("export-save-sav", cy - top, m.btnH)
btn(imp, x, cy, inner, m.btnH, "export-save-sav", Strings("Cartridge save (.sav)"), {
icon = "download", font = "small",
action = function()
imp._saveExport = nil
imp:_selectSlot(state.scope, state.slotId)
imp:exportSave(state.version)
end,
})
cy = cy + m.btnH + gap
end
Kit.textWrapped("small", converted, x, cy, inner, PAL.muted)
done()
end
local function buildSavePicker(imp, m)
local state = imp._savePicker
imp:_ensureSlots(state.scope)
local slots = imp.slots[state.scope] or {}
local rowH = math.max(m.btnH, Kit.textHeight("button") + 2 * Kit.textHeight("small") + 20 * m.s)
local x, y, w, h, at, maxAt, fy, gap = pickerFrame(imp, m, state, "_savePicker",
Strings("%s saves", gameLabel(state.version)), #slots, rowH)
local py = Kit.scrollBegin(x, y, w, h, at, maxAt)
local rw = w - Kit.scrollGutter(m.s)
for i, slot in ipairs(slots) do
local ry = py + (i - 1) * (rowH + gap)
local key = "_savePicker-row-" .. i
local loaded = imp.activeSlot[state.scope] == slot.id
-- Register clipped rows too so keyboard navigation can reveal them.
local selected = Kit.focusable(key, x, ry, rw, rowH)
if ry + rowH >= y and ry <= y + h then
local hot = Kit.hover(x, ry, rw, rowH)
Kit.card(x, ry, rw, rowH, (selected or hot) and "rowHover" or "row")
if loaded then
Theme.strokeRounded(x, ry, rw, rowH, PAL.green, 0.65, 1, Theme.cardRadius())
end
local label = loaded and Strings("LOADED") or Strings("Load")
local labelW = Kit.textWidth("micro", label) + gap
Kit.text("button", Kit.ellipsize("button", slot.label or slot.name or Strings("NEW GAME"), rw - 3 * gap - labelW),
x + gap, ry + gap, PAL.heading)
Kit.textRight("micro", label, x + rw - gap, ry + gap, loaded and PAL.green or PAL.blue)
Kit.textWrapped("small", slotMeta(slot), x + gap, ry + gap + Kit.textHeight("button") + 4 * m.s,
rw - 2 * gap, PAL.detail, 2)
end
if Kit.press(x, ry, rw, rowH) or Kit._activateId == key then
queueAction(imp, key, function()
imp:_selectSlot(state.scope, slot.id)
imp._savePicker = nil
end)
end
end
Kit.scrollEnd(x, y, w, h, at, maxAt, PAL.surface)
btn(imp, x, fy, (w - gap) / 2, m.btnH, "picker-new-save", Strings("+ New save slot"), {
kind = "good", font = "small", action = function()
imp:_newSlot(state.scope)
imp._savePicker = nil
end,
})
btn(imp, x + (w + gap) / 2, fy, (w - gap) / 2, m.btnH, "picker-save-done", Strings("Done"), {
font = "small", action = function() imp._savePicker = nil end,
})
end
local function buildModGamesPicker(imp, m)
local state, mod = imp._modGames, nil
for _, entry in ipairs(imp.mods or {}) do if entry.id == state.id then mod = entry break end end
if not mod then imp._modGames = nil return end
local rowH = m.btnH + math.floor(12 * m.s)
local x, y, w, h, at, maxAt, fy, gap = pickerFrame(imp, m, state, "_modGames",
Strings("Enable for games"), #gameTabs(imp), rowH)
local py = Kit.scrollBegin(x, y, w, h, at, maxAt)
local rw = w - Kit.scrollGutter(m.s)
for i, game in ipairs(gameTabs(imp)) do
local ry = py + (i - 1) * (rowH + gap)
local on = mod.enabledByVersion and mod.enabledByVersion[game.id] == true
local key = "_modGames-row-" .. i
btn(imp, x, ry, rw, rowH, key, "", {
face = "tab", enabled = not imp.safeMode,
action = function() imp:_toggleMod(mod.id, nil, game.id) end,
})
local sw = math.floor(10 * m.s)
Theme.fillRounded(x + gap, ry + (rowH - sw) / 2, sw, sw, game.color, 1, 2)
Kit.text("button", Kit.ellipsize("button", Strings(game.label), rw - rowH - sw - 3 * gap),
x + 2 * gap + sw, ry + (rowH - Kit.textHeight("button")) / 2, PAL.text)
local box = math.floor(22 * m.s)
local bx, by = x + rw - gap - box, ry + (rowH - box) / 2
Theme.strokeRounded(bx, by, box, box, on and PAL.green or PAL.line, 0.8, 1, 4)
if on then
require("src.ui.kit.Icons").draw("check", bx + 2, by + 2, box - 4, PAL.green)
end
end
Kit.scrollEnd(x, y, w, h, at, maxAt, PAL.surface)
btn(imp, x, fy, w, m.btnH, "picker-games-done", Strings("Done"), {
action = function() imp._modGames = nil end,
})
end
local function buildPrompt(imp, m, spec)
local pad = math.floor(18 * m.s)
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local w = math.floor(460 * m.s)
local inner = LauncherView.modalTextW(m, w, pad)
local hintH = spec.hint and (Kit.wrapHeight("small", spec.hint,
inner, 2) + math.floor(6 * m.s)) or 0
local footH = spec.footnote and (Kit.textHeight("micro")
+ math.floor(8 * m.s)) or 0
local headH = Kit.textHeight("button") + math.floor(10 * m.s)
local px, py, pw, footY, open = modalFrame(imp, m, spec.modal, w, pad,
headH, hintH + fieldH, m.btnH + footH, math.floor(12 * m.s))
Kit.text("button", Kit.ellipsize("button", spec.title, pw - 2 * pad),
px + pad, py + pad, PAL.heading)
local top, _, mark, done = open()
local cy = top
if spec.hint then
cy = cy + Kit.textWrapped("small", spec.hint, px + pad, cy,
inner, PAL.detail, 2) + math.floor(6 * m.s)
end
textField(imp, px + pad, mark(spec.key .. "-field", cy - top, fieldH), inner,
fieldH, spec.key .. "-field", spec.text or "", nil, true)
done()
cy = footY
local place = Layout.rightCluster(px + pad, pw - 2 * pad, math.floor(8 * m.s))
local okW = Kit.textWidth("small", spec.okLabel or Strings("Save"))
+ math.floor(28 * m.s)
btn(imp, place(okW), cy, okW, m.btnH, spec.key .. "-ok",
spec.okLabel or Strings("Save"),
{ kind = "primary", font = "small", action = spec.commit })
local cw = Kit.textWidth("small", Strings("Cancel")) + math.floor(28 * m.s)
btn(imp, place(cw), cy, cw, m.btnH, spec.key .. "-cancel", Strings("Cancel"),
{ font = "small", action = spec.cancel })
if spec.paste then
local pwid = Kit.textWidth("small", Strings("Paste")) + math.floor(28 * m.s)
btn(imp, px + pad, cy, pwid, m.btnH, spec.key .. "-paste", Strings("Paste"),
{ kind = "accent", font = "small", action = spec.paste })
end
cy = cy + m.btnH + math.floor(8 * m.s)
if spec.footnote then
Kit.text("micro", spec.footnote, px + pad, cy, PAL.muted)
end
end
local function buildConfirmModal(imp, m, spec)
local c = spec or imp._modConfirm
local function close()
if spec then spec.close() else imp._modConfirm = nil end
end
local pad = math.floor(22 * m.s)
local w = math.floor(520 * m.s)
local textW = LauncherView.modalTextW(m, w, pad)
local paraGap = math.floor(4 * m.s)
local titleH = Kit.wrapHeight("stat", c.title or Strings("Confirm"), textW)
local bodyH = 0
for _, line in ipairs(c.lines or {}) do
bodyH = bodyH + Kit.wrapHeight("small", line, textW) + paraGap
end
local gap = math.floor(10 * m.s)
local contentH = titleH + math.floor(12 * m.s) + bodyH
+ (c.toggle and math.floor(12 * m.s) + m.btnH or 0)
local px, py, pw, footY, open = modalFrame(imp, m, spec and "_settingsConfirm" or "_modConfirm",
w, pad, 0, contentH, m.btnH, c.toggle and gap or math.floor(12 * m.s))
local top, rw, mark, done = open()
local cy = top
cy = cy + Kit.textWrapped("stat", c.title or Strings("Confirm"), px + pad, cy,
textW, PAL.heading)
cy = cy + math.floor(12 * m.s)
for _, line in ipairs(c.lines or {}) do
cy = cy + Kit.textWrapped("small", line, px + pad, cy, textW,
PAL.detail) + paraGap
end
cy = cy + math.floor(12 * m.s)
if c.toggle then
local t = c.toggle
cy = mark("confirm-toggle", cy - top, m.btnH)
btn(imp, px + pad, cy, rw, m.btnH, "confirm-toggle", "", {
face = "tab",
action = function()
t.on = not t.on
if t.set then t.set(t.on) end
end,
})
local box = math.floor(22 * m.s)
local bx, by = px + pad + gap, cy + (m.btnH - box) / 2
Theme.strokeRounded(bx, by, box, box, t.on and PAL.green or PAL.line, 0.8, 1, 4)
if t.on then
require("src.ui.kit.Icons").draw("check", bx + 2, by + 2, box - 4, PAL.green)
end
Kit.text("small", Kit.ellipsize("small", t.label, rw - box - 3 * gap),
bx + box + gap, cy + (m.btnH - Kit.textHeight("small")) / 2, PAL.text)
end
done()
cy = footY
local halfW = math.floor((pw - 2 * pad - gap) / 2)
btn(imp, px + pad, cy, halfW, m.btnH, "confirm-yes",
c.yesLabel or Strings("OK"), {
kind = "primary", font = "small",
action = function()
close()
if c.onYes then
c.onYes()
elseif c.indexEntry then
imp:_findInstall(c.indexEntry)
elseif c.kind == "cartPins" then
imp:_installCartPins(c.version, c.id)
elseif c.kind == "update" then
imp:_confirmModUpdate(c.id, c.release)
elseif c.kind == "updateAllRun" then
imp:_confirmUpdateAll()
elseif c.kind == "enableAll" then
imp:_setAllMods(true, true)
elseif c.kind == "importOversize" then
imp:_importSave(c.version, c.source, true)
elseif c.kind == "largeImport" then
imp:_importRequiredSource(c.modId, c.importId, c.source, true)
else
imp:_toggleMod(c.id, true, c.version)
end
end,
})
btn(imp, px + pad + halfW + gap, cy, halfW, m.btnH, "confirm-no",
c.noLabel or Strings("Cancel"), { font = "small",
action = function()
close()
if c.onNo then c.onNo() end
end })
end
-- A body of text, paginated rather than scrolled (release notes, mod
-- descriptions). Long-form text is the one place a scrollbar was genuinely
-- convenient, so the pager here moves a LINE window instead of a row window.
local function buildTextModal(imp, m, key, title, body, closeFn)
local pad = math.floor(18 * m.s)
local w = math.floor(520 * m.s)
local h = math.floor(math.min(m.H - 2 * m.pad, 460 * m.s))
local px, py, pw, ph = modalPanel(m, w, h)
local cy = py + pad
local xW = math.max(Kit.tapMin(), math.floor(30 * m.s))
Kit.text("button", Kit.ellipsize("button", title,
pw - 2 * pad - xW - math.floor(8 * m.s)),
px + pad, cy, PAL.heading)
btn(imp, px + pw - pad - xW, cy, xW, xW, key .. "-x", "X",
{ font = "small", action = closeFn })
cy = cy + math.max(Kit.textHeight("button"), xW) + math.floor(10 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local bodyH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s)
- pagerH - math.floor(8 * m.s)
local lineH = Kit.textHeight("small")
local perPage = math.max(1, math.floor(bodyH / lineH))
local lines = Kit.wrapLines("small", body, pw - 2 * pad) or { "" }
local first, last, cur = Kit.pageBounds(page(imp, key), #lines, perPage)
setPage(imp, key, cur)
setPage(imp, key, Kit.wheelPage(px, cy, pw, bodyH, cur, #lines, perPage))
for i = first, last do
Kit.text("small", lines[i], px + pad, cy + (i - first) * lineH, PAL.detail)
end
cy = cy + bodyH + math.floor(8 * m.s)
setPage(imp, key, Kit.pager(px + pad, cy, pw - 2 * pad, cur, #lines,
perPage, key))
cy = cy + pagerH + math.floor(10 * m.s)
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, key .. "-close",
Strings("Close"), { font = "small", action = closeFn })
end
local function buildVersionsModal(imp, m)
local ModUpdate = require("src.mods.ModUpdate")
local v = imp._modVersions
local pad = math.floor(18 * m.s)
local w = math.floor(520 * m.s)
local h = math.floor(math.min(m.H - 2 * m.pad, 480 * m.s))
local px, py, pw, ph = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Kit.ellipsize("button",
Strings("Other versions: ") .. tostring(v.name), pw - 2 * pad),
px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(6 * m.s)
local info = imp:_modUpdateInfo(v.id)
local statusTxt = Strings("Installed: v") .. tostring(v.current)
local statusCol = PAL.detail
if info and info.status == "available" then
statusTxt = statusTxt .. " - " .. Strings("Update v") .. tostring(info.latest)
statusCol = PAL.yellow
elseif info and info.status == "current" then
statusTxt = statusTxt .. " - " .. Strings("Up to date")
statusCol = PAL.green
end
Kit.text("small", statusTxt, px + pad, cy, statusCol)
cy = cy + Kit.textHeight("small") + math.floor(10 * m.s)
local chipH = math.max(Kit.tapMin(), math.floor(28 * m.s))
local rowH = math.floor(8 * m.s) + Kit.textHeight("small")
+ math.floor(4 * m.s) + chipH + math.floor(8 * m.s)
local gap = math.floor(6 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s)
- pagerH - math.floor(8 * m.s)
local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 12)
local n = #v.releases
local first, last, cur = Kit.pageBounds(page(imp, "versions"), n, perPage)
setPage(imp, "versions", cur)
setPage(imp, "versions",
Kit.wheelPage(px, cy, pw, listH, cur, n, perPage))
for i = first, last do
local rel = v.releases[i]
local ry = cy + (i - first) * (rowH + gap)
Theme.stroke(px + pad, ry, pw - 2 * pad, rowH, PAL.line, Theme.A.hairline, 1)
local ix = px + pad + math.floor(10 * m.s)
local inner = pw - 2 * pad - math.floor(20 * m.s)
local text = "v" .. rel.version
if rel.version == v.current then text = text .. Strings(" (installed)") end
if rel.prerelease then text = text .. " pre" end
Kit.text("small", text, ix, ry + math.floor(8 * m.s),
rel.version == v.current and PAL.yellow or PAL.heading)
local preview = ModUpdate.previewLine(rel.body or "", 90)
if preview ~= "" then
Kit.text("micro", Kit.ellipsize("micro", preview,
inner - math.floor(180 * m.s)),
ix + Kit.textWidth("small", text) + math.floor(10 * m.s),
ry + math.floor(8 * m.s), PAL.muted)
end
local ly = ry + math.floor(8 * m.s) + Kit.textHeight("small")
+ math.floor(4 * m.s)
local place = Layout.rightCluster(ix, inner, math.floor(6 * m.s))
if rel.version ~= v.current then
local iw5 = Kit.textWidth("small", Strings("Install")) + math.floor(20 * m.s)
btn(imp, place(iw5), ly, iw5, chipH, "ver-inst-" .. i, Strings("Install"), {
kind = "accent", font = "small",
action = function() imp:_installModVersion(v.id, rel) end })
end
if type(rel.body) == "string" and rel.body:match("%S") then
local rw = Kit.textWidth("small", Strings("Read more")) + math.floor(20 * m.s)
btn(imp, place(rw), ly, rw, chipH, "ver-notes-" .. i, Strings("Read more"), {
kind = "accent", font = "small",
action = function()
imp._modReleaseNotes = { version = rel.version, body = rel.body or "" }
end })
end
end
cy = cy + listH + math.floor(8 * m.s)
setPage(imp, "versions",
Kit.pager(px + pad, cy, pw - 2 * pad, cur, n, perPage, "versions"))
cy = cy + pagerH + math.floor(10 * m.s)
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "versions-close",
Strings("Close"), { font = "small",
action = function() imp._modVersions = nil end })
end
-- Modal for per-profile actions (Duplicate, Rename, Delete) for compact / mobile / RG device compatibility
local function buildSingleProfileActionsModal(imp, m)
local pName = imp._singleProfileActions and imp._singleProfileActions.name
if not pName then imp._singleProfileActions = nil return end
local LauncherMods = require("src.mods.LauncherMods")
local prof = profileState(imp)
local options, profiles = prof.options, prof.list
local pad = math.floor(18 * m.s)
local w = math.min(math.floor(380 * m.s), m.w - 2 * m.pad)
local gap = math.floor(8 * m.s)
local canDelete = (#profiles > 1)
local armed = deleteArmed(imp, "profile", pName, nil)
local btns = {
{
label = Strings("Duplicate profile"),
kind = "accent",
action = function()
LauncherMods.duplicateProfile(pName, options)
imp._singleProfileActions = nil
if imp._refreshMods then imp:_refreshMods() end
end
},
{
label = Strings("Rename profile"),
font = "small",
action = function()
imp._singleProfileActions = nil
imp._profileRenamePrompt = { oldName = pName, text = pName }
imp:_armTextInput(pName)
end
},
}
if canDelete then
btns[#btns + 1] = {
label = DELETE_LABEL(armed),
kind = armed and "warn" or "danger",
keepArm = true,
action = function()
imp:pressDelete("profile", pName, nil, function()
LauncherMods.deleteProfile(pName, options)
imp._singleProfileActions = nil
if imp._refreshMods then imp:_refreshMods() end
end)
end
}
end
local px, py, pw, footY, open = modalFrame(imp, m, "_singleProfileActions", w, pad,
Kit.textHeight("button") + math.floor(12 * m.s), #btns * (m.btnH + gap) - gap,
m.btnH, gap)
Kit.text("button", Kit.ellipsize("button", pName, pw - 2 * pad), px + pad, py + pad, PAL.heading)
local _, rw, mark, done = open()
for i, b in ipairs(btns) do
local id = "profact-" .. i
btn(imp, px + pad, mark(id, (i - 1) * (m.btnH + gap), m.btnH), rw, m.btnH, id, b.label, {
kind = b.kind,
font = "small",
keepArm = b.keepArm,
action = function()
b.action()
if imp._refreshMods then imp:_refreshMods() end
end
})
end
done()
btn(imp, px + pad, footY, pw - 2 * pad, m.btnH, "profact-close",
Strings("Close"), {
font = "small",
action = function() imp._singleProfileActions = nil end })
end
-- Modal for Mod Profiles (#593) - interactive profile manager (switch, edit, duplicate, delete)
local function buildProfilesModal(imp, m)
local LauncherMods = require("src.mods.LauncherMods")
local prof = profileState(imp)
local options, profiles, active = prof.options, prof.list, prof.active
local pad = math.floor(18 * m.s)
local w = math.min(math.floor(460 * m.s), m.w - 2 * m.pad)
local gap = math.floor(8 * m.s)
local rowH = math.max(Kit.tapMin(), math.floor(40 * m.s))
local n = #profiles
local maxVisible = 4
local listH = math.min(maxVisible, math.max(1, n)) * (rowH + gap) - gap
local fullH = math.max(1, n) * (rowH + gap) - gap
local px, py, pw, footY, open = modalFrame(imp, m, "_profilesPopup", w, pad,
Kit.textHeight("button") + math.floor(12 * m.s), m.btnH + gap + fullH,
m.btnH, math.floor(12 * m.s), m.btnH + gap + listH)
Kit.text("button", Strings("Mod Profiles"), px + pad, py + pad, PAL.heading)
local top, rw, mark, done = open()
local cy = top
-- New Profile button
btn(imp, px + pad, mark("prof-new-top", 0, m.btnH), rw, m.btnH, "prof-new-top",
Strings("+ Create New Profile"), {
kind = "accent", font = "small",
action = function()
imp._profileSavePrompt = { text = "PROFILE " .. tostring(#profiles + 1) }
imp:_armTextInput(imp._profileSavePrompt.text)
end,
})
cy = cy + m.btnH + gap
-- Scrollable Profile Rows
for i, p in ipairs(profiles) do
local ry = mark("prof-ed-" .. i, cy - top + (i - 1) * (rowH + gap), rowH)
mark("prof-sw-" .. i, cy - top + (i - 1) * (rowH + gap), rowH)
local isCur = (p.name == active)
Kit.card(px + pad, ry, rw, rowH, isCur)
local rx = px + pad + math.floor(12 * m.s)
local editBtnW = math.floor(64 * m.s)
local swBtnW = isCur and 0 or math.floor(64 * m.s)
local rightClusterW = editBtnW + swBtnW + (isCur and 0 or math.floor(4 * m.s))
local nameW = math.max(math.floor(80 * m.s), rw - 2 * math.floor(12 * m.s) - rightClusterW - math.floor(50 * m.s))
local nameText = Kit.ellipsize("small", p.name, nameW)
Kit.text("small", nameText, rx, ry + (rowH - Kit.textHeight("small")) / 2, isCur and PAL.heading or PAL.muted)
if isCur then
Kit.tag(rx + Kit.textWidth("small", nameText) + math.floor(6 * m.s),
ry + (rowH - Kit.textHeight("micro")) / 2,
Kit.textWidth("micro", Strings("Active")) + math.floor(8 * m.s),
Kit.textHeight("micro"), Strings("Active"), PAL.green)
end
-- Right side controls: [Switch] (if not active) + [Edit]
local place = Layout.rightCluster(px + pad, rw, math.floor(4 * m.s))
-- Edit button (opens per-profile action sheet)
btn(imp, place(editBtnW), ry + math.floor(4 * m.s), editBtnW, rowH - math.floor(8 * m.s), "prof-ed-" .. i,
Strings("Edit"), {
font = "micro",
action = function()
imp._singleProfileActions = { name = p.name }
end,
})
-- Switch button (if not active)
if not isCur then
btn(imp, place(swBtnW), ry + math.floor(4 * m.s), swBtnW, rowH - math.floor(8 * m.s), "prof-sw-" .. i,
Strings("Switch"), {
kind = "good", font = "micro",
enabled = not imp.safeMode,
action = function()
LauncherMods.applyProfile(p.name, options)
if imp._refreshMods then imp:_refreshMods() end
end,
})
end
end
done()
btn(imp, px + pad, footY, pw - 2 * pad, m.btnH, "prof-close",
Strings("Close"), {
font = "small",
action = function() imp._profilesPopup = nil end })
end
-- Modal for MODS tab header actions on mobile / compact displays
local function buildModHeaderActionsModal(imp, m)
local pad = math.floor(18 * m.s)
local w = math.floor(380 * m.s)
local gap = math.floor(8 * m.s)
-- same gate as the header cluster: a cart's mod set is not bulk-editable
local cartId, cartReport
if imp.modCartPlan then cartId, cartReport = imp:modCartPlan() end
local bulkOk = not imp.safeMode and cartId == nil
local updateOk = (imp.updateAllAvailable and imp:updateAllAvailable()) == true
local note = (cartId and imp.cartPinsNote) and imp:cartPinsNote(cartId, cartReport) or nil
local btns = {
{ label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end },
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
{ label = Strings("Update all"), kind = "warn",
enabled = updateOk,
action = function() askUpdateAllMods(imp) end },
{ label = Strings("Enable all mods"), kind = "good", enabled = bulkOk,
action = function() imp:_setAllMods(true) end },
{ label = Strings("Disable all mods"), kind = "warn", enabled = bulkOk,
action = function() imp:_setAllMods(false) end },
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end },
}
local noteW = LauncherView.modalTextW(m, w, pad)
local noteH = note
and (Kit.wrapHeight("small", note, noteW, 3) + gap) or 0
local px, py, pw, footY, open = modalFrame(imp, m, "_modHeaderActionsPopup", w, pad,
Kit.textHeight("button") + math.floor(12 * m.s),
noteH + #btns * (m.btnH + gap) - gap, m.btnH, gap)
Kit.text("button", Strings("More Mod Actions"), px + pad, py + pad, PAL.heading)
local top, rw, mark, done = open()
if note then
Kit.textWrapped("small", note, px + pad, top, noteW, PAL.muted, 3)
end
for i, b in ipairs(btns) do
local id = "modheadact-" .. i
btn(imp, px + pad, mark(id, noteH + (i - 1) * (m.btnH + gap), m.btnH), rw, m.btnH, id, b.label, {
kind = b.kind or "ghost", font = "small",
enabled = b.enabled,
action = function()
imp._modHeaderActionsPopup = nil
b.action()
end
})
end
done()
btn(imp, px + pad, footY, pw - 2 * pad, m.btnH, "modheadact-close",
Strings("Close"), { font = "small",
action = function() imp._modHeaderActionsPopup = nil end })
end
-- Sort chooser, shared by the MODS and FIND MODS tabs (they share the
-- persisted key, so one popup serves both).
local function buildSortModal(imp, m)
local scope = imp._sortPopup
local defs = sortDefs(scope)
local pad = math.floor(18 * m.s)
local w = math.floor(360 * m.s)
local gap = math.floor(8 * m.s)
local canReset = scope == "mods" and imp._resetModOrder ~= nil
local headH = Kit.textHeight("button") + math.floor(12 * m.s)
local rowH = m.btnH + gap
local listH = (#defs + (canReset and 1 or 0)) * rowH - gap
local px, py, pw, ph = modalPanel(m, w, 2 * pad + headH + listH + gap + m.btnH)
Kit.text("button", Strings("Sort by"), px + pad, py + pad, PAL.heading)
local x, iw, bodyY = px + pad, pw - 2 * pad, py + pad + headH
local footY = py + ph - pad - m.btnH
local _, rw, place, done = modalBody(imp, "_sortPopup", x, bodyY, iw,
footY - gap - bodyY, listH)
local cur = currentSort(imp, scope)
for i, s in ipairs(defs) do
local key = s.key
local id = "sortpop-" .. key
btn(imp, x, place(id, (i - 1) * rowH, m.btnH), rw, m.btnH, id, s.label, {
kind = (cur == key) and "primary" or "ghost", font = "small",
action = function()
imp.modSort = key
imp._sortPopup = nil
pcall(function()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.modSort = key
SaveData.saveOptions(opts)
end)
end })
end
if canReset then
local cartOn = imp.modCartPlan and imp:modCartPlan() or nil
btn(imp, x, place("sortpop-reset-order", #defs * rowH, m.btnH), rw, m.btnH,
"sortpop-reset-order",
Strings("Reset load order"), { kind = "warn", font = "small",
enabled = not imp.safeMode and cartOn == nil,
action = function()
imp._sortPopup = nil
imp:_resetModOrder()
end })
end
done()
btn(imp, x, footY, iw, m.btnH, "sortpop-close",
Strings("Close"), { font = "small",
action = function() imp._sortPopup = nil end })
end
-- Game-scope chooser used when the Show-for chips cannot all fit on the
-- mods toolbar (portrait phones). Same options as the chip row.
local function buildModScopeModal(imp, m)
local options = modScopeOptions(imp)
local pad = math.floor(18 * m.s)
local w = math.floor(360 * m.s)
local gap = math.floor(8 * m.s)
local headH = Kit.textHeight("button") + math.floor(12 * m.s)
local rowH = m.btnH + gap
local listH = #options * rowH - gap
local px, py, pw, ph = modalPanel(m, w, 2 * pad + headH + listH + gap + m.btnH)
Kit.text("button", Strings("Show for"), px + pad, py + pad, PAL.heading)
local x, iw, bodyY = px + pad, pw - 2 * pad, py + pad + headH
local footY = py + ph - pad - m.btnH
local _, rw, place, done = modalBody(imp, "_modScopePopup", x, bodyY, iw,
footY - gap - bodyY, listH)
for i, opt in ipairs(options) do
local id = "scopepop-" .. tostring(opt.id or "all")
btn(imp, x, place(id, (i - 1) * rowH, m.btnH), rw, m.btnH, id, opt.label, {
kind = (imp.modScope == opt.id) and "primary" or "ghost", font = "small",
action = function()
imp:_setModScope(opt.id)
imp._modScopePopup = nil
end })
end
done()
btn(imp, x, footY, iw, m.btnH, "scopepop-close",
Strings("Close"), { font = "small",
action = function() imp._modScopePopup = nil end })
end
-- The cartridge picker uses two columns and spatial controller navigation.
local function buildGameModal(imp, m)
local pad = math.floor(18 * m.s)
local headH = Kit.textHeight("button") + math.floor(12 * m.s)
local avail = m.H - 2 * m.pad
local cols, gap, btnH = 2, math.floor(8 * m.s), m.btnH
local tabs = gameTabs(imp)
local function rows() return math.ceil(#tabs / cols) + 1 end
local function total() return 2 * pad + headH + rows() * btnH
+ (rows() - 1) * gap end
if total() > avail then gap = math.max(2, math.floor(3 * m.s)) end
if total() > avail then
btnH = math.max(Kit.tapMin(),
btnH - math.ceil((total() - avail) / rows()))
end
local nrows = rows() - 1
local w = math.floor(440 * m.s)
local px, py, pw, footY, open = modalFrame(imp, m, "_gamePopup", w, pad,
headH, nrows * (btnH + gap) - gap, btnH, gap)
Kit.text("button", Strings("Choose game"), px + pad, py + pad, PAL.heading)
local _, rw, mark, done = open()
local chrome = headerChrome(imp)
local colW = math.floor((rw - (cols - 1) * gap) / cols)
for i, g in ipairs(tabs) do
local bx = px + pad + ((i - 1) % cols) * (colW + gap)
local id = "gamepop-" .. g.id
btn(imp, bx, mark(id, math.floor((i - 1) / cols) * (btnH + gap), btnH), colW, btnH, id,
Strings(g.label), {
face = "tab", font = "small", letter = g.letter, color = g.color,
active = imp.tab == g.id,
action = chrome.tab[g.id] })
end
done()
btn(imp, px + pad, footY, pw - 2 * pad, btnH, "gamepop-close",
Strings("Close"), { font = "small",
action = function() imp._gamePopup = nil end })
end
local function buildSecretModal(imp, m)
local pad = math.floor(22 * m.s)
local w = math.floor(300 * m.s)
local titleH = Kit.textHeight("stat")
local px, _, pw, footY, open = modalFrame(imp, m, "_secretPopup", w, pad,
0, titleH, m.btnH, math.floor(16 * m.s))
local top, rw, _, done = open()
Kit.textCenter("stat", "BLITZ!", px + pad, top, rw, PAL.heading)
done()
btn(imp, px + pad, footY, pw - 2 * pad, m.btnH, "secret-ok", Strings("OK"), {
kind = "primary", font = "small",
action = function() imp:_confirmSecret() end })
end
local SEAL_WORD = { open = "open", ["sealed+"] = "sealed+" }
local function webClipAvailable(imp)
return imp.ios and love.system and love.system.installWebClip ~= nil
end
local function requestWebClip(imp, version, cartId)
local ok, text = WebClip.install(version, cartId)
imp._webClipNotice = {
key = tostring(version) .. ":" .. tostring(cartId or ""),
ok = ok,
text = ok and ("Home Screen entry ready for " .. tostring(text)) or text,
}
return ok
end
local function buildCartModal(imp, m)
local version = imp._cartPopup
local rows = imp:_ensureCarts(version)
local active = imp.activeCart[version]
local state = imp._cartPicker
if not state or state.version ~= version then
state = { version = version, scroll = 0 }
imp._cartPicker = state
end
local pad, gap = math.floor(16 * m.s), math.floor(8 * m.s)
local w = math.min(math.floor(480 * m.s), m.W - 2 * m.pad)
local inner = w - 2 * pad
local canWebClip = webClipAvailable(imp)
local iconExtra = math.floor(m.btnH * 0.42) + math.floor(7 * Kit.scale)
local actionMin = 0
for _, label in ipairs({Strings("Use cart"), Strings("Selected"), Strings("Export"),
DELETE_LABEL(false), DELETE_LABEL(true), canWebClip and Strings("Home Screen") or ""}) do
actionMin = math.max(actionMin, chipWidth(label, m) + iconExtra)
end
local available = inner - Kit.scrollGutter(m.s) - 2 * gap
local cols = math.max(1, math.min(3, math.floor((available + gap) / (actionMin + gap))))
local importLabel = imp:_cartImportButtonLabel()
local importW = chipWidth(importLabel, m) + iconExtra
local closeW = chipWidth(Strings("Close"), m)
local footerStacked = importW + gap + closeW > inner
local footerH = footerStacked and 2 * m.btnH + gap or m.btnH
local actionRows = math.ceil((canWebClip and 4 or 3) / cols)
local rowH = 2 * gap + Kit.textHeight("button") + 4 * m.s + Kit.textHeight("small")
+ gap + actionRows * m.btnH + (actionRows - 1) * gap
local notice = imp._cartNotice
local webNotice = imp._webClipNotice
if webNotice and tostring(webNotice.key):sub(1, #version + 1) == version .. ":" then
notice = webNotice.text
end
local hint = notice or Strings("Choose a cart to play. Deleting a cart keeps its saves and installed mods.")
local hintH = Kit.wrapHeight("small", hint, inner, 3)
local fixed = 2 * pad + 2 * m.btnH + 3 * gap + hintH + footerH + gap
local wanted = fixed + (#rows > 0 and math.min(#rows, 3) * (rowH + gap) - gap or m.btnH)
local px, py, pw, ph = modalPanel(m, w, wanted)
local x, cy = px + pad, py + pad
Kit.textBold("button", Strings("Custom Carts"), x,
cy + (m.btnH - Kit.textHeight("button")) / 2, PAL.heading)
btn(imp, px + pw - pad - m.btnH, cy, m.btnH, m.btnH, "cartpop-x", "", {
face = "invert", icon = "x", action = function() imp._cartPopup = nil end,
})
cy = cy + m.btnH + gap
cy = cy + Kit.textWrapped("small", hint, x, cy, inner, PAL.muted, 3) + gap
btn(imp, x, cy, inner, m.btnH, "cartpop-vanilla", (GameVersion.info(version) or {}).displayName or gameLabel(version), {
face = "tab", active = active == nil, color = PAL.green, font = "small",
action = function() imp:_selectCart(version, nil) end,
})
cy = cy + m.btnH + gap
local fy = py + ph - pad - footerH
local viewH = math.max(0, fy - gap - cy)
local maxAt = Kit.scrollExtent(#rows * (rowH + gap) - gap, viewH)
state.rect = state.rect or {}
state.rect.x, state.rect.y, state.rect.w, state.rect.h = x, cy, inner, viewH
state.maxScroll = maxAt
state.scroll = Kit.scrollInput(state.scroll, maxAt, x, cy, inner, viewH)
if Kit._ringShown and Kit.focusId ~= state.lastFocus then
for i, row in ipairs(rows) do
local key = "cartpop-id-" .. row.id
if Kit.focusId and (Kit.focusId == key or Kit.focusId:sub(1, #key + 1) == key .. "-") then
local top = (i - 1) * (rowH + gap)
state.scroll = Kit.scrollClamp(math.max(top + rowH - viewH, math.min(state.scroll, top)), maxAt)
break
end
end
end
state.lastFocus = Kit.focusId
local listY = Kit.scrollBegin(x, cy, inner, viewH, state.scroll, maxAt)
local rw = inner - Kit.scrollGutter(m.s)
if #rows == 0 then
Kit.textWrapped("small", Strings("No carts installed for this game yet."), x, listY, rw, PAL.muted)
end
for i, row in ipairs(rows) do
local ry = listY + (i - 1) * (rowH + gap)
local key = "cartpop-id-" .. row.id
Kit.card(x, ry, rw, rowH, "row")
if active == row.id then Theme.strokeRounded(x, ry, rw, rowH, PAL.green, 0.65, 1, Theme.cardRadius()) end
local tx, ty = x + gap, ry + gap
Kit.textBold("button", Kit.ellipsize("button", row.title or row.id, rw - 2 * gap), tx, ty, PAL.heading)
ty = ty + Kit.textHeight("button") + 4 * m.s
local meta = "v" .. tostring(row.version or "?") .. " / " .. Strings(SEAL_WORD[row.seal] or "sealed")
if active == row.id then meta = meta .. " / " .. Strings("Selected") end
Kit.text("small", Kit.ellipsize("small", meta, rw - 2 * gap), tx, ty, PAL.muted)
ty = ty + Kit.textHeight("small") + gap
local actions = {
{ key = key, label = active == row.id and Strings("Selected") or Strings("Use cart"),
icon = "check", enabled = active ~= row.id,
action = function() imp:_selectCart(version, row.id) end },
{ key = key .. "-export", label = Strings("Export"), icon = "upload",
action = function() imp:exportCart(row.id) end },
{ key = key .. "-delete", label = DELETE_LABEL(deleteArmed(imp, "cart", row.id, version)),
icon = "trash", kind = "danger", keepArm = true,
action = function()
imp:pressDelete("cart", row.id, version, function() imp:deleteCart(version, row.id) end)
end },
}
if canWebClip then
actions[#actions + 1] = { key = key .. "-webclip", label = Strings("Home Screen"),
action = function() if requestWebClip(imp, version, row.id) then imp._cartPopup = nil end end }
end
local cw = (rw - 2 * gap - (cols - 1) * gap) / cols
for j, action in ipairs(actions) do
btn(imp, tx + ((j - 1) % cols) * (cw + gap), ty + math.floor((j - 1) / cols) * (m.btnH + gap),
cw, m.btnH, action.key, action.label, {
font = "small", icon = action.icon, kind = action.kind, enabled = action.enabled,
keepArm = action.keepArm, action = action.action,
})
end
end
Kit.scrollEnd(x, cy, inner, viewH, state.scroll, maxAt, PAL.surface)
local firstW = footerStacked and inner or math.max(importW, (inner - gap) / 2)
btn(imp, x, fy, firstW, m.btnH, "cartpop-more", importLabel, {
kind = "accent", font = "small", icon = "download", action = function() imp:importCartFile(version) end,
})
btn(imp, footerStacked and x or x + firstW + gap, footerStacked and fy + m.btnH + gap or fy,
footerStacked and inner or inner - firstW - gap, m.btnH, "cartpop-close", Strings("Close"), {
font = "small", action = function() imp._cartPopup = nil; imp._cartNotice = nil end,
})
end
local CART_PIN_LINES = 4
local function cartPinLines(pins)
local out = {}
for i = 1, math.min(#pins, CART_PIN_LINES) do
local pin = pins[i]
out[#out + 1] = Strings("%s - %s", tostring(pin.name or pin.id),
tostring(pin.reason or ""))
end
if #pins > CART_PIN_LINES then
out[#out + 1] = Strings("...and %d more.", #pins - CART_PIN_LINES)
end
return out
end
local function buildCartSaveModal(imp, m)
local st = imp._cartSave
local info = GameVersion.info(st.version)
local gameName = (info and (info.launcherName or info.displayName))
or tostring(st.version)
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local w = math.floor(500 * m.s)
local inner = LauncherView.modalTextW(m, w, pad)
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local hint = (st.count == 1)
and Strings("This freezes the 1 mod enabled for %s into a cart.", gameName)
or Strings("This freezes the %d mods enabled for %s into a cart.",
st.count, gameName)
local id = imp:_cartSaveId()
local meta = id
and Strings("id %s - v%s - by %s", id, st.cartVersion, st.author)
or Strings("Type a title - the cart id is built from it.")
local pins = st.unresolved or {}
local pinHead = (#pins > 0) and ((#pins == 1)
and Strings("1 mod could only be pinned to this install:")
or Strings("%d mods could only be pinned to this install:", #pins)) or nil
local pinRows = pinHead and cartPinLines(pins) or {}
local share = st.publishable
and Strings("Every mod is pinned to a release, so this cart can be shared.")
or Strings("This cart can be saved and played here while those mods stay installed at these versions, and cannot be shared.")
local shareCol = st.publishable and PAL.green or PAL.yellow
local pinIndent = math.floor(10 * m.s)
local hintH = Kit.wrapHeight("small", hint, inner, 3) + gap
local metaH = Kit.textHeight("micro") + gap
local errH = st.error
and (Kit.wrapHeight("small", st.error, inner, 2) + gap) or 0
local pinH = 0
if pinHead then
pinH = Kit.textHeight("small") + math.floor(4 * m.s)
for _, line in ipairs(pinRows) do
pinH = pinH + Kit.wrapHeight("small", line, inner - pinIndent, 2)
+ math.floor(2 * m.s)
end
pinH = pinH + gap
end
local shareH = Kit.wrapHeight("small", share, inner, 2) + gap
local footH = Kit.textHeight("micro") + math.floor(8 * m.s)
local px, py, pw, footY, open = modalFrame(imp, m, "_cartSave", w, pad,
Kit.textHeight("button") + math.floor(10 * m.s),
hintH + fieldH + gap + metaH + errH + pinH + shareH - gap, m.btnH + footH, gap)
Kit.text("button", Strings("Save as cart"), px + pad, py + pad, PAL.heading)
local top, _, mark, done = open()
local cy = top
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, inner,
PAL.detail, 3) + gap
textField(imp, px + pad, mark("cartsave-field", cy - top, fieldH), inner, fieldH,
"cartsave-field", st.text or "", Strings("Cart title"), true)
cy = cy + fieldH + gap
Kit.text("micro", Kit.ellipsize("micro", meta, inner), px + pad, cy,
PAL.muted)
cy = cy + Kit.textHeight("micro") + gap
if st.error then
cy = cy + Kit.textWrapped("small", st.error, px + pad, cy, inner,
PAL.red, 2) + gap
end
if pinHead then
Kit.text("small", Kit.ellipsize("small", pinHead, inner),
px + pad, cy, PAL.yellow)
cy = cy + Kit.textHeight("small") + math.floor(4 * m.s)
for _, line in ipairs(pinRows) do
cy = cy + Kit.textWrapped("small", line, px + pad + pinIndent, cy,
inner - pinIndent, PAL.detail, 2) + math.floor(2 * m.s)
end
cy = cy + gap
end
Kit.textWrapped("small", share, px + pad, cy, inner, shareCol, 2)
done()
cy = footY
local place = Layout.rightCluster(px + pad, pw - 2 * pad, math.floor(8 * m.s))
local okLabel = Strings("Save as cart")
local okW = Kit.textWidth("small", okLabel) + math.floor(28 * m.s)
btn(imp, place(okW), cy, okW, m.btnH, "cartsave-ok", okLabel,
{ kind = "primary", font = "small",
action = function() imp:_commitCartSave() end })
local cw = Kit.textWidth("small", Strings("Cancel")) + math.floor(28 * m.s)
btn(imp, place(cw), cy, cw, m.btnH, "cartsave-cancel", Strings("Cancel"),
{ font = "small", action = function() imp:_cancelCartSave() end })
cy = cy + m.btnH + math.floor(8 * m.s)
Kit.text("micro", Strings("Enter to save - Esc to cancel"), px + pad, cy,
PAL.muted)
end
local function findGameOptions(imp)
local out = { { key = nil, label = Strings("All games") } }
local seen = {}
local games = SecretGames.order(imp)
for _, version in ipairs(games) do
local gen = GameVersion.generation(version)
if gen and not seen[gen] then
seen[gen] = true
out[#out + 1] = { key = "gen" .. gen, label = Strings("Gen %d", gen) }
end
end
for _, version in ipairs(games) do
if imp.ready and imp.ready[version] then
out[#out + 1] = { key = version, label = gameLabel(version) }
end
end
return out
end
local function buildFilterModal(imp, m)
local carts = imp.findKind == "carts"
local keys = (imp.findIndex
and (carts and imp.findIndex.baseGames or imp.findIndex.categories)) or {}
local items = { { key = nil, label = Strings("All") } }
for _, c in ipairs(keys) do
items[#items + 1] = { key = c, label = carts and gameLabel(c) or c }
end
local games = findGameOptions(imp)
local pad = math.floor(18 * m.s)
local w = math.floor(440 * m.s)
local gap = math.floor(8 * m.s)
local nrows = math.ceil(#items / 2)
local grows = math.ceil(#games / 3)
local headH = Kit.textHeight("button") + math.floor(12 * m.s)
local rowH = m.btnH + gap
local subY = grows * rowH + math.floor(6 * m.s)
local contentH = subY + headH + nrows * rowH - gap
local px, py, pw, ph = modalPanel(m, w, 2 * pad + headH + contentH + gap + m.btnH)
Kit.text("button", Strings("Filter by game"), px + pad, py + pad, PAL.heading)
local x, iw, bodyY = px + pad, pw - 2 * pad, py + pad + headH
local footY = py + ph - pad - m.btnH
local top, rw, place, done = modalBody(imp, "_filterPopup", x, bodyY, iw,
footY - gap - bodyY, contentH)
local gColW = math.floor((rw - 2 * gap) / 3)
for i, it in ipairs(games) do
local bx = x + ((i - 1) % 3) * (gColW + gap)
local key = it.key
local id = "filterpopgame-" .. (key or "all")
btn(imp, bx, place(id, math.floor((i - 1) / 3) * rowH, m.btnH), gColW, m.btnH, id,
it.label, {
kind = (imp.findGame == key) and "primary" or "ghost",
font = "small",
action = function()
imp:_setFindGame(key)
setPage(imp, "find", 1)
imp._filterPopup = nil
end })
end
Kit.text("button", carts and Strings("Filter by base game")
or Strings("Filter by category"), x, top + subY, PAL.heading)
local colW = math.floor((rw - gap) / 2)
local active = carts and imp.findBase or imp.findCategory
for i, it in ipairs(items) do
local bx = x + ((i - 1) % 2) * (colW + gap)
local key = it.key
local id = "filterpop-" .. (key or "all")
btn(imp, bx, place(id, subY + headH + math.floor((i - 1) / 2) * rowH, m.btnH),
colW, m.btnH, id, it.label, {
kind = (active == key) and "primary" or "ghost",
font = "small",
action = function()
if carts then imp.findBase = key else imp.findCategory = key end
setPage(imp, "find", 1)
imp._filterPopup = nil
end })
end
done()
btn(imp, x, footY, iw, m.btnH, "filterpop-close",
Strings("Close"), { font = "small",
action = function() imp._filterPopup = nil end })
end
-- Index manager: every source with its Remove, plus Add and Refresh all.
-- This replaces both the old always-visible source rows above the search
-- field and the lone "Add index" header button.
local function buildIndexesModal(imp, m)
local sources = imp.findSources or {}
local pad = math.floor(18 * m.s)
local w = math.floor(520 * m.s)
local gap = math.floor(6 * m.s)
local rowH = math.max(Kit.tapMin(), math.floor(34 * m.s))
local listH = (#sources > 0) and #sources * (rowH + gap)
or (Kit.textHeight("small") + gap)
local headH = Kit.textHeight("button") + math.floor(12 * m.s)
local bgap = math.floor(8 * m.s)
local addY = listH + math.floor(6 * m.s)
local contentH = addY + m.btnH + bgap + m.btnH
local px, py, pw, ph = modalPanel(m, w, 2 * pad + headH + contentH + bgap + m.btnH)
Kit.text("button", Strings("Mod indexes"), px + pad, py + pad, PAL.heading)
local x, iw, bodyY = px + pad, pw - 2 * pad, py + pad + headH
local footY = py + ph - pad - m.btnH
local top, rw, place, done = modalBody(imp, "_indexManage", x, bodyY, iw,
footY - bgap - bodyY, contentH)
if #sources == 0 then
Kit.text("small", Strings("No index added yet."), x, top, PAL.muted)
else
for i, source in ipairs(sources) do
local feed = source.feed
local id = "idx-rm-" .. tostring(feed)
local cy = place(id, (i - 1) * (rowH + gap), rowH)
local rmW = Kit.textWidth("small", Strings("Remove"))
+ math.floor(20 * m.s)
Kit.text("small", Kit.ellipsize("small", source.label or feed,
rw - rmW - math.floor(12 * m.s)), x,
cy + (rowH - Kit.textHeight("small")) / 2, PAL.detail)
btn(imp, x + rw - rmW, cy, rmW, rowH,
id, Strings("Remove"), {
kind = "danger", font = "small",
action = function() imp:_removeIndex(feed) end })
end
end
btn(imp, x, place("idx-add", addY, m.btnH), rw, m.btnH, "idx-add",
Strings("Add index"), { kind = "accent", font = "small",
action = function() imp:_promptAddIndex() end })
btn(imp, x, place("idx-refresh", addY + m.btnH + bgap, m.btnH), rw, m.btnH, "idx-refresh",
Strings("Refresh all"), {
kind = "accent", font = "small", enabled = #sources > 0,
action = function()
imp._findSearchFocus = false
imp:_disarmTextInput()
imp:_refreshFind(true)
end })
done()
btn(imp, x, footY, iw, m.btnH, "idx-close",
Strings("Close"), { font = "small",
action = function() imp._indexManage = nil end })
end
-- Per-mod actions for the MODS tab: the row itself only carries the enable
-- toggle, everything episodic (update check, versions, delete) lives here.
local SKIN_EXPORTS = {
{ id = "native", key = "skinact-exp-native", label = "Export as gen1recomp .zip" },
{ id = "retroarch", key = "skinact-exp-ra", label = "Export as RetroArch .zip" },
{ id = "delta", key = "skinact-exp-delta", label = "Export as Delta .deltaskin" },
}
local function buildSkinActionsModal(imp, m)
local id = imp._skinActions and imp._skinActions.id
if not id then imp._skinActions = nil return end
local entry
for _, e in ipairs(imp:_ensureSkins()) do
if e.id == id then entry = e break end
end
if not entry then imp._skinActions = nil return end
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local rows = #SKIN_EXPORTS + 2 + (imp.onOpenSkinStudio and 1 or 0)
+ (imp._skinExport and imp._skinExport.dir and 1 or 0)
local px, py, pw, footY, open = modalFrame(imp, m, "_skinActions", math.floor(440 * m.s), pad,
Kit.textHeight("button") + math.floor(4 * m.s) + Kit.textHeight("small") + math.floor(12 * m.s),
(rows - 1) * (m.btnH + gap) - gap, m.btnH, gap)
local cy = py + pad
Kit.text("button", Kit.ellipsize("button", entry.id, pw - 2 * pad),
px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
local fmt = SKIN_FORMAT_LABEL[entry.format or ""] or Strings("unknown format")
Kit.text("small", Kit.ellipsize("small",
fmt .. " \194\183 " .. entry.pages .. " " .. Strings("pages")
.. " \194\183 " .. entry.controls .. " " .. Strings("buttons"),
pw - 2 * pad), px + pad, cy, PAL.muted)
local top, rw, mark, done = open()
cy = top
local function row(id)
return mark(id, cy - top, m.btnH)
end
btn(imp, px + pad, row("skinact-use"), rw, m.btnH, "skinact-use",
Strings("Use this skin"), { kind = "primary", font = "small",
action = function()
imp:_useSkin(id)
imp._skinActions = nil
end })
cy = cy + m.btnH + gap
if imp.onOpenSkinStudio then
btn(imp, px + pad, row("skinact-edit"), rw, m.btnH, "skinact-edit",
Strings("Open in Skin Studio"), { kind = "accent", font = "small",
action = function()
imp._skinActions = nil
imp.onOpenSkinStudio(imp.modScope or "red", id)
end })
cy = cy + m.btnH + gap
end
for _, spec in ipairs(SKIN_EXPORTS) do
btn(imp, px + pad, row(spec.key), rw, m.btnH, spec.key, Strings(spec.label), {
font = "small",
action = function()
imp:_exportSkin(id, spec.id)
imp._skinActions = nil
end })
cy = cy + m.btnH + gap
end
if imp._skinExport and imp._skinExport.dir then
btn(imp, px + pad, row("skinact-reveal"), rw, m.btnH, "skinact-reveal",
Strings("Show the exported file"), { font = "small",
action = function() imp:_revealSkinExport() end })
end
done()
btn(imp, px + pad, footY, pw - 2 * pad, m.btnH, "skinact-close",
Strings("Close"), { font = "small",
action = function() imp._skinActions = nil end })
end
local function buildModActionsModal(imp, m)
local mod
for _, mm in ipairs(imp.mods or {}) do
if mm.id == imp._modActions then mod = mm break end
end
if not mod then imp._modActions = nil return end
local hasGit = mod.github and mod.github ~= ""
local depSpecs = mod.dependencySpecs or (mod.manifest and mod.manifest.dependencySpecs)
local hasDeps = depSpecs and #depSpecs > 0
local imports = mod.imports or mod.requiredImports
local hasImports = imports and #imports > 0
local info = hasGit and imp:_modUpdateInfo(mod.id)
local pad = math.floor(18 * m.s)
local w = math.floor(440 * m.s)
local gap = math.floor(8 * m.s)
local cartOn = imp.modCartPlan and imp:modCartPlan() or nil
local canOrder = not imp.safeMode and cartOn == nil and #(imp.mods or {}) > 1
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0)
+ (hasImports and 1 or 0) + 2 + (canOrder and 2 or 0)
local px, py, pw, footY, open = modalFrame(imp, m, "_modActions", w, pad,
Kit.textHeight("button") + math.floor(4 * m.s) + Kit.textHeight("small") + math.floor(12 * m.s),
(nBtns - 1) * (m.btnH + gap) - gap, m.btnH, gap)
local cy = py + pad
Kit.text("button", Kit.ellipsize("button", mod.name, pw - 2 * pad),
px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
local statusText, statusCol = modStatusColor(mod.status)
local line = "v" .. tostring(mod.version or "?") .. " " .. statusText
if info and info.status == "available" then
line = line .. " " .. Strings("v%s available", tostring(info.latest))
elseif info and info.status == "current" then
line = line .. " " .. Strings("up to date")
end
Kit.text("small", Kit.ellipsize("small", line, pw - 2 * pad),
px + pad, cy, statusCol)
local top, rw, mark, done = open()
cy = top
local function row(key)
return mark(key, cy - top, m.btnH)
end
local id = mod.id
if canOrder then
local mine = tonumber(mod.loadRank) or math.huge
local at, n = 1, #(imp.mods or {})
for _, mm in ipairs(imp.mods or {}) do
local r = tonumber(mm.loadRank) or math.huge
if mm.id ~= id and (r < mine or (r == mine and mm.id < id)) then
at = at + 1
end
end
local half = math.floor((rw - gap) / 2)
local moves = {
{ "modact-up", Strings("Move up"), -1, at and at > 1 },
{ "modact-down", Strings("Move down"), 1, at and at < n },
{ "modact-top", Strings("Move to top"), -math.huge, at and at > 1 },
{ "modact-bottom", Strings("Move to bottom"), math.huge, at and at < n },
}
for k, mv in ipairs(moves) do
local bx = px + pad + ((k % 2 == 0) and (half + gap) or 0)
local delta = mv[3]
btn(imp, bx, row(mv[1]), half, m.btnH, mv[1], mv[2], {
font = "small", enabled = mv[4] == true,
action = function() imp:_moveMod(id, delta) end })
if k % 2 == 0 then cy = cy + m.btnH + gap end
end
end
if hasGit then
local updLabel, updKind = Strings("Check for updates"), "ghost"
if info and info.status == "available" then
updLabel, updKind = Strings("Update"), "warn"
elseif info and info.status == "current" then
updLabel = Strings("Check again")
end
btn(imp, px + pad, row("modact-upd"), rw, m.btnH, "modact-upd", updLabel, {
kind = updKind, font = "small",
action = function() imp:_modGithubAction(id, "update") end })
cy = cy + m.btnH + gap
btn(imp, px + pad, row("modact-ver"), rw, m.btnH, "modact-ver",
Strings("Versions"), { kind = "accent", font = "small",
action = function() imp:_modGithubAction(id, "versions") end })
cy = cy + m.btnH + gap
end
if hasDeps then
btn(imp, px + pad, row("modact-deps"), rw, m.btnH, "modact-deps",
Strings("Check dependencies"), {
kind = "accent", font = "small",
action = function()
local LauncherMods = require("src.mods.LauncherMods")
local depCheck = LauncherMods.checkDependencies(mod.manifest or mod)
if depCheck then
imp._modDepResolver = depCheck
end
imp._modActions = nil
end })
cy = cy + m.btnH + gap
end
if hasImports then
local missing = tonumber(mod.missingRequiredImports) or 0
local label = missing > 0
and Strings("Imported files (%d required)", missing)
or Strings("Imported files")
btn(imp, px + pad, row("modact-imports"), rw, m.btnH, "modact-imports",
label, { kind = missing > 0 and "warn" or "accent", font = "small",
action = function()
imp._modImports = id
imp._modActions = nil
end })
cy = cy + m.btnH + gap
end
local armed = deleteArmed(imp, "mod", id, nil)
btn(imp, px + pad, row("modact-del"), rw, m.btnH, "modact-del",
DELETE_LABEL(armed), {
kind = "danger", font = "small", keepArm = true,
action = function()
imp:pressDelete("mod", id, nil, function()
imp:_deleteMod(id)
imp._modActions = nil
end)
end })
done()
btn(imp, px + pad, footY, pw - 2 * pad, m.btnH, "modact-close",
Strings("Close"), { font = "small",
action = function() imp._modActions = nil end })
end
-- Imported files declared by one installed mod. The engine picks, validates,
-- canonicalizes and copies; this surface never exposes a host path to mod code.
local function buildRequiredImportsModal(imp, m)
local mod
for _, candidate in ipairs(imp.mods or {}) do
if candidate.id == imp._modImports then mod = candidate break end
end
if not mod then imp._modImports = nil return end
local imports = mod.imports or mod.requiredImports or {}
local pad, gap = math.floor(18 * m.s), math.floor(8 * m.s)
local w = math.floor(540 * m.s)
local notice = imp.requiredImportNotice
if not notice or notice.modId ~= mod.id then notice = nil end
local noticeText
if notice then
local importName = notice.importId
for _, row in ipairs(imports) do
if row.id == notice.importId then importName = row.name break end
end
noticeText = Strings("%s rejected: %s", importName, notice.text)
end
local noticeW = LauncherView.modalTextW(m, w, pad)
local noticeH = noticeText and Kit.wrapHeight("small", noticeText, noticeW, 2) or 0
local rowH = math.max(math.floor(70 * m.s), m.btnH)
local perPage = math.min(4, math.max(1, #imports))
local pagerH = #imports > perPage and math.max(Kit.tapMin(), math.floor(30 * m.s)) or 0
local px, py, pw, footY, open = modalFrame(imp, m, "_modImports", w, pad,
Kit.textHeight("button") + math.floor(4 * m.s) + Kit.textHeight("small") + math.floor(12 * m.s),
noticeH + (noticeH > 0 and gap or 0)
+ perPage * rowH + math.max(0, perPage - 1) * gap
+ (pagerH > 0 and (gap + pagerH) or 0), m.btnH, gap)
local cy = py + pad
Kit.text("button", Kit.ellipsize("button", mod.name, pw - 2 * pad),
px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
Kit.text("small", Strings("User-supplied files are validated by MD5 and copied into this mod only."),
px + pad, cy, PAL.muted)
local top, rw, mark, done = open()
cy = top
if noticeText then
cy = cy + Kit.textWrapped("small", noticeText, px + pad, cy,
noticeW, PAL.red, 2) + gap
end
local pageKey = "required-imports-" .. mod.id
local cur = page(imp, pageKey)
local first, last, bounded = Kit.pageBounds(cur, #imports, perPage)
setPage(imp, pageKey, bounded)
for i = first, last do
local row = imports[i]
local importId = row.id
local pickId = "req-pick-" .. mod.id .. "-" .. importId
cy = mark(pickId, cy - top, rowH)
mark("req-remove-" .. mod.id .. "-" .. importId, cy - top, rowH)
Kit.card(px + pad, cy, rw, rowH, row.present and "muted" or false)
local innerX = px + pad + math.floor(12 * m.s)
local actionW = math.floor(108 * m.s)
local removeW = row.present and math.floor(86 * m.s) or 0
local actionX = px + pad + rw - math.floor(10 * m.s) - actionW
if removeW > 0 then actionX = actionX - removeW - math.floor(6 * m.s) end
local textW = actionX - innerX - math.floor(8 * m.s)
Kit.text("small", Kit.ellipsize("small", row.name, textW), innerX,
cy + math.floor(8 * m.s), PAL.heading)
local stateY = cy + math.floor(8 * m.s) + Kit.textHeight("small")
+ math.floor(3 * m.s)
if row.description and row.description ~= "" then
Kit.text("micro", Kit.ellipsize("micro", row.description, textW),
innerX, stateY, PAL.muted)
stateY = stateY + Kit.textHeight("micro") + math.floor(2 * m.s)
end
local state = row.present and Strings("Ready - %s", row.file)
or (row.error and Strings("Invalid file - choose again")
or (row.required and Strings("Required - %s", row.file)
or Strings("Optional - %s", row.file)))
Kit.text("micro", Kit.ellipsize("micro", state, textW), innerX, stateY,
row.present and PAL.green or (row.required and PAL.yellow or PAL.muted))
btn(imp, actionX, cy + (rowH - m.btnH) / 2, actionW, m.btnH, pickId,
row.present and Strings("Replace") or Strings("Choose file"), {
kind = row.present and "ghost" or "accent", font = "small",
action = function() imp:chooseRequiredImport(mod.id, importId) end })
if row.present then
local deleteId = mod.id .. ":" .. importId
local armed = deleteArmed(imp, "required-import", deleteId, nil)
btn(imp, actionX + actionW + math.floor(6 * m.s),
cy + (rowH - m.btnH) / 2, removeW, m.btnH,
"req-remove-" .. mod.id .. "-" .. row.id, DELETE_LABEL(armed), {
kind = "danger", font = "small", keepArm = true,
action = function()
imp:pressDelete("required-import", deleteId, nil, function()
imp:_removeRequiredImport(mod.id, importId)
end)
end })
end
cy = cy + rowH + gap
end
if pagerH > 0 then
local newPage = Kit.pager(px + pad, cy, rw, bounded,
#imports, perPage, pageKey)
setPage(imp, pageKey, newPage)
end
done()
btn(imp, px + pad, footY, pw - 2 * pad, m.btnH, "req-close", Strings("Close"), {
font = "small", action = function() imp._modImports = nil end })
end
-- Per-mod popup for FIND MODS: the row is a plain click, and Install /
-- Details / Source live here instead of crowding every row.
local function buildFindEntryModal(imp, m)
local ModIndex = require("src.mods.ModIndex")
local ModUpdate = require("src.mods.ModUpdate")
local entry = imp._findEntry
local installed = imp:_findInstalledMap()
local action, note = findActionFor(entry, installed[entry.id])
local pad = math.floor(18 * m.s)
local w = math.floor(460 * m.s)
local gap = math.floor(8 * m.s)
local nBtns = 3 -- install row, details/source row, close row
local noteH = note and (Kit.textHeight("small") + math.floor(4 * m.s)) or 0
local stats = imp:_findStats(entry)
local trend = {}
local trendLine = stats and ModUpdate.trendingLine(stats.recent, stats.windowDays)
if trendLine then trend[#trend + 1] = trendLine end
if stats and stats.asOf then
trend[#trend + 1] = Strings("counts approximate, as of %s",
tostring(stats.asOf):match("^%d%d%d%d%-%d%d%-%d%d") or stats.asOf)
end
trend = (#trend > 0) and table.concat(trend, " - ") or nil
local trendH = trend and (Kit.textHeight("small") + math.floor(2 * m.s)) or 0
-- What a cart actually is: a pinned mod list. Its own page installs them;
-- this popup only ever installs the cart file.
local pins = ModIndex.isCart(entry)
and Strings("Pins %d mod(s) - install them from the cart's own page",
#(entry.mods or {})) or nil
local pinsH = pins and (Kit.textHeight("small") + math.floor(2 * m.s)) or 0
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + trendH + pinsH + noteH + math.floor(12 * m.s)
+ nBtns * (m.btnH + gap) - gap + pad
local image = imp._findThumb and imp:_findThumb(entry)
local imageH = image and math.max(0, math.min(math.floor(200 * m.s),
m.H - 2 * m.pad - h - gap)) or 0
local px, _, pw, footY, open = modalFrame(imp, m, "_findEntry", w, pad, 0,
h - 2 * pad - m.btnH - gap + (imageH > 0 and imageH + gap or 0), m.btnH, gap)
local top, rw, mark, done = open()
local cy = top
if imageH > 0 then
local iw, ih = image:getDimensions()
local scale = math.min(rw / iw, imageH / ih)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(image, Theme.snap(px + pad + (rw - iw * scale) / 2),
Theme.snap(cy + (imageH - ih * scale) / 2), 0, scale, scale)
cy = cy + imageH + gap
end
Kit.text("button", Kit.ellipsize("button", entry.title or entry.id,
rw), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
if entry.author then lead = lead .. " - " .. entry.author end
if ModIndex.isCart(entry) then
lead = lead .. " - " .. gameLabel(entry.base)
if entry.seal then lead = lead .. " - " .. entry.seal end
elseif entry.categories and entry.categories[1] then
lead = lead .. " - " .. entry.categories[1]
end
local dl = stats and ModUpdate.downloadsShort(stats.total) or nil
local hasCount = stats ~= nil and stats.total ~= nil
local segs = { { lead, PAL.detail } }
if dl then
segs[#segs + 1] = { " - " .. dl, hasCount and PAL.green or PAL.faint }
end
segLine("small", segs, px + pad, cy, rw)
cy = cy + Kit.textHeight("small")
if trend then
cy = cy + math.floor(2 * m.s)
Kit.text("small", Kit.ellipsize("small", trend, rw),
px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small")
end
if pins then
cy = cy + math.floor(2 * m.s)
Kit.text("small", Kit.ellipsize("small", pins, rw),
px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small")
end
if note then
cy = cy + math.floor(4 * m.s)
Kit.text("small", note, px + pad, cy, PAL.green)
cy = cy + Kit.textHeight("small")
end
cy = cy + math.floor(12 * m.s)
local cartHave = ModIndex.isCart(entry)
and imp:_findInstalledCarts()[entry.id] ~= nil
local instW = cartHave and math.floor((rw - gap) / 2) or rw
cy = mark("findpop-inst", cy - top, m.btnH)
mark("findpop-del", cy - top, m.btnH)
if action then
btn(imp, px + pad, cy, instW, m.btnH, "findpop-inst", action, {
kind = "primary", font = "small",
action = function()
imp._findEntry = nil
imp:_findConfirmInstall(entry)
end })
else
btn(imp, px + pad, cy, instW, m.btnH, "findpop-inst",
Strings("Not installable from this index"),
{ font = "small", enabled = false })
end
if cartHave then
local cartId, cartBase = entry.id, entry.base
btn(imp, px + pad + instW + gap, cy, instW, m.btnH, "findpop-del",
DELETE_LABEL(deleteArmed(imp, "cart", cartId, cartBase)), {
kind = "danger", font = "small", icon = "trash", keepArm = true,
action = function()
imp:pressDelete("cart", cartId, cartBase, function()
imp._findEntry = nil
imp:deleteCart(cartBase, cartId, { from = "find" })
end)
end })
end
cy = cy + m.btnH + gap
local half = entry.repo and math.floor((rw - gap) / 2) or rw
cy = mark("findpop-det", cy - top, m.btnH)
mark("findpop-src", cy - top, m.btnH)
btn(imp, px + pad, cy, half, m.btnH, "findpop-det", Strings("Details"), {
kind = "accent", font = "small",
action = function() imp:_findShowDetails(entry) end })
if entry.repo then
local repo = entry.repo
btn(imp, px + pad + half + gap, cy, half, m.btnH, "findpop-src",
Strings("Source"), { kind = "accent", font = "small",
action = function() love.system.openURL(repo) end })
end
done()
btn(imp, px + pad, footY, pw - 2 * pad, m.btnH, "findpop-close",
Strings("Close"), { font = "small",
action = function() imp._findEntry = nil end })
end
-- Per-game file management, behind the manage button beside Play. A ready
-- game's panel is Play and its saves; everything episodic about the FILES --
-- swapping the ROM out, finding them on disk -- lives here instead of taking
-- two permanent buttons out of a column that has to fit on a phone.
local function buildGameManageModal(imp, m)
local version = imp._gameManage
local info = GameVersion.info(version)
local ready = imp.ready[version] or false
local mdl = romModel(imp, version, info, ready, info == nil)
local skin = cartSkin(imp, version)
local cartId = skin.cartId
local gameName = info and (info.launcherName or info.displayName)
or tostring(version)
local saveDir = love.filesystem.getSaveDirectory
and love.filesystem.getSaveDirectory() or nil
-- The folder link is desktop-only: Android and NX have no browsable path to
-- open, and both already print their own transfer hint on the slot card.
local canOpenFolder = saveDir and not imp.android and not imp.isNX
local canWebClip = ready and webClipAvailable(imp)
local webClipKey = tostring(version) .. ":" .. tostring(cartId or "")
local webClipNotice = imp._webClipNotice
if not webClipNotice or webClipNotice.key ~= webClipKey then webClipNotice = nil end
local pad = math.floor(18 * m.s)
local w = math.floor(460 * m.s)
local gap = math.floor(8 * m.s)
local bodyW = LauncherView.modalTextW(m, w, pad)
local detailH = Kit.wrapHeight("small",
mdl.detail or Strings("The ROM for this game is imported and verified."),
bodyW, 3)
local pathH = saveDir
and (Kit.textHeight("micro") + math.floor(8 * m.s)) or 0
local noticeH = webClipNotice
and (Kit.wrapHeight("small", webClipNotice.text, bodyW, 2) + gap) or 0
local nBtns = 1 + (canWebClip and 1 or 0) + (canOpenFolder and 1 or 0) + 1
local px, py, pw, footY, open = modalFrame(imp, m, "_gameManage", w, pad,
Kit.textHeight("button") + math.floor(8 * m.s),
detailH + math.floor(12 * m.s) + pathH + noticeH
+ (nBtns - 1) * (m.btnH + gap) - gap, m.btnH, gap)
Kit.text("button", Kit.ellipsize("button",
Strings("Manage ") .. gameName, pw - 2 * pad), px + pad, py + pad, PAL.heading)
local top, rw, mark, done = open()
local cy = top
cy = cy + Kit.textWrapped("small",
mdl.detail or Strings("The ROM for this game is imported and verified."),
px + pad, cy, bodyW, mdl.state and PAL.detail or PAL.green, 3)
cy = cy + math.floor(12 * m.s)
if saveDir then
-- Truncated from the LEFT: the tail of a save path is the part that
-- identifies it.
Kit.text("micro", Kit.ellipsizeLeft("micro", saveDir, bodyW),
px + pad, cy, PAL.faint)
cy = cy + Kit.textHeight("micro") + math.floor(8 * m.s)
end
if webClipNotice then
cy = cy + Kit.textWrapped("small", webClipNotice.text, px + pad, cy,
bodyW, webClipNotice.ok and PAL.green or PAL.red, 2) + gap
end
btn(imp, px + pad, mark("manage-rom", cy - top, m.btnH), rw, m.btnH, "manage-rom",
mdl.label or Strings("Re-import ROM"), {
kind = "accent", font = "small", enabled = mdl.enabled ~= false,
action = (mdl.enabled ~= false) and function()
imp._gameManage = nil
local fn = romAction(imp, version, mdl)
if fn then fn() end
end or nil })
cy = cy + m.btnH + gap
if canWebClip then
btn(imp, px + pad, mark("manage-webclip", cy - top, m.btnH), rw, m.btnH, "manage-webclip",
Strings("Add to Home Screen"), { kind = "accent", font = "small",
action = function()
if requestWebClip(imp, version, cartId) then imp._gameManage = nil end
end })
cy = cy + m.btnH + gap
end
if canOpenFolder then
btn(imp, px + pad, mark("manage-folder", cy - top, m.btnH), rw, m.btnH, "manage-folder",
Strings("Open folder"), { kind = "accent", font = "small",
action = function() love.system.openURL(imp:fileUrl(saveDir)) end })
end
done()
btn(imp, px + pad, footY, pw - 2 * pad, m.btnH, "manage-close",
Strings("Close"), { font = "small",
action = function() imp._gameManage = nil end })
end
local function buildBugModal(imp, m)
local pad = math.floor(18 * m.s)
local w = math.floor(560 * m.s)
local btnH = math.max(m.btnH, Kit.tapMin())
local h = math.floor(math.min(m.H - 2 * m.pad, 420 * m.s))
local px, py, pw, ph = modalPanel(m, w, h)
buildBugPanel(imp, px + pad, py + pad, pw - 2 * pad,
ph - 2 * pad - btnH - m.gap, m)
btn(imp, px + pad, py + ph - pad - btnH, pw - 2 * pad, btnH, "bug-close",
Strings("Close"), { kind = "primary", font = "small",
action = function() imp:_closeBugPanel() end })
end
local function buildSettingsModal(imp, m)
local model = imp._settings
local SaveData = require("src.core.SaveData")
local pad, gap = math.floor(18 * m.s), math.floor(8 * m.s)
local px, py, pw, ph = modalPanel(m, math.floor(600 * m.s),
math.floor(math.min(m.H - 2 * m.pad, m.H * 0.92)))
if model.confirm then Kit.blockClicks = true end
local x, cy, width = px + pad, py + pad, pw - 2 * pad
Kit.textBold("title", Strings("Settings"), x, cy, PAL.heading)
local subtitleW = width - m.btnH - gap
if model.flash then
Kit.text("small", Kit.ellipsize("small", model.flash, subtitleW), x,
cy + Kit.textHeight("title") + math.floor(3 * m.s), PAL.green)
else
Kit.text("small", Strings("Saved automatically"), x,
cy + Kit.textHeight("title") + math.floor(3 * m.s), PAL.muted)
end
btn(imp, x + width - m.btnH, cy, m.btnH, m.btnH, "settings-close", "", {
face = "invert", icon = "x", action = function() imp:_closeSettings() end })
cy = cy + Kit.textHeight("title") + Kit.textHeight("small") + math.floor(20 * m.s)
local footerY = py + ph - pad - m.btnH
local viewH = math.max(0, footerY - gap * 2 - cy)
local rowW = width - Kit.scrollGutter(m.s)
local inset = math.floor(12 * m.s)
local inner = rowW - inset * 2
local stepW = m.btnH
local flat = imp._settingsFlat
if not flat or flat.model ~= model then
flat = { model = model }
local acronyms = {UI=true, FPS=true, HUD=true, CPU=true, GB=true, GBA=true, ROM=true, NX=true}
local function label(text)
if text ~= text:upper() then return text end
return (text:gsub("[%a]+", function(word)
return acronyms[word] and word or word:sub(1, 1) .. word:sub(2):lower()
end))
end
flat.pretty = label
for sectionIndex, section in ipairs(model.sections) do
flat[#flat + 1] = { header = section.title,
hint = sectionIndex == 1 and Strings("Applies immediately")
or sectionIndex == 2 and Strings("Applies on the next game start") or nil }
for _, row in ipairs(section.rows) do
flat[#flat + 1] = { row = row, label = label(row.label) }
end
end
imp._settingsFlat = flat
end
local total = 0
for _, item in ipairs(flat) do
item.top = total
if item.header then
item.h = Kit.textHeight("button") + (item.hint and Kit.textHeight("micro") + 4 * m.s or 0) + gap * 2
else
item.stacked = item.row.choices ~= nil or Kit.textWidth("small", item.label)
+ math.floor(210 * m.s) > inner
item.labelH = item.stacked and Kit.wrapHeight("small", item.label, inner) or Kit.textHeight("small")
item.h = 2 * inset + m.btnH + (item.stacked and item.labelH + gap or 0)
end
total = total + item.h + gap
end
local maxAt = Kit.scrollExtent(total - gap, viewH)
model.rect = {x=x, y=cy, w=width, h=viewH}
model.maxScroll = maxAt
model.scroll = Kit.scrollInput(model.scroll, maxAt, x, cy, width, viewH)
local focused = Kit.focusId and tonumber(Kit.focusId:match("^set%-(%d+)%-"))
if focused and flat[focused] and Kit._ringShown and model.lastFocus ~= Kit.focusId then
local item = flat[focused]
model.scroll = Kit.scrollClamp(math.max(item.top + item.h - viewH,
math.min(model.scroll, item.top)), maxAt)
end
model.lastFocus = Kit.focusId
local listY = Kit.scrollBegin(x, cy, width, viewH, model.scroll, maxAt)
for i, item in ipairs(flat) do
local ry = listY + item.top
local visible = ry + item.h >= cy and ry <= cy + viewH
if item.header then
if visible then
Kit.textBold("button", item.header, x, ry + gap, PAL.heading)
if item.hint then
Kit.text("micro", item.hint, x, ry + gap + Kit.textHeight("button") + 4 * m.s, PAL.muted)
end
end
else
local row, key = item.row, "set-" .. i
local rowEnabled = not row.safeModeBlocked or not SaveData.isSafeMode(model.opts)
local ix, rx = x + inset, x + rowW - inset
local ctlY = ry + inset + (item.stacked and item.labelH + gap or 0)
local labelY = item.stacked and ry + inset or ctlY + (m.btnH - Kit.textHeight("small")) / 2
-- Register offscreen controls for keyboard navigation without painting
-- their cards, text, or icons. Focus scrolls them into view next frame.
local function control(bx, bw, id, text, opts)
opts = opts or {}
opts.font = "small"
if opts.enabled == nil then opts.enabled = rowEnabled end
if visible then btn(imp, bx, ctlY, bw, m.btnH, id, text, opts)
elseif opts.enabled then Kit.focusable(id, bx, ctlY, bw, m.btnH) end
end
if visible then
Kit.card(x, ry, rowW, item.h, "row")
Kit.textWrapped("small", item.label, ix, labelY, inner, PAL.text)
end
if row.choices then
local cw = (inner - gap) / 2
for j, choice in ipairs(row.choices) do
control(ix + (j - 1) * (cw + gap), cw, key .. "-" .. choice.value, choice.label, {
kind = row.selected() == choice.value and "accent" or "ghost",
action = function() row.select(choice.value) end })
end
elseif row.editText then
local ew = chipWidth(Strings("Edit"), m)
local vw = item.stacked and inner - ew - gap or math.floor(140 * m.s)
if visible then
Kit.textRight("small", Kit.ellipsize("small", tostring(row.value()), vw),
rx - ew - gap, ctlY + (m.btnH - Kit.textHeight("small")) / 2, PAL.detail)
end
control(rx - ew, ew, key .. "-edit", Strings("Edit"), {kind="accent",
action=function()
imp._settingsText = {row=row, text=tostring(row.value() or ""), maxLen=row.editText.maxLen}
imp:_armTextInput()
end})
elseif row.action then
local label = type(row.actionLabel) == "function" and row.actionLabel()
or row.actionLabel or Strings("Run")
local aw = math.min(inner, chipWidth(label, m))
local function run()
if row.action() ~= false then
model.save()
if row.doneText then model.flash = row.doneText end
end
end
control(rx - aw, aw, key .. "-act", label, {
kind=row.danger and "danger" or "ghost",
action=function()
if row.confirm then
model.confirm = {
title = row.confirm.title, lines = row.confirm.lines,
yesLabel = Strings("Yes"), noLabel = Strings("No"),
onYes = run,
close = function() model.confirm = nil end,
}
else
run()
end
end})
else
local bandW = item.stacked and inner or math.floor(200 * m.s)
local vw = bandW - 2 * stepW - gap
if visible then
Theme.fillRounded(rx - bandW, ctlY, bandW, m.btnH, PAL.field, 1, Theme.radius())
Kit.textCenter("small", Kit.ellipsize("small", flat.pretty(tostring(row.value())), vw),
rx - bandW + stepW + gap / 2, ctlY + (m.btnH - Kit.textHeight("small")) / 2,
vw, rowEnabled and PAL.heading or PAL.muted)
end
control(rx - bandW, stepW, key .. "-prev", "‹", {
action=function() if row.step and row.step(-1) then model.save() end end})
control(rx - stepW, stepW, key .. "-next", "›", {
action=function() if row.step and row.step(1) then model.save() end end})
end
end
end
Kit.scrollEnd(x, cy, width, viewH, model.scroll, maxAt, PAL.surface)
Theme.stroke(x, footerY - gap, width, 1, PAL.line, 0.25, 1)
local doneW = math.max(chipWidth(Strings("Done"), m), math.floor(80 * m.s))
btn(imp, x, footerY, width - doneW - gap, m.btnH, "settings-bug", Strings("Troubleshooting"), {
font="small", action=function() imp:_openBugPanel() end })
btn(imp, x + width - doneW, footerY, doneW, m.btnH, "settings-done", Strings("Done"), {
font="small", kind="accent", action=function() imp:_closeSettings() end })
if model.confirm then buildConfirmModal(imp, m, model.confirm) end
end
local function buildDepResolverModal(imp, m)
local res = imp._modDepResolver
if not res then return end
local pad = math.floor(18 * m.s)
local w = math.floor(540 * m.s)
local chipH = math.max(Kit.tapMin(), math.floor(28 * m.s))
local rowH = math.floor(74 * m.s)
local gap = math.floor(8 * m.s)
local warnH = math.floor(38 * m.s)
local n = #(res.deps or {})
local anyUnsatisfied = false
for _, d in ipairs(res.deps or {}) do
if d.status ~= "satisfied" and d.status ~= "disabled" then anyUnsatisfied = true; break end
end
local totalContentH = n > 0 and (n * rowH + (n - 1) * gap) or 0
-- Calculate content height dynamically so modal auto-fits small lists snuggly
local headerH = Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(10 * m.s)
local warnTotalH = warnH + math.floor(12 * m.s)
local listMaxH = math.floor(240 * m.s)
local itemsH = math.min(totalContentH > 0 and totalContentH or rowH, listMaxH)
local footerH = math.floor(10 * m.s) + m.btnH
local wantedH = pad + headerH + warnTotalH + itemsH + footerH + pad
local h = math.floor(math.min(m.H - 2 * m.pad, math.max(260 * m.s, wantedH)))
local px, py, pw, ph = modalPanel(m, w, h)
local cy = py + pad
-- Title
local titleText = Strings("Dependency Resolver: ") .. tostring(res.targetMod.name or res.targetMod.id)
Kit.text("button", Kit.ellipsize("button", titleText, pw - 2 * pad), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
-- Subtitle / intro
local subText = Strings("This mod requires additional dependencies or has conflicts:")
Kit.text("small", subText, px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(10 * m.s)
-- Security Disclaimer Banner Callout Card
Kit.card(px + pad, cy, pw - 2 * pad, warnH, "warn")
local warnMsg = Strings("Caution: Only pull dependencies from sources you trust.\nVerify source repositories before fetching.")
Kit.text("micro", warnMsg, px + pad + math.floor(12 * m.s), cy + math.floor(5 * m.s), PAL.yellow)
cy = cy + warnH + math.floor(12 * m.s)
-- List area bounds
local listH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s)
local scrollMax = math.max(0, totalContentH - listH)
-- Mouse wheel scroll handling matching upstream pattern
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(px + pad, cy, pw - 2 * pad, listH) then
imp._depScrollOffset = clamp((imp._depScrollOffset or 0) - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
Kit.wheelY = 0
elseif scrollMax == 0 then
imp._depScrollOffset = 0
else
imp._depScrollOffset = clamp(imp._depScrollOffset or 0, 0, scrollMax)
end
-- Pump active in-flight pulls
if imp._pumpDepPulls then
imp:_pumpDepPulls()
end
-- Clipped vertical scroll container
Kit.pushClip(px + pad, cy, pw - 2 * pad, listH)
local startY = cy - (imp._depScrollOffset or 0)
for i = 1, n do
local dep = res.deps[i]
local ry = startY + (i - 1) * (rowH + gap)
-- Cull rows completely outside the list viewport rectangle
if ry + rowH >= cy and ry <= cy + listH then
-- Item Card Fill & Stroke (matching launcher card interiors & radius)
local hot = Kit.hover(px + pad, ry, pw - 2 * pad, rowH)
Kit.card(px + pad, ry, pw - 2 * pad, rowH, hot and "rowHover" or "row")
local ix = px + pad + math.floor(12 * m.s)
local innerW = pw - 2 * pad - math.floor(24 * m.s)
-- Dep title & range
local depHeader = tostring(dep.name or dep.id)
if dep.range and dep.range ~= "" then
depHeader = depHeader .. " (" .. dep.range .. ")"
end
Kit.text("small", Kit.ellipsize("small", depHeader, innerW - math.floor(210 * m.s)),
ix, ry + math.floor(8 * m.s), PAL.heading)
-- Status Badge & Subtext
local statusText, statusCol
if dep.status == "satisfied" then
statusText = Strings("Installed & Compatible (v%s)", tostring(dep.installedVersion or "?"))
statusCol = PAL.green
elseif dep.status == "incompatible" then
statusText = Strings("Incompatible (installed v%s, needs %s)", tostring(dep.installedVersion or "?"), tostring(dep.range or ""))
statusCol = PAL.yellow
elseif dep.status == "conflict" then
statusText = Strings("Incompatible mod enabled (v%s)", tostring(dep.installedVersion or "?"))
statusCol = PAL.red
elseif dep.status == "disabled" then
statusText = Strings("Disabled (conflict resolved)")
statusCol = PAL.green
else
statusText = Strings("Missing")
statusCol = PAL.red
end
Kit.text("micro", statusText, ix, ry + math.floor(8 * m.s) + Kit.textHeight("small") + math.floor(2 * m.s), statusCol)
-- Repo source line or Conflict reason
local repoLine
if dep.status == "conflict" or dep.kind == "conflict" then
repoLine = Strings("Listed as incompatible with ") .. tostring(res.targetMod.name or res.targetMod.id)
elseif dep.github then
repoLine = Strings("Source: github.com/") .. dep.github
else
repoLine = Strings("Source: Unknown (no repo listed)")
end
Kit.text("micro", repoLine, ix, ry + math.floor(8 * m.s) + Kit.textHeight("small") + Kit.textHeight("micro") + math.floor(4 * m.s), PAL.muted)
-- Action buttons right cluster (vertically centered inside card)
local ly = ry + math.floor((rowH - chipH) / 2)
local place = Layout.rightCluster(ix, innerW, math.floor(8 * m.s))
local pState = imp._depPullState and imp._depPullState[dep.id]
if pState and pState.stage ~= "done" and pState.stage ~= "error" then
local label = Strings("Pulling...")
if pState.stage == "fetching" then label = Strings("Fetching...")
elseif pState.stage == "downloading" then
if pState.progress and pState.progress > 0 then
label = Strings("Downloading %d%%", math.floor(pState.progress * 100))
else
label = Strings("Downloading...")
end
elseif pState.stage == "installing" then label = Strings("Installing...")
end
Kit.chip(place(Kit.textWidth("small", label) + math.floor(16 * m.s)), ly,
Kit.textWidth("small", label) + math.floor(16 * m.s), chipH, label, true, PAL.yellow, "dep-pulling-" .. i)
elseif dep.status == "conflict" then
local btnLabel = Strings("Disable mod")
local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s)
btn(imp, place(bw), ly, bw, chipH, "dep-dis-" .. i, btnLabel, {
kind = "warn", font = "small",
enabled = not imp.safeMode,
action = function()
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.setEnabled(dep.id, false, imp.modScope)
dep.status = "disabled"
if imp._refreshMods then imp:_refreshMods() end
end,
})
elseif dep.status == "disabled" then
local chipLabel = Strings("Disabled")
local cw = Kit.textWidth("small", chipLabel) + math.floor(16 * m.s)
Kit.chip(place(cw), ly, cw, chipH, chipLabel, true, PAL.green, "dep-dischip-" .. i)
else
-- Pull / Update button if github repo is known and not satisfied
if dep.github and dep.status ~= "satisfied" then
local btnLabel = dep.status == "incompatible" and Strings("Update") or Strings("Pull from GitHub")
local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s)
btn(imp, place(bw), ly, bw, chipH, "dep-pull-" .. i, btnLabel, {
kind = "accent", font = "small",
action = function()
if imp._startDepPull then
imp:_startDepPull(dep)
end
end,
})
end
-- Open Source link button if safeUrl is present
if dep.safeUrl then
local bw = Kit.textWidth("small", Strings("View Source")) + math.floor(20 * m.s)
btn(imp, place(bw), ly, bw, chipH, "dep-view-" .. i, Strings("View Source"), {
font = "small",
action = function()
if love and love.system and love.system.openURL then
love.system.openURL(dep.safeUrl)
end
end,
})
end
end
end
end
Kit.popClip()
-- Scrollbar indicator if scrollMax > 0
if scrollMax > 0 then
local barW = math.floor(4 * m.s)
local barX = px + pw - pad - barW
local thumbH = math.max(math.floor(20 * m.s), math.floor(listH * (listH / totalContentH)))
local thumbY = cy + (listH - thumbH) * ((imp._depScrollOffset or 0) / scrollMax)
Theme.fill(barX, cy, barW, listH, PAL.bg, 0.4)
Theme.fill(barX, thumbY, barW, thumbH, PAL.muted, 0.7)
end
cy = cy + listH + math.floor(10 * m.s)
-- Bottom Action Buttons
if anyUnsatisfied then
local btnW = math.floor((pw - 2 * pad - math.floor(10 * m.s)) / 2)
btn(imp, px + pad, cy, btnW, m.btnH, "depresolver-pullall", Strings("Pull All Available"), {
kind = "accent", font = "small",
action = function()
for _, dep in ipairs(res.deps or {}) do
if dep.github and dep.status ~= "satisfied" and dep.status ~= "disabled" and imp._startDepPull then
imp:_startDepPull(dep)
end
end
end,
})
btn(imp, px + pad + btnW + math.floor(10 * m.s), cy, btnW, m.btnH, "depresolver-close", Strings("Done"), {
font = "small",
action = function()
imp._modDepResolver = nil
end,
})
else
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "depresolver-close", Strings("Done"), {
kind = "accent", font = "small",
action = function()
imp._modDepResolver = nil
end,
})
end
end
local Sync = {}
Sync.HINT = "Save sync keeps your saves and your mod list on our server so another device can pick them up. Keep your own backups too."
function Sync.title(imp, m, w, pad, fit)
local headH = Kit.textHeight("button") + math.floor(12 * m.s)
local px, py, pw, footY, open = modalFrame(imp, m, "_syncModal", w, pad, headH,
fit.h + math.max(0, fit.over) - 2 * pad - headH - fit.gap - fit.btnH,
fit.btnH, fit.gap)
Kit.text("button", Strings("SAVE SYNC"), px + pad, py + pad, PAL.heading)
local top, _, mark, done = open()
fit.top, fit.mark = top, mark
return px, pw, top, footY, function()
fit.mark = nil
done()
end
end
function Sync.width(m, want)
return math.floor(math.min(want, m.W - 2 * m.pad))
end
function Sync.fit(m, fixed, rows, gaps, texts)
local fit = { btnH = m.btnH, gap = math.floor(8 * m.s), lines = {} }
local avail = m.H - 2 * m.pad
texts = texts or {}
for i, blk in ipairs(texts) do fit.lines[i] = blk.max end
local function total()
local t = fixed + rows * fit.btnH + gaps * fit.gap
for i, blk in ipairs(texts) do
t = t + Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i])
end
return t
end
while total() > avail do
local worst, worstH = nil, 0
for i, blk in ipairs(texts) do
if fit.lines[i] > 1 then
local hgt = Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i])
if hgt > worstH then worst, worstH = i, hgt end
end
end
if not worst then break end
fit.lines[worst] = fit.lines[worst] - 1
end
if total() > avail and gaps > 0 then
fit.gap = math.max(math.max(2, math.floor(3 * m.s)),
fit.gap - math.ceil((total() - avail) / gaps))
end
if total() > avail and rows > 0 then
fit.btnH = math.max(Kit.tapMin(),
fit.btnH - math.ceil((total() - avail) / rows))
end
fit.over = total() - avail
fit.h = math.min(total(), avail)
return fit
end
function Sync.status(imp, m, x, y, w, eng, fit)
local bh = (fit and fit.btnH) or m.btnH
local gap = (fit and fit.gap) or math.floor(8 * m.s)
if eng:busy() then
Loader.inline(x, y, w, bh, eng.status)
return bh + gap
end
Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y,
eng.phase == "error" and PAL.red or PAL.muted)
return Kit.textHeight("small") + gap + math.floor(2 * m.s)
end
function Sync.reserve(m, eng)
if eng:busy() then return m.btnH + math.floor(8 * m.s) end
return Kit.textHeight("small") + math.floor(10 * m.s)
end
function Sync.row(imp, m, x, y, w, key, label, opts, fit)
opts = opts or {}
opts.font = "small"
local bh = (fit and fit.btnH) or m.btnH
local gap = (fit and fit.gap) or math.floor(8 * m.s)
if fit and fit.mark then y = fit.mark(key, y - fit.top, bh) end
btn(imp, x, y, w, bh, key, label, opts)
return y + bh + gap
end
function LauncherView.syncSideText(meta)
meta = type(meta) == "table" and meta or {}
local summary = type(meta.summary) == "table" and meta.summary or {}
local bits = {}
if type(summary.name) == "string" and summary.name ~= "" then
bits[#bits + 1] = summary.name
end
if tonumber(summary.badges) then
bits[#bits + 1] = tostring(math.floor(summary.badges)) .. " "
.. Strings("badges")
end
if type(summary.timeText) == "string" and summary.timeText ~= "" then
bits[#bits + 1] = summary.timeText
end
if tonumber(summary.dexCount) then
bits[#bits + 1] = tostring(math.floor(summary.dexCount)) .. " "
.. Strings("seen")
end
local when = tonumber(meta.savedAt)
if when then
bits[#bits + 1] = Strings("saved") .. " " .. os.date("%Y-%m-%d %H:%M", when)
end
if #bits == 0 then return Strings("no details") end
return table.concat(bits, " \194\183 ")
end
function Sync.conflict(imp, m, eng)
local row = eng.conflicts[1]
local pad = math.floor(18 * m.s)
local w = Sync.width(m, math.floor(520 * m.s))
local innerW = w - 2 * pad - Kit.scrollGutter()
local lead = row.overlap
and Strings("These saves were played at the same time.")
or Strings("This save also changed on another device.")
local mine = LauncherView.syncSideText(row.localMeta)
local theirs = LauncherView.syncSideText(row.remoteMeta)
local fit = Sync.fit(m,
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
+ 2 * (Kit.textHeight("small") + math.floor(12 * m.s)),
4, 4, {
{ font = "small", str = lead, w = innerW, max = 2 },
{ font = "micro", str = mine, w = innerW, max = 2 },
{ font = "micro", str = theirs, w = innerW, max = 2 },
})
local px, pw, cy, footY, done = Sync.title(imp, m, w, pad, fit)
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW,
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
local function side(title, text, lines)
Kit.text("small", title, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("small") + math.floor(2 * m.s)
cy = cy + Kit.textWrapped("micro", text, px + pad, cy, innerW,
PAL.muted, lines) + math.floor(10 * m.s)
end
side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"),
mine, fit.lines[2])
side(Strings("Other device"), theirs, fit.lines[3])
local key = row.key
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-keep-this",
Strings("Keep this device"), { kind = "primary",
action = function() imp:_syncResolve(key, "local") end }, fit)
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-keep-other",
Strings("Keep the other device"), { kind = "accent",
action = function() imp:_syncResolve(key, "remote") end }, fit)
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-keep-both",
Strings("Keep both"), {
action = function() imp:_syncResolve(key, "both") end }, fit)
done()
Sync.row(imp, m, px + pad, footY, pw - 2 * pad, "sync-conflict-close",
Strings("Close"), { action = function() imp:_closeSync() end }, fit)
end
function Sync.link(imp, m, eng)
local mo = imp._syncModal
local pad = math.floor(18 * m.s)
local w = Sync.width(m, math.floor(460 * m.s))
local innerW = w - 2 * pad - Kit.scrollGutter()
local hint = Strings("Enter the two codes the other device is showing.")
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local fit = Sync.fit(m,
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
+ 2 * (fieldH + math.floor(8 * m.s)) + Sync.reserve(m, eng),
2, 2, { { font = "small", str = hint, w = innerW, max = 2 } })
local px, pw, cy, footY, done = Sync.title(imp, m, w, pad, fit)
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, innerW,
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
textField(imp, px + pad, cy, innerW, fieldH, "sync-code1",
mo.code1 or "", Strings("First code"), imp._syncFocus == "code1",
function() imp:_syncFocusField("code1") end)
cy = cy + fieldH + fit.gap
textField(imp, px + pad, cy, innerW, fieldH, "sync-code2",
mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2",
function() imp:_syncFocusField("code2") end)
cy = cy + fieldH + fit.gap
cy = cy + Sync.status(imp, m, px + pad, cy, innerW, eng, fit)
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-link-go",
Strings("Link this device"), { kind = "primary", enabled = not eng:busy(),
action = function() imp:_syncLink() end }, fit)
done()
Sync.row(imp, m, px + pad, footY, pw - 2 * pad, "sync-link-back",
Strings("Back"), { action = function() imp:_syncView("home") end }, fit)
end
function Sync.mods(imp, m, eng)
local mo = imp._syncModal
local pad = math.floor(18 * m.s)
local w = Sync.width(m, math.floor(500 * m.s))
local innerW = w - 2 * pad - Kit.scrollGutter()
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local plan = eng.modPlan
local rows = 4 + (plan and 1 or 0)
local codeH = eng.shareCode and (Kit.textHeight("small")
+ Kit.textHeight("stat") + Kit.textHeight("micro")
+ math.floor(18 * m.s)) or 0
local planH = plan and (Kit.textHeight("small") + math.floor(8 * m.s)) or 0
local fit = Sync.fit(m,
2 * pad + Kit.textHeight("button") + math.floor(12 * m.s) + codeH + planH
+ fieldH + math.floor(8 * m.s) + Sync.reserve(m, eng),
rows, rows, {})
local px, pw, cy, footY, done = Sync.title(imp, m, w, pad, fit)
if eng.shareCode then
Kit.text("small", Strings("Share this code:"), px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(4 * m.s)
Kit.text("stat", eng.shareCode, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("stat") + math.floor(4 * m.s)
Kit.text("micro", Kit.ellipsize("micro",
Strings("Enter this code in Save Sync > Get mod list"), innerW),
px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s)
end
local withOptions = mo.withOptions ~= false
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-share-options",
Strings("Include my mod options") .. " \194\183 "
.. (withOptions and Strings("ON") or Strings("OFF")),
{ kind = withOptions and "accent" or nil, enabled = not eng:busy(),
action = function() imp:_syncToggleShareOptions() end }, fit)
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-share-mods",
Strings("Share mod list"), { kind = "accent", enabled = not eng:busy(),
action = function() imp:_syncShareMods() end }, fit)
textField(imp, px + pad, cy, innerW, fieldH, "sync-share-code",
mo.share or "", Strings("Paste a 6-character mod code"),
imp._syncFocus == "share", function() imp:_syncFocusField("share") end)
cy = cy + fieldH + fit.gap
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-get-mods",
Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(),
action = function() imp:_syncGetShare() end }, fit)
if plan then
local line = Strings("%d mods, %d indexes to add",
#(plan.toInstall or {}) + #(plan.toEnable or {}), #(plan.indexes or {}))
if #(plan.missing or {}) > 0 then
line = line .. " \194\183 " .. Strings("%d not in your indexes",
#plan.missing)
end
if #(plan.options or {}) > 0 then
line = line .. " \194\183 " .. (plan.applyOptions
and Strings("options for %d mods", #plan.options)
or Strings("their options skipped"))
end
Kit.text("small", Kit.ellipsize("small", line, innerW), px + pad, cy,
PAL.detail)
cy = cy + Kit.textHeight("small") + math.floor(8 * m.s)
local prog = mo.progress
if prog then
Loader.inline(px + pad, cy, innerW, fit.btnH,
Strings("%d of %d", prog.done or 0, prog.total or 0))
cy = cy + fit.btnH + fit.gap
else
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-apply-mods",
Strings("Apply these mods"), { kind = "primary",
enabled = not eng:busy(),
action = function() imp:_syncApplyMods() end }, fit)
end
end
cy = cy + Sync.status(imp, m, px + pad, cy, innerW, eng, fit)
done()
Sync.row(imp, m, px + pad, footY, pw - 2 * pad, "sync-mods-back", Strings("Back"),
{ action = function() imp:_syncView("home") end }, fit)
end
function LauncherView.syncDeviceRows(eng, limit)
local out = {}
if not eng or type(eng.devices) ~= "table" then return out end
for _, row in ipairs(eng.devices) do
if #out >= (limit or 6) then break end
if type(row) == "table" and type(row.id) == "string" then
out[#out + 1] = {
id = row.id,
current = row.current == true,
label = type(row.label) == "string" and row.label ~= "" and row.label
or "device",
}
end
end
return out
end
function Sync.home(imp, m, eng)
local pad = math.floor(18 * m.s)
local w = Sync.width(m, math.floor(460 * m.s))
local linked = eng:linked()
local codes = linked and eng.codes or nil
local body = linked
and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.")
or Strings(Sync.HINT)
local innerW = w - 2 * pad - Kit.scrollGutter()
local codesH = codes
and (Kit.textHeight("small") + math.floor(6 * m.s)
+ 2 * (Kit.textHeight("title") + math.floor(4 * m.s))
+ math.floor(8 * m.s)) or 0
local devices = linked and LauncherView.syncDeviceRows(eng) or {}
local hidden, fit = 0, nil
repeat
local devicesH = #devices > 0
and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0
fit = Sync.fit(m,
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s) + codesH
+ devicesH + Sync.reserve(m, eng),
(linked and 5 or 3) + #devices, (linked and 5 or 3) + #devices,
{ { font = "small", str = body, w = innerW, max = 5 } })
if fit.over <= 0 or #devices == 0 then break end
table.remove(devices)
hidden = hidden + 1
until false
local px, pw, cy, footY, done = Sync.title(imp, m, w, pad, fit)
cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail,
fit.lines[1]) + math.floor(10 * m.s)
if codes then
Kit.text("small", Strings("Your sync codes, enter these on another device:"), px + pad,
cy, PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
Kit.text("title", codes.code1, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("title") + math.floor(4 * m.s)
Kit.text("title", codes.code2, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("title") + math.floor(8 * m.s)
end
cy = cy + Sync.status(imp, m, px + pad, cy, innerW, eng, fit)
if #devices > 0 then
Kit.text("small", hidden > 0
and Strings("Devices on this account (%d more)", hidden)
or Strings("Devices on this account:"), px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
for i, device in ipairs(devices) do
local id = device.id
if device.current then
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
device.label .. " \194\183 " .. Strings("this device"),
{ enabled = false }, fit)
else
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
Strings("Unlink %s", device.label), { kind = "danger",
enabled = not eng:busy(),
action = function() imp:_syncUnlinkDevice(id) end }, fit)
end
end
end
if linked then
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"),
{ kind = "primary", enabled = not eng:busy(),
action = function() imp:_syncNow() end }, fit)
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-mods",
Strings("Share or get a mod list"), { kind = "accent",
action = function() imp:_syncView("mods") end }, fit)
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-codes",
codes and Strings("Get new sync codes") or Strings("Show my sync codes"),
{ enabled = not eng:busy(),
action = function() imp:_syncCodes() end }, fit)
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-unlink",
Strings("Unlink this device"), { kind = "danger",
action = function() imp:_syncUnlink() end }, fit)
else
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-create",
Strings("Create sync account"), { kind = "primary",
enabled = not eng:busy(),
action = function() imp:_syncCreate() end }, fit)
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-link",
Strings("Link this device"), { kind = "accent",
action = function() imp:_syncView("link") end }, fit)
end
done()
Sync.row(imp, m, px + pad, footY, pw - 2 * pad, "sync-close", Strings("Close"),
{ action = function() imp:_closeSync() end }, fit)
end
function Sync.modOptions(imp, m, eng)
local plan = eng.modPlan
local ids = {}
for _, row in ipairs(plan.options or {}) do ids[#ids + 1] = row.id end
local pad = math.floor(18 * m.s)
local w = Sync.width(m, math.floor(480 * m.s))
local innerW = w - 2 * pad - Kit.scrollGutter()
local lead = Strings(
"This mod list also carries the options its owner set for %d mods. Import their options, or keep the ones you have?",
#ids)
local names = table.concat(ids, ", ")
local fit = Sync.fit(m,
2 * pad + Kit.textHeight("button") + math.floor(32 * m.s), 2, 2, {
{ font = "small", str = lead, w = innerW, max = 4 },
{ font = "micro", str = names, w = innerW, max = 3 },
})
local px, pw, cy, footY, done = Sync.title(imp, m, w, pad, fit)
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW, PAL.detail,
fit.lines[1]) + math.floor(8 * m.s)
cy = cy + Kit.textWrapped("micro", names, px + pad, cy, innerW, PAL.muted,
fit.lines[2]) + math.floor(12 * m.s)
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-options-import",
Strings("Import their options"), { kind = "primary",
action = function() imp:_syncAnswerModOptions(true) end }, fit)
done()
Sync.row(imp, m, px + pad, footY, pw - 2 * pad, "sync-options-skip",
Strings("Keep my options"), {
action = function() imp:_syncAnswerModOptions(false) end }, fit)
end
function Sync.unavailable(imp, m, msg)
local pad = math.floor(18 * m.s)
local w = Sync.width(m, math.floor(420 * m.s))
local innerW = w - 2 * pad - Kit.scrollGutter()
local fit = Sync.fit(m,
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s), 1, 0,
{ { font = "small", str = msg, w = innerW, max = 4 } })
local px, pw, cy, footY, done = Sync.title(imp, m, w, pad, fit)
cy = cy + Kit.textWrapped("small", msg, px + pad, cy, innerW,
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
done()
Sync.row(imp, m, px + pad, footY, pw - 2 * pad, "sync-close",
Strings("Close"), { action = function() imp:_closeSync() end }, fit)
end
local function buildSyncModal(imp, m)
if not imp:_syncSupported() then
Sync.unavailable(imp, m, Strings(
"Save sync cannot run on this build: it has no way to send the signed requests it needs. Update to the latest app build, or use a desktop build."))
return
end
local eng = imp._sync
if not eng then
Sync.unavailable(imp, m,
Strings("Save sync is not available in this build."))
return
end
if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then
Sync.conflict(imp, m, eng)
return
end
local plan = eng.modPlan
if type(plan) == "table" and #(plan.options or {}) > 0
and plan.applyOptions == nil then
Sync.modOptions(imp, m, eng)
return
end
local view = imp._syncModal and imp._syncModal.view or "home"
if view == "link" then
Sync.link(imp, m, eng)
elseif view == "mods" then
Sync.mods(imp, m, eng)
else
Sync.home(imp, m, eng)
end
end
-- Whether ANY modal will draw this frame. draw() consults this BEFORE the
-- panels build: immediate mode hit-tests each control as it draws, so the
-- panels underneath a modal must run with Kit.blockClicks already raised or
-- a click on the scrim lands on whatever button happens to be behind it.
-- Keep this list in sync with buildModals below.
local MODAL_KEYS = {
"_profileRenamePrompt", "_profileSavePrompt", "_settingsText",
"_cartSave", "_bugModal", "_settings", "_rename", "_indexPrompt",
"_modConfirm", "_appPatchNotes", "_modReleaseNotes", "_findDetails",
"_modVersions", "_modDepResolver", "_modImports", "_singleProfileActions",
"_profilesPopup", "_modHeaderActionsPopup", "_sortPopup", "_gamePopup",
"_cartPopup", "_modScopePopup", "_filterPopup", "_indexManage",
"_syncModal", "_pcPicker", "_tradeModal", "_skinActions", "_modActions",
"_findEntry", "_gameManage", "_saveExport", "_savePicker", "_modGames",
"_pinModal", "_invitePicker", "_secretPopup",
}
LauncherView.MODAL_KEYS = MODAL_KEYS
local function modalUp(imp)
if Kit.FileBrowser and Kit.FileBrowser.active then return true end
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return true end
return (imp._settingsText or imp._settings or imp._rename
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
or imp._appPatchNotes
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
or imp._filterPopup or imp._modScopePopup or imp._indexManage
or imp._gamePopup or imp._cartPopup or imp._cartSave
or imp._modActions or imp._modImports or imp._skinActions or imp._syncModal
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage
or imp._saveExport or imp._savePicker or imp._modGames
or imp._tradeModal or imp._bugModal or imp._pcPicker
or imp._pinModal or imp._invitePicker or imp._secretPopup) ~= nil
end
local function modalKey(imp)
if not modalUp(imp) then return nil end
if Kit.FileBrowser and Kit.FileBrowser.active then return "filebrowser" end
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return "vkeyboard" end
for i = 1, #MODAL_KEYS do
if imp[MODAL_KEYS[i]] ~= nil then return MODAL_KEYS[i] end
end
return nil
end
LauncherView.modalKey = modalKey
local function buildModals(imp, m)
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
Kit.VirtualKeyboard.draw(m)
return true
end
if Kit.FileBrowser and Kit.FileBrowser.active then
Kit.FileBrowser.draw(m)
return true
end
if imp._secretPopup then buildSecretModal(imp, m) return true end
if imp._profileRenamePrompt then
buildPrompt(imp, m, {
key = "profren", modal = "_profileRenamePrompt", title = Strings("Rename profile"),
hint = Strings("Enter a new name for this profile:"),
text = imp._profileRenamePrompt.text or "", okLabel = Strings("Save"),
commit = function()
local txt = imp._profileRenamePrompt and imp._profileRenamePrompt.text
local old = imp._profileRenamePrompt and imp._profileRenamePrompt.oldName
if txt and txt ~= "" and old then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.renameProfile(old, txt)
imp._profileRenamePrompt = nil
imp:_disarmTextInput()
if imp._refreshMods then imp:_refreshMods() end
end
end,
cancel = function()
imp._profileRenamePrompt = nil
imp:_disarmTextInput()
end,
footnote = Strings("Enter to save - Esc to cancel"),
})
return true
end
if imp._profileSavePrompt then
buildPrompt(imp, m, {
key = "profsave", modal = "_profileSavePrompt", title = Strings("Save mod profile"),
hint = Strings("Enter a name for this mod profile:"),
text = imp._profileSavePrompt.text or "", okLabel = Strings("Save"),
commit = function()
local txt = imp._profileSavePrompt and imp._profileSavePrompt.text
if txt and txt ~= "" then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.saveProfile(txt)
imp._profileSavePrompt = nil
imp:_disarmTextInput()
if imp._refreshMods then imp:_refreshMods() end
end
end,
cancel = function()
imp._profileSavePrompt = nil
imp:_disarmTextInput()
end,
footnote = Strings("Enter to save - Esc to cancel"),
})
return true
end
if imp._settingsText then
local st = imp._settingsText
buildPrompt(imp, m, {
key = "settext", modal = "_settingsText", title = st.row.label, text = st.text,
okLabel = Strings("Save"),
commit = function() imp:_commitSettingsText() end,
cancel = function()
imp._settingsText = nil
imp:_disarmTextInput()
end,
footnote = Strings("Enter to save - Esc to cancel"),
})
return true
end
if imp._cartSave then buildCartSaveModal(imp, m) return true end
if imp._bugModal then buildBugModal(imp, m) return true end
if imp._settings then buildSettingsModal(imp, m) return true end
if imp._rename then
buildPrompt(imp, m, {
key = "rename", modal = "_rename", title = Strings("Name save slot"),
text = imp._rename.text, okLabel = Strings("Save"),
commit = function() imp:_commitRename() end,
cancel = function()
imp._rename = nil
imp:_disarmTextInput()
end,
footnote = Strings("Enter to save - Esc to cancel - empty clears"),
})
return true
end
if imp._indexPrompt then
buildPrompt(imp, m, {
key = "index", modal = "_indexPrompt", title = Strings("Add a mod index"),
hint = Strings("Paste the index URL, or its owner/repo."),
text = imp._indexPrompt.text or "", okLabel = Strings("Add"),
commit = function() imp:_commitAddIndex() end,
cancel = function()
imp._indexPrompt = nil
imp:_disarmTextInput()
end,
paste = function() imp:_pasteIndexUrl() end,
footnote = Strings("Enter to add - Esc to cancel"),
})
return true
end
if imp._modConfirm then buildConfirmModal(imp, m) return true end
if imp._appPatchNotes then
local PatchNotes = require("src.update.PatchNotes")
local ModUpdate = require("src.mods.ModUpdate")
local raw, ver = PatchNotes.body(imp.Check)
local body = ModUpdate.cleanBody(raw or "", 0)
if body == "" then body = Strings("(No patch notes.)") end
local title = Strings("Patch notes")
if ver and ver ~= "" then
title = title .. " v" .. tostring(ver)
end
buildTextModal(imp, m, "patch-notes-modal", title, body,
function() imp._appPatchNotes = nil end)
return true
end
if imp._modReleaseNotes then
local ModUpdate = require("src.mods.ModUpdate")
local n = imp._modReleaseNotes
local body = ModUpdate.cleanBody(n.body or "", 0)
if body == "" then body = Strings("(No release notes.)") end
buildTextModal(imp, m, "release-notes",
"v" .. tostring(n.version) .. Strings(" notes"), body,
function() imp._modReleaseNotes = nil end)
return true
end
if imp._findDetails then
local ModUpdate = require("src.mods.ModUpdate")
local d = imp._findDetails
local body = ModUpdate.cleanBody(d.body or "", 0)
if body == "" then
body = d.loading and Strings("Loading description...")
or Strings("(No description.)")
end
buildTextModal(imp, m, "find-details", d.title, body,
function()
imp._findDetails = nil
if imp._cancelFindDetails then imp:_cancelFindDetails() end
end)
return true
end
if imp._modVersions then buildVersionsModal(imp, m) return true end
if imp._modDepResolver then buildDepResolverModal(imp, m) return true end
if imp._modImports then buildRequiredImportsModal(imp, m) return true end
-- The lighter popups come after the deep ones on purpose: opening
-- Versions or Details from inside an actions popup draws the deeper modal
-- while the popup's own state stays set, so closing the deep one drops
-- you back where you were.
if imp._singleProfileActions then buildSingleProfileActionsModal(imp, m) return true end
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
if imp._sortPopup then buildSortModal(imp, m) return true end
if imp._gamePopup then buildGameModal(imp, m) return true end
if imp._cartPopup then buildCartModal(imp, m) return true end
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
if imp._filterPopup then buildFilterModal(imp, m) return true end
if imp._indexManage then buildIndexesModal(imp, m) return true end
if imp._syncModal then buildSyncModal(imp, m) return true end
if imp._pcPicker then
return require("src.import.online.PcPicker").draw(imp, m) == true
end
if imp._tradeModal then
return require("src.import.online.TradeScreen").drawModal(imp, m) == true
end
if imp._pinModal then
return require("src.import.online.PinModal").draw(imp, m) == true
end
if imp._invitePicker then
return require("src.import.online.InvitePicker").draw(imp, m) == true
end
if imp._skinActions then buildSkinActionsModal(imp, m) return true end
if imp._modActions then buildModActionsModal(imp, m) return true end
if imp._findEntry then buildFindEntryModal(imp, m) return true end
if imp._gameManage then buildGameManageModal(imp, m) return true end
if imp._saveExport then buildSaveExport(imp, m) return true end
if imp._savePicker then buildSavePicker(imp, m) return true end
if imp._modGames then buildModGamesPicker(imp, m) return true end
return false
end
-- --------------------------------------------------------------- overlays
-- The blocking loader. imp.workState drives the ROM import (which reports
-- real progress); imp._busy drives every async network operation.
local function loaderSpec(imp)
if imp.workState == "working" then
return {
title = imp.status or Strings("Working"),
detail = imp.detail,
progress = imp.progress,
}
end
local b = imp._busy
if b then
return { title = b.title, detail = b.detail, progress = b.progress,
onCancel = b.cancel }
end
return nil
end
local function drawPadCursor(imp)
if not imp._padCursorActive then return end
if (Kit.FileBrowser and Kit.FileBrowser.active)
or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then
return
end
local x, y = imp._padCursor.x, imp._padCursor.y
if imp._consolePointerHost and imp:_consolePointerHost() then
x, y = math.floor(x + 0.5), math.floor(y + 0.5)
end
love.graphics.push("all")
love.graphics.origin()
love.graphics.setLineWidth(1)
love.graphics.setColor(0, 0, 0, 0.45)
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, 0, 0, 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)
love.graphics.pop()
end
-- ------------------------------------------------------------ frame assembly
-- Mirror of buildHeader's vertical arithmetic, so the frame can decide
-- whether the window is tall enough BEFORE anything draws. Keep in sync
-- with buildHeader (rail, logo row, tab row, hairline).
local function headerHeight(m)
local _, _, labelH, rows = headerTabMetrics(m)
return m.railH + m.logoH + math.floor(12 * m.s) + math.floor(6 * m.s)
+ rows * (m.chip + labelH) + (rows - 1) * math.floor(4 * m.s)
+ math.floor(8 * m.s) + 1
end
-- The panel space a tab needs to lay out without crushing itself. Below
-- this the page SCROLLS (wheel / touch drag) instead of compressing: the
-- pinned Play block used to walk up over the cards on a short window, which
-- is unusable, and the footer simply lives below the fold until scrolled to.
local function minPanelHeight(m)
-- One column stacks the ROM card and the slot card in a single pile, so it
-- needs more room than the side-by-side layout; two columns only have to
-- fit the taller of the two. Both numbers came DOWN sharply when the
-- pinned Touch-Controls / Reset-rebinds pair moved behind the gear and the
-- save-file buttons moved into the slot card: the pile they used to sit on
-- top of was what forced 460/660 (#852), and a threshold larger than the
-- content pushes the whole page below the fold on windows that could have
-- shown it outright (a 1280x720 desktop was scrolling for 93px of nothing).
-- Whatever a window still cannot show, the page scroll above reaches.
return math.floor((m.twoCol and 340 or 470) * m.s)
end
local function buildTabPanel(imp, x, y, w, availH, budgetH, m)
if imp.tab == "mods" then
return buildModsPanel(imp, x, y, w, budgetH, m)
elseif imp.tab == "find" then
return buildFindPanel(imp, x, y, w, budgetH, m)
elseif imp.tab == "skins" then
return buildSkinsPanel(imp, x, y, w, budgetH, m)
elseif imp.tab == "importers" then
return buildImportersPanel(imp, x, y, w, budgetH, m)
elseif imp.tab == "online" then
return require("src.import.OnlinePanel")
.buildOnlinePanel(imp, x, y, w, budgetH, m)
end
return buildGamePanel(imp, x, y, w, availH, m, imp.tab, budgetH)
end
local function drawTabLayer(imp, tabId, x, contentY, w, viewH, availH, m, dx)
local prevTab = imp.tab
imp.tab = tabId
local at = tabScrollAt(imp)
local maxAt = tabScrollMax(imp)
if dx ~= 0 then
love.graphics.push()
love.graphics.translate(dx, 0)
end
local py = Kit.scrollBegin(x, contentY, w, viewH, at, maxAt)
local budgetH = math.floor(viewH * (1 + PANEL_OVERSCAN))
local panelW = math.max(0, w - Kit.scrollGutter(m.s))
local contentH = buildTabPanel(imp, x, py + 5, panelW, availH - 5, budgetH - 5, m)
contentH = (contentH or (availH - 5)) + 5
imp._tabContentH[tabId] = contentH
imp._tabScrollMax[tabId] = Kit.scrollExtent(contentH, viewH)
at = clamp(at, 0, tabScrollMax(imp))
imp._tabScroll[tabId] = at
Kit.scrollEnd(x, contentY, w, viewH, at, tabScrollMax(imp))
if dx ~= 0 then love.graphics.pop() end
imp.tab = prevTab
end
function LauncherView.draw(imp)
ensureState(imp)
local m = Layout.metrics(1200)
-- The pointer is the pad cursor while it is active, so the ring, hover and
-- clicks all agree on where "the pointer" is.
local mx, my = 0, 0
if imp._padCursorActive then
mx, my = imp._padCursor.x, imp._padCursor.y
elseif love.mouse and love.mouse.getPosition then
mx, my = love.mouse.getPosition()
end
local click = imp._clickPt
if click then mx, my = click.x, click.y end
-- SHORT-WINDOW SCROLL. When the space between header and footer falls
-- under the panel minimum, the whole page (header included) scrolls by a
-- plain y offset: layout runs off a shifted m.top, so hit tests, focus
-- rects and drawing all agree with the real pointer and no transform is
-- involved. Modals and the loader keep the REAL metrics and stay
-- centred in the window.
local footH = footerHeight(imp, m)
local naturalAvail = m.h - headerHeight(m) - footH
local scrollMax = math.max(0, minPanelHeight(m) - naturalAvail)
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
imp._clickPt = nil
imp._wheelY = 0
imp._noDragN = 0
local Toast = require("src.import.online.Toast")
Toast.occlude(imp)
Theme.field()
if imp._themeVideo then imp._themeVideo:draw() end
-- Everything from here to buildModals sits UNDER any open modal, so the
-- whole stage draws shielded (no clicks, no hover, no focus ring) while
-- one is up; buildModals lowers the shield for the modal's own controls.
local mkey = modalKey(imp)
if mkey ~= imp._modalKey then
if mkey then
if imp._modalHeld then
Transition.clear("modal")
imp._modalHeld = nil
end
Transition.start("modal", "in")
elseif imp._modalKey and imp._modalLastValue ~= nil then
if Transition.start("modal", "out") then
imp._modalHeld = { key = imp._modalKey, value = imp._modalLastValue }
end
end
imp._modalKey = mkey
end
imp._modalLastValue = mkey and imp[mkey] or nil
if imp._modalHeld and not Transition.active("modal") then
imp._modalHeld = nil
end
if imp._modalScroll then
local heldKey = imp._modalHeld and imp._modalHeld.key
for k in pairs(imp._modalScroll) do
local live = imp[k] ~= nil
or k == "_settingsConfirm" and imp._settings and imp._settings.confirm ~= nil
if not live and k ~= heldKey then imp._modalScroll[k] = nil end
end
end
imp._modalUpNow = mkey ~= nil
if imp._modalUpNow then imp:_blurPanelFields() end
Kit.blockClicks = imp._modalUpNow or Transition.active()
local step = Kit.scrollStep(m.s)
do
local rect = imp._tabRegionRect
if rect then
setTabScroll(imp, (Kit.scrollWheel(tabScrollAt(imp), tabScrollMax(imp),
rect.x, rect.y, rect.w, rect.h, step)))
end
end
local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax))
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks then
local moved = math.max(0, math.min(scroll - Kit.wheelY * step, scrollMax))
if moved ~= scroll then
scroll = moved
Kit.wheelY = 0
end
end
imp._pageScroll, imp._pageScrollMax = scroll, scrollMax
if (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks
and tabScrollMax(imp) > 0 then
local was = tabScrollAt(imp)
local to = Kit.scrollClamp(was - Kit.wheelY * step, tabScrollMax(imp))
if to ~= was then
setTabScroll(imp, to)
Kit.wheelY = 0
end
end
-- The header is the only block that moves with the page scroll, so shift
-- m.top across the call and put it back rather than wrapping `m` in a
-- proxy: the proxy cost two tables a frame and put a metatable lookup on
-- every m.* read for the rest of the frame.
local baseTop = m.top
if scroll > 0 then m.top = baseTop - scroll end
local contentY = buildHeader(imp, m)
m.top = baseTop
local footY, availH
if scrollMax > 0 then
availH = minPanelHeight(m)
footY = contentY + availH
else
footY = m.top + m.h - footH
availH = footY - contentY
end
local x, w = m.contentX, m.contentW
local viewH = math.max(0, availH)
local rect = imp._tabRegionRect
if not rect then rect = {}; imp._tabRegionRect = rect end
rect.x, rect.y, rect.w, rect.h = x, contentY, w, viewH
local tabTr = Transition.get("tabs")
if tabTr and tabTr.from and tabTr.from ~= tabKeyOf(imp) then
local p = Transition.progress("tabs")
local dir = tabTr.dir >= 0 and 1 or -1
pcall(drawTabLayer, imp, tabTr.from, x, contentY, w, viewH, availH, m,
-dir * p * w)
drawTabLayer(imp, tabKeyOf(imp), x, contentY, w, viewH, availH, m,
dir * (1 - p) * w)
else
drawTabLayer(imp, tabKeyOf(imp), x, contentY, w, viewH, availH, m, 0)
end
buildFooter(imp, m, footY)
Kit.blockClicks = Transition.active()
local held = imp._modalHeld
if held then imp[held.key] = held.value end
buildModals(imp, m)
endModalDraw(m)
if held then imp[held.key] = nil end
local spec = loaderSpec(imp)
Toast.draw(imp, m, contentY + math.floor(8 * m.s), spec ~= nil
or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active)
or (Kit.FileBrowser and Kit.FileBrowser.active) or false)
-- The loader sits above everything, including modals: it is the one thing
-- that must never be clicked around.
if spec then
if Loader.overlay(m, spec) and spec.onCancel then
queueAction(imp, "loader-cancel", spec.onCancel)
end
end
if imp._launchFade then
Theme.fill(0, 0, m.W, m.H, PAL.bg,
math.min(1, imp._launchFade.elapsed / imp._launchFade.duration))
end
if Kit.mouseClicked and not Kit.blockClicks and imp._onlineFocus
and not imp._onlineFieldHit
and not (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active)
and type(imp._commitOnlineField) == "function" then
imp:_commitOnlineField()
end
imp._onlineFieldHit = nil
Kit.endFrame()
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
return LauncherView