mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
aa6217e581
Feat Switch OTA Update
2550 lines
108 KiB
Lua
2550 lines
108 KiB
Lua
-- 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:
|
|
-- * NO SCROLLING. Every list paginates (Kit.pager). Rows per page come
|
|
-- from the real viewport height, so a tall window shows more and a phone
|
|
-- shows fewer -- but a page's row count is bounded either way, which is
|
|
-- what makes a 500-mod index cost the same as a 10-mod one.
|
|
-- * 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 Theme = require("src.ui.kit.Theme")
|
|
local Layout = require("src.ui.kit.Layout")
|
|
local Loader = require("src.ui.kit.Loader")
|
|
local GameVersion = require("src.core.GameVersion")
|
|
local Version = require("src.core.Version")
|
|
local Strings = require("src.core.Strings")
|
|
|
|
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
|
|
-- Finger travel past this (px) is a drag, not a tap.
|
|
local TAP_SLOP2 = 16 * 16
|
|
|
|
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) 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 {}
|
|
-- 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
|
|
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
|
|
|
|
-- ---------------------------------------------------------------- input
|
|
-- The kit is polled, not evented: update() samples the mouse and turns a
|
|
-- rising edge into a click point that the next draw consumes. 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 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()
|
|
imp._clickPt = { x = mx, y = my }
|
|
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
|
|
imp._touchAt = imp._touchAt or {}
|
|
imp._touchAt[tostring(id)] = { x = x, y = y }
|
|
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
|
|
-- Short-window mode: a vertical drag scrolls the page (draw() clamps).
|
|
if start.dragged and (imp._pageScrollMax or 0) > 0 then
|
|
local last = start.lastY or start.y
|
|
imp._pageScroll = (imp._pageScroll or 0) - (y - last)
|
|
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 start = imp._touchAt and imp._touchAt[tostring(id)]
|
|
if imp._touchAt then imp._touchAt[tostring(id)] = nil end
|
|
if start and start.dragged 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
|
|
|
|
-- 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
|
|
|
|
-- 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 imp._ringArmed and (key == "return" or key == "kpenter" or key == "space") then
|
|
Kit.activateFocused()
|
|
return true
|
|
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
|
|
|
|
-- ------------------------------------------------------- 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)
|
|
Kit.focusable(key, x, y, w, h)
|
|
Theme.fill(x, y, w, h, PAL.bg, 1)
|
|
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)
|
|
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 action and (Kit.press(x, y, w, h) or Kit._activateId == key) then
|
|
queueAction(imp, key, action)
|
|
end
|
|
end
|
|
|
|
-- A square icon control: the header's gear and quit, and the game panel's
|
|
-- manage button. Inverts to a solid white fill when hot, the same signal
|
|
-- every other control here uses, and rounds to the shared control radius.
|
|
-- `image` draws a texture; `drawFn(x, y, size, hot)` draws a hand-rolled
|
|
-- glyph (the quit X, which ships no asset).
|
|
local function iconButton(imp, key, x, y, size, image, action, drawFn)
|
|
Kit._audit("control", x, y, size, size, key)
|
|
local focused = Kit.focusable(key, x, y, size, size)
|
|
local hot = focused or Kit.hover(x, y, size, size)
|
|
Theme.fillRounded(x, y, size, size, hot and PAL.ink or PAL.surface, 1)
|
|
Theme.strokeRounded(x, y, size, size, PAL.line,
|
|
hot and Theme.A.focus or Theme.A.hairline, 1)
|
|
if image then
|
|
local iw, ih = image:getDimensions()
|
|
local pad = math.floor(size * 0.24)
|
|
local s = math.min((size - 2 * pad) / iw, (size - 2 * pad) / ih)
|
|
if hot then love.graphics.setColor(0, 0, 0, 1)
|
|
else love.graphics.setColor(1, 1, 1, 0.85) end
|
|
love.graphics.draw(image, Theme.snap(x + (size - iw * s) / 2),
|
|
Theme.snap(y + (size - ih * s) / 2), 0, s, s)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
elseif drawFn then
|
|
drawFn(x, y, size, hot)
|
|
end
|
|
if action and (Kit.press(x, y, size, size) or Kit._activateId == key) then
|
|
queueAction(imp, key, action)
|
|
end
|
|
end
|
|
|
|
-- The cartridge colour for a game, matching its tab in the header. Play
|
|
-- wears it, so "which game is this button going to boot" is answered before
|
|
-- the label is read. Unknown versions fall back to the commit green.
|
|
local CART_COLOR = {
|
|
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
|
|
}
|
|
local function cartColor(version)
|
|
return CART_COLOR[version] or PAL.green
|
|
end
|
|
|
|
local function modStatusColor(status)
|
|
if status == "ok" then return Strings("Ready"), PAL.green end
|
|
if status == "conflict" then return Strings("Conflict"), PAL.red end
|
|
return Strings("Incompatible"), PAL.yellow
|
|
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
|
|
|
|
-- A hand-drawn X, for the same reason drawCheck exists below: the UI font has
|
|
-- no guaranteed glyph, and the launcher ships no icon asset for it.
|
|
local function drawCross(x, y, size, color)
|
|
love.graphics.push("all")
|
|
love.graphics.setColor(color)
|
|
love.graphics.setLineWidth(math.max(2, size * 0.16))
|
|
love.graphics.setLineJoin("bevel")
|
|
love.graphics.line(x, y, x + size, y + size)
|
|
love.graphics.line(x + size, y, x, y + size)
|
|
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.
|
|
local function buildHeader(imp, m)
|
|
local y = m.top
|
|
Theme.versionRail(m.x, y, m.w, m.railH)
|
|
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.
|
|
local clusterW = 2 * gear + math.floor(6 * m.s) + m.pad
|
|
local boxX = m.x + clusterW
|
|
local boxW = 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
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.draw(imp.logo, Theme.snap(boxX + (boxW - dw) / 2),
|
|
Theme.snap(y + (rowH - dh) / 2), 0, scale, scale)
|
|
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
|
|
Theme.fill(lx, by, chipW, gear, PAL.bg, 1)
|
|
Theme.stroke(lx, by, chipW, gear, PAL.yellow, Theme.A.hover, 1)
|
|
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 = rx - gear
|
|
rx = quitX - math.floor(6 * m.s)
|
|
|
|
-- 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.
|
|
imp._gearIcon = imp._gearIcon
|
|
or love.graphics.newImage("assets/launcher/gear.png")
|
|
rx = rx - gear
|
|
iconButton(imp, "gear", rx, by, gear, imp._gearIcon,
|
|
function() imp:_openSettings() end)
|
|
|
|
-- Quit, top-right corner.
|
|
iconButton(imp, "quit", quitX, by, gear, nil,
|
|
function() imp:_quitApp() end,
|
|
function(x, y, size, hot)
|
|
local pad = math.floor(size * 0.32)
|
|
drawCross(x + pad, y + pad, size - 2 * pad,
|
|
hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 })
|
|
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
|
|
|
|
-- tab bar
|
|
imp._modsIcon = imp._modsIcon
|
|
or love.graphics.newImage("assets/launcher/mods.png")
|
|
imp._findIcon = imp._findIcon
|
|
or love.graphics.newImage("assets/launcher/find.png")
|
|
-- The three game tabs keep their cartridge colours -- that is the one piece
|
|
-- of brand identity in the launcher, and "the red one" is how people
|
|
-- actually refer to these. The colour rides the outline and the glyph at
|
|
-- rest and becomes the fill when active, the same rule the buttons follow.
|
|
local tabs = {
|
|
{ id = "red", letter = "R", label = Strings("RED"), color = PAL.railRed },
|
|
{ id = "blue", letter = "B", label = Strings("BLUE"), color = PAL.railBlue },
|
|
{ id = "yellow", letter = "Y", label = Strings("YELLOW"), color = PAL.railGold },
|
|
{ id = "mods", icon = imp._modsIcon, label = Strings("MODS") },
|
|
{ id = "find", icon = imp._findIcon, label = Strings("FIND MODS") },
|
|
}
|
|
local tabH = m.chip
|
|
local tx = m.x + m.pad
|
|
local ty = y + math.floor(6 * m.s)
|
|
for _, t in ipairs(tabs) do
|
|
local active = imp.tab == t.id
|
|
local key = "tab-" .. t.id
|
|
local labelW = Kit.textWidth("tab", t.label)
|
|
-- The active tab spells its name out; inactive tabs are the glyph alone,
|
|
-- so five tabs fit a phone width without wrapping.
|
|
local w = active and (tabH + math.floor(8 * m.s) + labelW + math.floor(12 * m.s))
|
|
or tabH
|
|
Kit._audit("control", tx, ty, w, tabH, key)
|
|
local focused = Kit.focusable(key, tx, ty, w, tabH)
|
|
local hot = focused or Kit.hover(tx, ty, w, tabH)
|
|
local invert = active or hot
|
|
local tint = t.color or PAL.ink
|
|
Theme.fillRounded(tx, ty, w, tabH, invert and tint or PAL.surface, 1)
|
|
if not invert then
|
|
Theme.strokeRounded(tx, ty, w, tabH, tint,
|
|
t.color and Theme.A.hover or Theme.A.hairline, 1)
|
|
end
|
|
-- Ink on a filled tab must contrast with THAT fill: black on the light
|
|
-- red/blue/gold cartridge colours, which are all high-luminance.
|
|
local ink = invert and PAL.inverse or (t.color or PAL.text)
|
|
if t.icon then
|
|
local iw, ih = t.icon:getDimensions()
|
|
local pad = math.floor(tabH * 0.24)
|
|
local s = math.min((tabH - 2 * pad) / iw, (tabH - 2 * pad) / ih)
|
|
if invert then love.graphics.setColor(0, 0, 0, 1)
|
|
else love.graphics.setColor(1, 1, 1, 0.9) end
|
|
love.graphics.draw(t.icon, Theme.snap(tx + (tabH - iw * s) / 2),
|
|
Theme.snap(ty + (tabH - ih * s) / 2), 0, s, s)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
else
|
|
Kit.textCenter("tab", t.letter, tx,
|
|
ty + (tabH - Kit.textHeight("tab")) / 2, tabH, ink)
|
|
end
|
|
if active then
|
|
Kit.text("tab", t.label, tx + tabH + math.floor(4 * m.s),
|
|
ty + (tabH - Kit.textHeight("tab")) / 2, ink)
|
|
end
|
|
if Kit.press(tx, ty, w, tabH) or Kit._activateId == key then
|
|
queueAction(imp, key, function() imp:_switchTab(t.id) end)
|
|
end
|
|
tx = tx + w + math.floor(6 * m.s)
|
|
end
|
|
|
|
y = ty + tabH + math.floor(8 * m.s)
|
|
Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline)
|
|
return y + math.floor(10 * m.s)
|
|
end
|
|
|
|
-- The state of the self-updater, as a top-right control.
|
|
-- 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 == "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" then
|
|
return status, Strings("Open releases"),
|
|
function() love.system.openURL(imp.Check.releaseUrl()) end, true
|
|
end
|
|
-- idle / uptodate / error: offer a manual check, with no glow.
|
|
return status, Strings("Check for updates"),
|
|
function() pcall(imp.Check.start) 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 dropHint = imp.isNX and Strings("Copy the .gb/.gbc via MTP into imports/.")
|
|
or (imp.baseRomDiscovery and Strings("Or copy the .gb/.gbc into baseroms/.")
|
|
or (imp.android and Strings("Copy the .gb/.gbc via USB.")
|
|
or Strings("Or drop the .gb/.gbc file here.")))
|
|
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
|
|
|
|
-- Save slots, PAGINATED. This was a fixed-height scroller with momentum; it
|
|
-- is now a page of rows sized to whatever height the column has left, which
|
|
-- is why 40 slots cost exactly what 4 do.
|
|
-- Lay a row's action chips out right-aligned, wrapping onto further lines
|
|
-- when they cannot all fit across the row. A narrow window (the 150%-scaled
|
|
-- desktop and the portrait phone in the reports) could not fit four chips on
|
|
-- one line, and a fixed right-to-left cluster simply walked them off the left
|
|
-- edge and under the row's own text. Returns an array of lines, each an
|
|
-- array of chips, so the caller can size the row BEFORE drawing it.
|
|
local function chipLines(chips, inner, gap)
|
|
local lines, line, used = {}, {}, 0
|
|
for _, c in ipairs(chips) do
|
|
if #line > 0 and used + gap + c.w > inner then
|
|
lines[#lines + 1] = line
|
|
line, used = {}, 0
|
|
end
|
|
used = used + ((#line > 0) and gap or 0) + c.w
|
|
line[#line + 1] = c
|
|
end
|
|
if #line > 0 then lines[#lines + 1] = line end
|
|
return lines
|
|
end
|
|
|
|
-- The width a chip needs for its caption, at the row-chip font.
|
|
local function chipWidth(label, m)
|
|
return Kit.textWidth("small", label) + math.floor(20 * m.s)
|
|
end
|
|
|
|
local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
|
|
imp:_ensureSlots(version)
|
|
local slots = imp.slots[version] or {}
|
|
local active = imp.activeSlot[version]
|
|
local n = #slots
|
|
local pad = math.floor(14 * m.s)
|
|
local iw = w - 2 * pad
|
|
local gap = math.floor(8 * m.s)
|
|
|
|
-- A slot row: name + LOADED tag, meta line, then the action chips. The
|
|
-- chip set is measured against the WIDEST possible row (every chip present)
|
|
-- so every row on the page is the same height even though an empty slot
|
|
-- offers fewer -- pagination derives its row count from a uniform height.
|
|
local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
|
local rowInner = iw - math.floor(20 * m.s)
|
|
local maxChips = {
|
|
{ w = chipWidth(Strings("Export"), m) },
|
|
{ w = chipWidth(Strings("Rename"), m) },
|
|
{ w = chipWidth(Strings("Edit"), m) },
|
|
-- Delete's width is pinned to the WIDER of its two captions so arming to
|
|
-- "Sure?" never reflows the row under the pointer (#433).
|
|
{ w = math.max(chipWidth(DELETE_LABEL(false), m),
|
|
chipWidth(DELETE_LABEL(true), m)) },
|
|
}
|
|
local chipGap = math.floor(6 * m.s)
|
|
local maxChipsW = 0
|
|
for i, c in ipairs(maxChips) do
|
|
maxChipsW = maxChipsW + c.w + ((i > 1) and chipGap or 0)
|
|
end
|
|
-- BESIDE the text when the row is wide enough to hold both and still leave
|
|
-- the name and meta lines a readable share, UNDER it when it is not. A
|
|
-- desktop row costs one text block instead of a text block plus a button
|
|
-- strip, which is what lets a two-column window show several slots per page
|
|
-- instead of one; a phone row keeps the taller shape rather than squeezing
|
|
-- four chips and a name into one line.
|
|
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
|
|
+ Kit.textHeight("small")
|
|
-- The threshold is what the TEXT needs, not a fraction of the row: a slot
|
|
-- name plus its badges/time/dex line wants about this much before it starts
|
|
-- ellipsizing anything a player came to read.
|
|
local textMinW = math.floor(150 * m.s)
|
|
local sideBySide =
|
|
(rowInner - maxChipsW - math.floor(12 * m.s)) >= textMinW
|
|
local chipRowCount = #chipLines(maxChips, rowInner, chipGap)
|
|
local chipBlockH = chipRowCount * chipH
|
|
+ math.max(0, chipRowCount - 1) * chipGap
|
|
local rowH
|
|
if sideBySide then
|
|
rowH = math.floor(8 * m.s) + math.max(textH, chipH) + math.floor(8 * m.s)
|
|
else
|
|
rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + chipBlockH
|
|
+ math.floor(8 * m.s)
|
|
end
|
|
|
|
-- The header carries "Import save": a .sav import CREATES a slot, so it
|
|
-- belongs to the slot list rather than to the ROM card it used to sit in.
|
|
local headH = math.max(Kit.textHeight("caption"), m.btnH) + math.floor(8 * m.s)
|
|
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
|
local newBtnH = m.btnH
|
|
local sfNotice = imp.saveNotice[version]
|
|
local hintText, hintCol
|
|
if sfNotice then
|
|
hintText, hintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red)
|
|
else
|
|
hintText, hintCol = nil, PAL.muted
|
|
end
|
|
local hintH = hintText
|
|
and (Kit.wrapHeight("small", hintText, iw, 2) + math.floor(8 * m.s)) or 0
|
|
local folderRow = sfNotice and sfNotice.dir
|
|
if folderRow then hintH = hintH + Kit.textHeight("small") + math.floor(4 * m.s) end
|
|
|
|
-- Rows get whatever is left after the card's fixed furniture.
|
|
local listH = availH
|
|
- (pad * 2 + headH + hintH + pagerH + gap + newBtnH + gap)
|
|
local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 12)
|
|
local pageKey = "slots-" .. version
|
|
local first, last, cur, pages = Kit.pageBounds(page(imp, pageKey), n, perPage)
|
|
setPage(imp, pageKey, cur)
|
|
|
|
local shown = math.max(0, last - first + 1)
|
|
local usedListH = (n == 0) and math.floor(70 * m.s)
|
|
or (shown * rowH + math.max(0, shown - 1) * gap)
|
|
local h = pad + headH + usedListH + gap + hintH
|
|
+ (pages > 1 and (pagerH + gap) or 0) + newBtnH + pad
|
|
|
|
Kit.card(x, y, w, h)
|
|
local cy = y + pad
|
|
local capY = cy + math.floor((m.btnH - Kit.textHeight("caption")) / 2)
|
|
Kit.caption(x + pad, capY, Strings("SAVE SLOT"))
|
|
local savImportLabel = imp.isNX and Strings("Scan again")
|
|
or Strings("Import save")
|
|
local impW = chipWidth(savImportLabel, m) + math.floor(8 * m.s)
|
|
btn(imp, x + w - pad - impW, cy, impW, m.btnH, "sav-import-" .. version,
|
|
savImportLabel, {
|
|
kind = "accent", font = "small", enabled = ready and true or false,
|
|
action = ready and function() imp:chooseSaveImport(version) end or nil,
|
|
})
|
|
local countW = (x + w - pad - impW - math.floor(8 * m.s))
|
|
- (x + pad + Kit.captionWidth(Strings("SAVE SLOT")) + math.floor(8 * m.s))
|
|
if countW > 0 then
|
|
Kit.textRight("small",
|
|
n == 1 and Strings("1 slot") or Strings("%d slots", n),
|
|
x + w - pad - impW - math.floor(8 * m.s), capY, PAL.muted)
|
|
end
|
|
cy = cy + headH
|
|
|
|
if n == 0 then
|
|
Kit.emptyBox(x + pad, cy, iw, usedListH,
|
|
Strings("No saves yet - start a new game or import one."))
|
|
cy = cy + usedListH + gap
|
|
else
|
|
-- Wheel over the list turns pages; the page index is bounded, so there is
|
|
-- no scroll offset to interpolate and nothing to clamp against content.
|
|
setPage(imp, pageKey,
|
|
Kit.wheelPage(x + pad, cy, iw, usedListH, cur, n, perPage))
|
|
for i = first, last do
|
|
local slot = slots[i]
|
|
local selected = slot.id == active
|
|
local rowKey = "slot-" .. version .. "-" .. slot.id
|
|
local ry = cy + (i - first) * (rowH + gap)
|
|
local ink = rowHit(imp, x + pad, ry, iw, rowH, selected, rowKey,
|
|
function() imp:_selectSlot(version, slot.id) end)
|
|
|
|
local px = x + pad + math.floor(10 * m.s)
|
|
local inner = iw - math.floor(20 * m.s)
|
|
-- Beside the chips, the text block only owns what they leave; under
|
|
-- them it owns the row. Either way the width is fixed before anything
|
|
-- prints, so the name ellipsizes into its own space rather than into
|
|
-- a button.
|
|
local textW = sideBySide
|
|
and (inner - maxChipsW - math.floor(12 * m.s)) or inner
|
|
local ly = ry + math.floor(8 * m.s)
|
|
+ (sideBySide and math.floor((math.max(textH, chipH) - textH) / 2) or 0)
|
|
local name = slot.label or slot.name or Strings("NEW GAME")
|
|
local tagW = 0
|
|
if selected then
|
|
tagW = Kit.textWidth("micro", Strings("LOADED")) + math.floor(16 * m.s)
|
|
Kit.tag(px + textW - tagW, ly, tagW, Kit.textHeight("button"),
|
|
Strings("LOADED"), PAL.inverse)
|
|
tagW = tagW + math.floor(8 * m.s)
|
|
end
|
|
Kit.text("button", Kit.ellipsize("button", name, textW - tagW), px, ly, ink)
|
|
ly = ly + Kit.textHeight("button") + math.floor(4 * m.s)
|
|
local metaTxt
|
|
if slot.exists and slot.meta then
|
|
metaTxt = Strings("%d badges - %s - %d caught", slot.meta.badges or 0,
|
|
slot.meta.timeText or "0:00", slot.meta.dexCount or 0)
|
|
else
|
|
metaTxt = Strings("empty slot")
|
|
end
|
|
Kit.text("small", Kit.ellipsize("small", metaTxt, textW), px, ly,
|
|
selected and PAL.inverse or PAL.muted)
|
|
-- Where the chip block starts: centred on the row beside the text, or
|
|
-- on its own line under it.
|
|
ly = sideBySide and (ry + (rowH - chipBlockH) / 2)
|
|
or (ly + Kit.textHeight("small") + math.floor(8 * m.s))
|
|
|
|
-- Action chips, right-aligned and wrapped onto as many lines as the row
|
|
-- width needs. Export lives HERE rather than beside the ROM buttons:
|
|
-- an export is a property of a slot, so the control belongs on the slot
|
|
-- it exports (it selects the row first, since the exporter writes
|
|
-- whichever slot is active).
|
|
local armed = deleteArmed(imp, "slot", slot.id, version)
|
|
local chips = {}
|
|
if slot.exists then
|
|
chips[#chips + 1] = { label = Strings("Export"), kind = "accent",
|
|
key = rowKey .. "-export",
|
|
action = function()
|
|
imp:_selectSlot(version, slot.id)
|
|
imp:exportSave(version)
|
|
end }
|
|
end
|
|
if not imp.android then
|
|
chips[#chips + 1] = { label = Strings("Rename"), kind = "accent",
|
|
key = rowKey .. "-rename",
|
|
action = function() imp:_beginRename(version, slot.id) end }
|
|
end
|
|
if imp.onEditSave and slot.exists then
|
|
chips[#chips + 1] = { label = Strings("Edit"), kind = "accent",
|
|
key = rowKey .. "-edit",
|
|
action = function() imp.onEditSave(version, slot.id) end }
|
|
end
|
|
chips[#chips + 1] = { label = DELETE_LABEL(armed), kind = "danger",
|
|
keepArm = true, key = rowKey .. "-del",
|
|
-- Pinned width, so arming to "Sure?" cannot reflow the cluster.
|
|
w = math.max(chipWidth(DELETE_LABEL(false), m),
|
|
chipWidth(DELETE_LABEL(true), m)),
|
|
action = function()
|
|
imp:pressDelete("slot", slot.id, version, function()
|
|
imp:_deleteSlot(version, slot.id)
|
|
end)
|
|
end }
|
|
for _, c in ipairs(chips) do c.w = c.w or chipWidth(c.label, m) end
|
|
for li, line in ipairs(chipLines(chips, inner, chipGap)) do
|
|
local total = 0
|
|
for i, c in ipairs(line) do
|
|
total = total + c.w + ((i > 1) and chipGap or 0)
|
|
end
|
|
local cx = px + inner - total
|
|
local cly = ly + (li - 1) * (chipH + chipGap)
|
|
for _, c in ipairs(line) do
|
|
btn(imp, cx, cly, c.w, chipH, c.key, c.label, {
|
|
kind = c.kind, font = "small", keepArm = c.keepArm,
|
|
action = c.action,
|
|
})
|
|
cx = cx + c.w + chipGap
|
|
end
|
|
end
|
|
end
|
|
cy = cy + usedListH + gap
|
|
end
|
|
|
|
-- The save-file notice (import/export result) lands in this card now that
|
|
-- the buttons that produce it do.
|
|
if hintText then
|
|
cy = cy + Kit.textWrapped("small", hintText, x + pad, cy, iw, hintCol, 2)
|
|
if folderRow then
|
|
cy = cy + math.floor(4 * m.s)
|
|
local key = "sav-folder-" .. version
|
|
local label = Strings("Open folder")
|
|
local lw = Kit.textWidth("small", label)
|
|
local lh = Kit.textHeight("small")
|
|
Kit.focusable(key, x + pad, cy, lw, lh)
|
|
Kit.text("small", label, x + pad, cy, PAL.blue)
|
|
Theme.fill(x + pad, cy + lh - 1, lw, 1, PAL.blue, 0.6)
|
|
if Kit.press(x + pad, cy, lw, lh) or Kit._activateId == key then
|
|
local dir = sfNotice.dir
|
|
queueAction(imp, key, function()
|
|
love.system.openURL(imp:fileUrl(dir))
|
|
end)
|
|
end
|
|
cy = cy + lh
|
|
end
|
|
cy = cy + math.floor(8 * m.s)
|
|
end
|
|
|
|
if pages > 1 then
|
|
local newPage = Kit.pager(x + pad, cy, iw, cur, n, perPage, pageKey)
|
|
setPage(imp, pageKey, newPage)
|
|
cy = cy + pagerH + gap
|
|
end
|
|
btn(imp, x + pad, cy, iw, newBtnH, "slot-new-" .. version,
|
|
Strings("+ New save slot"), {
|
|
kind = "good",
|
|
action = function() imp:_newSlot(version) end,
|
|
})
|
|
return h
|
|
end
|
|
|
|
local function buildGamePanel(imp, x, y, w, availH, m, version)
|
|
imp.panelVersion = version
|
|
local info = GameVersion.info(version)
|
|
local locked = info == nil
|
|
local gameName = info and (info.launcherName or info.displayName)
|
|
or tostring(version)
|
|
local ready = (not locked) and imp.ready[version] or false
|
|
|
|
-- title + status tag
|
|
local titleH = Kit.textHeight("title")
|
|
Kit.text("title", Kit.ellipsize("title", gameName, w * 0.6), x, y, PAL.heading)
|
|
local tagText, tagCol
|
|
if ready then tagText, tagCol = Strings("GOOD TO GO"), PAL.green
|
|
elseif imp.baseRoms and imp.baseRoms[version] then
|
|
tagText, tagCol = Strings("ROM FOUND"), PAL.green
|
|
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
|
|
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
|
|
local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
|
|
local tagH = Kit.textHeight("micro") + math.floor(10 * m.s)
|
|
Kit.tag(x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6))
|
|
+ math.floor(12 * m.s), y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol)
|
|
local cy = y + titleH + math.floor(12 * m.s)
|
|
local remaining = availH - (titleH + math.floor(12 * m.s))
|
|
|
|
local gap = m.gap
|
|
local lx, lw, rx2, rw
|
|
if m.twoCol then
|
|
lx, lw = x, m.colW
|
|
rx2, rw = x + m.colW + m.colGap, m.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
|
|
-- Play IS the panel: it takes the space the ROM buttons used to hold, at
|
|
-- the top of the column where the eye lands, wearing this game's own
|
|
-- cartridge colour rather than a generic green.
|
|
-- Play grows into the room the column has: it is the one thing on this
|
|
-- screen the player came for, and the space freed by moving ROM and
|
|
-- control management out belongs to it rather than to a gap. Clamped at
|
|
-- both ends so a short window still gets a real button and a tall one
|
|
-- does not get a billboard.
|
|
local playH = math.floor(clamp(remaining * 0.30, 64 * m.s, 132 * m.s))
|
|
local mgW = playH
|
|
local bgap = math.floor(8 * m.s)
|
|
btn(imp, lx, ly, lw - mgW - bgap, playH, "play-" .. version,
|
|
Strings("Play ") .. gameName, {
|
|
fill = cartColor(version), ink = PAL.inverse, font = "stat",
|
|
action = function() imp:play(version) end,
|
|
})
|
|
imp._gearIcon = imp._gearIcon
|
|
or love.graphics.newImage("assets/launcher/gear.png")
|
|
iconButton(imp, "manage-" .. version, lx + lw - mgW, ly, playH,
|
|
imp._gearIcon, function() imp._gameManage = version end)
|
|
ly = ly + playH + gap
|
|
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.
|
|
if not locked then
|
|
local slotY = m.twoCol and cy or ly
|
|
local slotAvail = m.twoCol and remaining or (cy + remaining - ly)
|
|
if slotAvail > 80 * m.s then
|
|
Kit.pushClip(rx2, slotY, rw, math.max(0, slotAvail))
|
|
buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version, ready)
|
|
Kit.popClip()
|
|
end
|
|
end
|
|
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.
|
|
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()
|
|
return {
|
|
{ key = "name", label = Strings("Name") },
|
|
{ key = "popularity", label = Strings("Popularity") },
|
|
{ key = "release", label = Strings("Release date") },
|
|
{ key = "updated", label = Strings("Last updated") },
|
|
}
|
|
end
|
|
|
|
local function currentSort(imp)
|
|
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
|
|
return sortKey
|
|
end
|
|
|
|
-- A hand-drawn check mark: the UI font has no guaranteed glyph for one, and
|
|
-- a tofu box on the "you already have this" signal would be worse than none.
|
|
local function drawCheck(x, y, size, color)
|
|
love.graphics.push("all")
|
|
love.graphics.setColor(color)
|
|
love.graphics.setLineWidth(math.max(2, size * 0.16))
|
|
love.graphics.setLineJoin("bevel")
|
|
love.graphics.line(
|
|
x, y + size * 0.55,
|
|
x + size * 0.35, y + size * 0.85,
|
|
x + size * 0.95, y + size * 0.15)
|
|
love.graphics.pop()
|
|
end
|
|
|
|
local function buildModsPanel(imp, x, y, w, availH, m)
|
|
imp:_ensureMods()
|
|
local ModUpdate = require("src.mods.ModUpdate")
|
|
local mods = imp.mods or {}
|
|
local gap = m.gap
|
|
local cy = y
|
|
|
|
-- header: just the action cluster, right-aligned. No "Mods" headline (the
|
|
-- active tab already says it) and no enabled count (the toggles show it).
|
|
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
|
|
local bh = m.btnH
|
|
local importLabel = imp:_modsImportButtonLabel()
|
|
local iw2 = Kit.textWidth("small", importLabel) + math.floor(24 * m.s)
|
|
btn(imp, place(iw2), cy, iw2, bh, "mods-import", importLabel, {
|
|
kind = "accent", font = "small",
|
|
action = function() imp:chooseMod() end })
|
|
if #mods > 0 then
|
|
local dw = Kit.textWidth("small", Strings("Disable all")) + math.floor(20 * m.s)
|
|
btn(imp, place(dw), cy, dw, bh, "mods-disable-all", Strings("Disable all"), {
|
|
kind = "warn", font = "small",
|
|
action = function() imp:_setAllMods(false) end })
|
|
local ew = Kit.textWidth("small", Strings("Enable all")) + math.floor(20 * m.s)
|
|
btn(imp, place(ew), cy, ew, bh, "mods-enable-all", Strings("Enable all"), {
|
|
kind = "good", font = "small",
|
|
action = function() imp:_setAllMods(true) end })
|
|
local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(24 * m.s)
|
|
btn(imp, place(sw), cy, sw, bh, "mods-sort", Strings("Sort"), {
|
|
font = "small",
|
|
action = function() imp._sortPopup = true end })
|
|
end
|
|
cy = cy + bh + math.floor(8 * m.s)
|
|
|
|
-- notice line
|
|
local noticeText, noticeCol
|
|
if 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 #mods == 0 then
|
|
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
|
|
return
|
|
end
|
|
|
|
local sortKey = currentSort(imp)
|
|
|
|
-- 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 cache = imp._modSortCache
|
|
if cache and cache.src == mods and cache.n == #mods
|
|
and cache.key == sortKey and cache.rev == (imp._modUpdateRev or 0) then
|
|
mods = cache.list
|
|
else
|
|
local sorted = {}
|
|
for i, v in ipairs(mods) do sorted[i] = v end
|
|
table.sort(sorted, function(a, b)
|
|
local function value(mod)
|
|
if sortKey == "name" then return (mod.name or ""):lower() 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
|
|
local va, vb = value(a), value(b)
|
|
if va ~= vb then
|
|
if sortKey == "name" then return va < vb end
|
|
return va > vb -- data sorts newest / most popular first
|
|
end
|
|
return (a.name or ""):lower() < (b.name or ""):lower()
|
|
end)
|
|
imp._modSortCache = { src = imp.mods, n = #mods, key = sortKey,
|
|
rev = imp._modUpdateRev or 0, list = sorted }
|
|
mods = sorted
|
|
end
|
|
|
|
-- A mod row is a fixed height: name line, version + status line, one line
|
|
-- of description, and an action row. Fixed because a page of uniform rows
|
|
-- is what lets perPage come from the viewport.
|
|
local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
|
-- Text block on the left, chips right-aligned beside it: one row, not a
|
|
-- text block with a button strip stacked under it.
|
|
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
|
|
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small")
|
|
local rowH = math.floor(8 * m.s) + math.max(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, 1, 20)
|
|
local first, last, cur, pages = Kit.pageBounds(page(imp, "mods"), #mods, perPage)
|
|
setPage(imp, "mods", cur)
|
|
local listTop = cy
|
|
setPage(imp, "mods",
|
|
Kit.wheelPage(x, listTop, w, listH, cur, #mods, perPage))
|
|
|
|
for i = first, last do
|
|
local mod = mods[i]
|
|
local ry = listTop + (i - first) * (rowH + gap)
|
|
local rowKey = "mod-row-" .. mod.id
|
|
-- The whole row is the control: it opens the per-mod actions popup
|
|
-- (update / versions / delete moved there). Only the enable toggle
|
|
-- stays inline, because flipping a mod on and off is the everyday act.
|
|
local focused = Kit.focusable(rowKey, x, ry, w, rowH)
|
|
local hot = focused or 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(10 * m.s)
|
|
|
|
local togW = math.floor(56 * m.s)
|
|
local togH = math.floor(26 * m.s)
|
|
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
|
|
|
|
local togKey = "mod-toggle-" .. mod.id
|
|
-- The toggle reports its own new value, but the importer owns the state:
|
|
-- queue the flip and let _toggleMod (which may raise an experimental-mod
|
|
-- confirm) decide what actually happens.
|
|
local _, flipped = Kit.toggle(px + inner - togW,
|
|
ry + (rowH - togH) / 2, togW, togH, mod.enabled, togKey)
|
|
if flipped then
|
|
queueAction(imp, togKey, function() imp:_toggleMod(mod.id) end)
|
|
end
|
|
-- The toggle sits inside the row's rect, so its press also passes the
|
|
-- row's hit test; `flipped` gates the row action to everywhere else.
|
|
if not flipped
|
|
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
|
|
local id = mod.id
|
|
queueAction(imp, rowKey, function() imp._modActions = id end)
|
|
end
|
|
local chipsW = togW + math.floor(6 * m.s)
|
|
local textW = inner - chipsW - math.floor(12 * m.s)
|
|
|
|
local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s)
|
|
local nameShown = Kit.ellipsize("button", mod.name,
|
|
textW - badgeW - math.floor(8 * m.s))
|
|
Kit.text("button", nameShown, px, ly, PAL.heading)
|
|
Kit.tag(px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s), ly,
|
|
badgeW, Kit.textHeight("button"), mod.badge,
|
|
mod.experimental and PAL.yellow or PAL.muted)
|
|
ly = ly + Kit.textHeight("button") + 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", line, 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", Strings("v%s available", tostring(info.latest)),
|
|
lx, ly, PAL.yellow)
|
|
elseif info and info.status == "current" then
|
|
Kit.text("small", Strings("up to date"), lx, ly, PAL.muted)
|
|
elseif info and info.status == "error" then
|
|
Kit.text("small", Strings("check failed"), 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 (mod.description or "") ~= "" then
|
|
Kit.text("small", Kit.ellipsize("small", mod.description, textW),
|
|
px, ly, PAL.detail)
|
|
end
|
|
end
|
|
|
|
local pagerY = listTop + (last - first + 1) * (rowH + gap)
|
|
local newPage = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods")
|
|
setPage(imp, "mods", newPage)
|
|
end
|
|
|
|
-- ---------------------------------------------------------- find mods panel
|
|
|
|
local function buildFindPanel(imp, x, y, w, availH, m)
|
|
imp:_ensureFind()
|
|
imp:_ensureMods()
|
|
local ModIndex = require("src.mods.ModIndex")
|
|
local ModUpdate = require("src.mods.ModUpdate")
|
|
local sources = imp.findSources or {}
|
|
local rows = imp:_findRows()
|
|
local total = #((imp.findIndex and 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
|
|
end
|
|
|
|
-- 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 = true end })
|
|
-- The Filter button carries its state: blue while a category is active,
|
|
-- so a filtered-down list never reads as "the index shrank".
|
|
local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s)
|
|
btn(imp, place(fw), cy, fw, fieldH, "find-filter", Strings("Filter"), {
|
|
kind = imp.findCategory 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 "",
|
|
Strings("Search mods"), imp._findSearchFocus == true,
|
|
function() imp:_toggleFindSearchFocus() end)
|
|
cy = cy + fieldH + math.floor(8 * m.s)
|
|
|
|
if #rows == 0 then
|
|
Kit.emptyBox(x, cy, w, math.floor(110 * m.s),
|
|
(total == 0) and Strings("This index lists no mods yet.")
|
|
or Strings("No mods match that search."))
|
|
return
|
|
end
|
|
|
|
local sortKey = currentSort(imp)
|
|
|
|
-- Same caching rule as the MODS tab: the comparator allocates, so only
|
|
-- re-sort when the inputs actually change.
|
|
local fcache = imp._findSortCache
|
|
if fcache and fcache.src == rows and fcache.key == sortKey
|
|
and fcache.rev == (imp._findStatsRev or 0) then
|
|
rows = fcache.list
|
|
else
|
|
local sorted = {}
|
|
for i, v in ipairs(rows) do sorted[i] = v end
|
|
table.sort(sorted, function(a, b)
|
|
local function value(entry)
|
|
if sortKey == "name" then return (entry.title or entry.id or ""):lower() end
|
|
local stats = imp:_findStats(entry)
|
|
if sortKey == "popularity" then return stats and stats.total 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
|
|
local va, vb = value(a), value(b)
|
|
if va ~= vb then
|
|
if sortKey == "name" then return va < vb end
|
|
return va > vb
|
|
end
|
|
return (a.title or a.id or ""):lower() < (b.title or b.id or ""):lower()
|
|
end)
|
|
imp._findSortCache = { src = rows, key = sortKey,
|
|
rev = imp._findStatsRev or 0, 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, 1, 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))
|
|
|
|
for i = first, last do
|
|
local entry = rows[i]
|
|
local ry = listTop + (i - first) * (rowH + gap)
|
|
local rowKey = "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 focused = Kit.focusable(rowKey, x, ry, w, rowH)
|
|
local hot = focused or 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)
|
|
Kit.textCenter("micro", "MOD", px,
|
|
ly + (thumb - Kit.textHeight("micro")) / 2, thumb, PAL.faint)
|
|
end
|
|
|
|
local bx = px + thumb + math.floor(10 * m.s)
|
|
local bw = inner - thumb - math.floor(10 * m.s) - chipsW
|
|
Kit.text("button", Kit.ellipsize("button", entry.title or entry.id, bw),
|
|
bx, ly, PAL.heading)
|
|
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 Popularity 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.downloadsLine(stats.total) or 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 entry.categories and entry.categories[1] then
|
|
rest[#rest + 1] = entry.categories[1]
|
|
end
|
|
if dates then
|
|
rest[#rest + 1] = dates
|
|
elseif not dl and (entry.summary or "") ~= "" then
|
|
rest[#rest + 1] = entry.summary
|
|
end
|
|
local segs = { { lead, baseCol } }
|
|
if dl then segs[#segs + 1] = { " - " .. dl, PAL.green } end
|
|
if #rest > 0 then
|
|
segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol }
|
|
end
|
|
segLine("small", segs, bx, by2, bw)
|
|
end
|
|
|
|
local pagerY = listTop + (last - first + 1) * (rowH + gap)
|
|
setPage(imp, "find", Kit.pager(x, pagerY, w, cur, #rows, perPage, "find"))
|
|
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)"
|
|
|
|
-- 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 + 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 rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
|
|
return math.floor(8 * m.s) + rowH + math.floor(6 * m.s)
|
|
+ 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 rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
|
|
-- The mark and the small self-update control 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.
|
|
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
|
|
-- Kit.button insets its label 16*scale per side, so the width must budget
|
|
-- more than that or the label ellipsizes ("Check for updat...").
|
|
local uw = upStatus
|
|
and (Kit.textWidth("micro", upLabel) + math.floor(36 * m.s)) or 0
|
|
local groupW = dw + (upStatus and (math.floor(10 * m.s) + uw) or 0)
|
|
local bx = m.x + math.floor((m.w - groupW) / 2)
|
|
local my = cy + math.floor((rowH - dh) / 2)
|
|
local hot = Kit.hover(bx, my, dw, dh)
|
|
love.graphics.setShader(imp.invertShader)
|
|
love.graphics.setColor(1, 1, 1, hot 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) then
|
|
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
|
|
end
|
|
if upStatus then
|
|
btn(imp, bx + dw + math.floor(10 * m.s), cy, uw, rowH, "updater",
|
|
upLabel, {
|
|
kind = upGlow and "warn" or "ghost", font = "micro",
|
|
glow = upGlow, action = upAction,
|
|
})
|
|
end
|
|
cy = cy + rowH + math.floor(6 * m.s)
|
|
-- 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 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.
|
|
Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.93)
|
|
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)
|
|
Kit.card(px, py, pw, ph, true)
|
|
Kit.blockClicks = false
|
|
return px, py, pw, ph
|
|
end
|
|
|
|
-- Shared prompt: title, read-only field over the importer's text, buttons.
|
|
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 hintH = spec.hint and (Kit.wrapHeight("small", spec.hint,
|
|
w - 2 * pad, 2) + math.floor(6 * m.s)) or 0
|
|
local footH = spec.footnote and (Kit.textHeight("micro")
|
|
+ math.floor(8 * m.s)) or 0
|
|
local h = pad + Kit.textHeight("button") + math.floor(10 * m.s) + hintH
|
|
+ fieldH + math.floor(12 * m.s) + m.btnH + footH + pad
|
|
local px, py, pw = modalPanel(m, w, h)
|
|
local cy = py + pad
|
|
Kit.text("button", spec.title, px + pad, cy, PAL.heading)
|
|
cy = cy + Kit.textHeight("button") + math.floor(10 * m.s)
|
|
if spec.hint then
|
|
cy = cy + Kit.textWrapped("small", spec.hint, px + pad, cy,
|
|
pw - 2 * pad, PAL.detail, 2) + math.floor(6 * m.s)
|
|
end
|
|
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, spec.key .. "-field",
|
|
spec.text or "", nil, true)
|
|
cy = cy + fieldH + math.floor(12 * m.s)
|
|
|
|
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)
|
|
local c = imp._modConfirm
|
|
local pad = math.floor(22 * m.s)
|
|
local w = math.floor(520 * m.s)
|
|
local lineH = Kit.textHeight("small") + math.floor(4 * m.s)
|
|
local h = pad + Kit.textHeight("stat") + math.floor(12 * m.s)
|
|
+ #(c.lines or {}) * lineH + math.floor(12 * m.s) + m.btnH + pad
|
|
local px, py, pw = modalPanel(m, w, h)
|
|
local cy = py + pad
|
|
Kit.text("stat", c.title or Strings("Confirm"), px + pad, cy, PAL.heading)
|
|
cy = cy + Kit.textHeight("stat") + math.floor(12 * m.s)
|
|
for _, line in ipairs(c.lines or {}) do
|
|
Kit.text("small", Kit.ellipsize("small", line, pw - 2 * pad),
|
|
px + pad, cy, PAL.detail)
|
|
cy = cy + lineH
|
|
end
|
|
cy = cy + math.floor(12 * m.s)
|
|
local gap = math.floor(10 * m.s)
|
|
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()
|
|
imp._modConfirm = nil
|
|
if c.indexEntry then
|
|
imp:_findInstall(c.indexEntry)
|
|
elseif c.kind == "update" then
|
|
imp:_confirmModUpdate(c.id, c.release)
|
|
elseif c.kind == "enableAll" then
|
|
imp:_setAllMods(true, true)
|
|
elseif c.kind == "importOversize" then
|
|
imp:_importSave(c.version, c.source, true)
|
|
else
|
|
imp:_toggleMod(c.id, true)
|
|
end
|
|
end,
|
|
})
|
|
btn(imp, px + pad + halfW + gap, cy, halfW, m.btnH, "confirm-no",
|
|
Strings("Cancel"), { font = "small",
|
|
action = function() imp._modConfirm = nil 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
|
|
Kit.text("button", Kit.ellipsize("button", title, pw - 2 * pad),
|
|
px + pad, cy, PAL.heading)
|
|
cy = cy + Kit.textHeight("button") + 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
|
|
|
|
-- 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 defs = sortDefs()
|
|
local pad = math.floor(18 * m.s)
|
|
local w = math.floor(360 * m.s)
|
|
local gap = math.floor(8 * m.s)
|
|
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
|
+ #defs * (m.btnH + gap) + m.btnH + pad
|
|
local px, py, pw = modalPanel(m, w, h)
|
|
local cy = py + pad
|
|
Kit.text("button", Strings("Sort by"), px + pad, cy, PAL.heading)
|
|
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
|
local cur = currentSort(imp)
|
|
for _, s in ipairs(defs) do
|
|
local key = s.key
|
|
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-" .. key, 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 })
|
|
cy = cy + m.btnH + gap
|
|
end
|
|
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-close",
|
|
Strings("Close"), { font = "small",
|
|
action = function() imp._sortPopup = nil end })
|
|
end
|
|
|
|
-- Category filter for FIND MODS. Two columns, because an index can list
|
|
-- enough categories to overflow a single stacked column on a short window.
|
|
local function buildFilterModal(imp, m)
|
|
local cats = (imp.findIndex and imp.findIndex.categories) or {}
|
|
local items = { { key = nil, label = Strings("All") } }
|
|
for _, c in ipairs(cats) do items[#items + 1] = { key = c, label = c } end
|
|
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 h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
|
+ nrows * (m.btnH + gap) + m.btnH + pad
|
|
local px, py, pw = modalPanel(m, w, h)
|
|
local cy = py + pad
|
|
Kit.text("button", Strings("Filter by category"), px + pad, cy, PAL.heading)
|
|
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
|
local colW = math.floor((pw - 2 * pad - gap) / 2)
|
|
for i, it in ipairs(items) do
|
|
local bx = px + pad + ((i - 1) % 2) * (colW + gap)
|
|
local by = cy + math.floor((i - 1) / 2) * (m.btnH + gap)
|
|
local key = it.key
|
|
btn(imp, bx, by, colW, m.btnH, "filterpop-" .. (key or "all"), it.label, {
|
|
kind = (imp.findCategory == key) and "primary" or "ghost",
|
|
font = "small",
|
|
action = function()
|
|
imp.findCategory = key
|
|
setPage(imp, "find", 1)
|
|
imp._filterPopup = nil
|
|
end })
|
|
end
|
|
cy = cy + nrows * (m.btnH + gap)
|
|
btn(imp, px + pad, cy, pw - 2 * pad, 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 h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + listH
|
|
+ math.floor(6 * m.s) + 3 * (m.btnH + math.floor(8 * m.s))
|
|
- math.floor(8 * m.s) + pad
|
|
local px, py, pw = modalPanel(m, w, h)
|
|
local cy = py + pad
|
|
Kit.text("button", Strings("Mod indexes"), px + pad, cy, PAL.heading)
|
|
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
|
if #sources == 0 then
|
|
Kit.text("small", Strings("No index added yet."), px + pad, cy, PAL.muted)
|
|
cy = cy + Kit.textHeight("small") + gap
|
|
else
|
|
for _, source in ipairs(sources) do
|
|
local feed = source.feed
|
|
local rmW = Kit.textWidth("small", Strings("Remove"))
|
|
+ math.floor(20 * m.s)
|
|
Kit.text("small", Kit.ellipsize("small", source.label or feed,
|
|
pw - 2 * pad - rmW - math.floor(12 * m.s)), px + pad,
|
|
cy + (rowH - Kit.textHeight("small")) / 2, PAL.detail)
|
|
btn(imp, px + pw - pad - rmW, cy, rmW, rowH,
|
|
"idx-rm-" .. tostring(feed), Strings("Remove"), {
|
|
kind = "danger", font = "small",
|
|
action = function() imp:_removeIndex(feed) end })
|
|
cy = cy + rowH + gap
|
|
end
|
|
end
|
|
cy = cy + math.floor(6 * m.s)
|
|
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "idx-add",
|
|
Strings("Add index"), { kind = "accent", font = "small",
|
|
action = function() imp:_promptAddIndex() end })
|
|
cy = cy + m.btnH + math.floor(8 * m.s)
|
|
btn(imp, px + pad, cy, pw - 2 * pad, 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 })
|
|
cy = cy + m.btnH + math.floor(8 * m.s)
|
|
btn(imp, px + pad, cy, pw - 2 * pad, 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 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 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 nBtns = (hasGit and 2 or 0) + 2
|
|
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
|
+ Kit.textHeight("small") + math.floor(12 * m.s)
|
|
+ nBtns * (m.btnH + gap) - gap + pad
|
|
local px, py, pw = modalPanel(m, w, h)
|
|
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)
|
|
cy = cy + Kit.textHeight("small") + math.floor(12 * m.s)
|
|
local id = mod.id
|
|
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, cy, pw - 2 * pad, 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, cy, pw - 2 * pad, m.btnH, "modact-ver",
|
|
Strings("Versions"), { kind = "accent", font = "small",
|
|
action = function() imp:_modGithubAction(id, "versions") end })
|
|
cy = cy + m.btnH + gap
|
|
end
|
|
local armed = deleteArmed(imp, "mod", id, nil)
|
|
btn(imp, px + pad, cy, pw - 2 * pad, 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 })
|
|
cy = cy + m.btnH + gap
|
|
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-close",
|
|
Strings("Close"), { font = "small",
|
|
action = function() imp._modActions = 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 h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
|
+ Kit.textHeight("small") + noteH + math.floor(12 * m.s)
|
|
+ nBtns * (m.btnH + gap) - gap + pad
|
|
local px, py, pw = modalPanel(m, w, h)
|
|
local cy = py + pad
|
|
Kit.text("button", Kit.ellipsize("button", entry.title or entry.id,
|
|
pw - 2 * pad), px + pad, cy, PAL.heading)
|
|
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
|
|
local stats = imp:_findStats(entry)
|
|
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
|
|
if entry.author then lead = lead .. " - " .. entry.author end
|
|
if entry.categories and entry.categories[1] then
|
|
lead = lead .. " - " .. entry.categories[1]
|
|
end
|
|
local dl = stats and ModUpdate.downloadsLine(stats.total) or nil
|
|
local segs = { { lead, PAL.detail } }
|
|
if dl then segs[#segs + 1] = { " - " .. dl, PAL.green } end
|
|
segLine("small", segs, px + pad, cy, pw - 2 * pad)
|
|
cy = cy + Kit.textHeight("small")
|
|
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)
|
|
if action then
|
|
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "findpop-inst", action, {
|
|
kind = "primary", font = "small",
|
|
action = function()
|
|
imp._findEntry = nil
|
|
imp:_findConfirmInstall(entry)
|
|
end })
|
|
else
|
|
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "findpop-inst",
|
|
Strings("Not installable from this index"),
|
|
{ font = "small", enabled = false })
|
|
end
|
|
cy = cy + m.btnH + gap
|
|
local half = entry.repo and math.floor((pw - 2 * pad - gap) / 2)
|
|
or (pw - 2 * pad)
|
|
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
|
|
cy = cy + m.btnH + gap
|
|
btn(imp, px + pad, cy, 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 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 pad = math.floor(18 * m.s)
|
|
local w = math.floor(460 * m.s)
|
|
local gap = math.floor(8 * m.s)
|
|
local bodyW = w - 2 * 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 nBtns = 1 + (canOpenFolder and 1 or 0) + 1
|
|
local h = pad + Kit.textHeight("button") + math.floor(8 * m.s) + detailH
|
|
+ math.floor(12 * m.s) + pathH + nBtns * (m.btnH + gap) - gap + pad
|
|
local px, py, pw = modalPanel(m, w, h)
|
|
local cy = py + pad
|
|
|
|
Kit.text("button", Kit.ellipsize("button",
|
|
Strings("Manage ") .. gameName, pw - 2 * pad), px + pad, cy, PAL.heading)
|
|
cy = cy + Kit.textHeight("button") + math.floor(8 * m.s)
|
|
cy = cy + Kit.textWrapped("small",
|
|
mdl.detail or Strings("The ROM for this game is imported and verified."),
|
|
px + pad, cy, pw - 2 * pad, 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, pw - 2 * pad),
|
|
px + pad, cy, PAL.faint)
|
|
cy = cy + Kit.textHeight("micro") + math.floor(8 * m.s)
|
|
end
|
|
|
|
btn(imp, px + pad, cy, pw - 2 * pad, 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 canOpenFolder then
|
|
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "manage-folder",
|
|
Strings("Open folder"), { kind = "accent", font = "small",
|
|
action = function() love.system.openURL(imp:fileUrl(saveDir)) end })
|
|
cy = cy + m.btnH + gap
|
|
end
|
|
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "manage-close",
|
|
Strings("Close"), { font = "small",
|
|
action = function() imp._gameManage = nil end })
|
|
end
|
|
|
|
local function buildSettingsModal(imp, m)
|
|
local model = imp._settings
|
|
local pad = math.floor(18 * m.s)
|
|
local w = math.floor(640 * m.s)
|
|
local h = math.floor(math.min(m.H - 2 * m.pad, m.H * 0.9))
|
|
local px, py, pw, ph = modalPanel(m, w, h)
|
|
local cy = py + pad
|
|
|
|
Kit.text("stat", Strings("Settings"), px + pad, cy, PAL.heading)
|
|
local cw = Kit.textWidth("small", Strings("Close")) + math.floor(24 * m.s)
|
|
btn(imp, px + pw - pad - cw, cy, cw, m.btnH, "settings-close",
|
|
Strings("Close"), { font = "small",
|
|
action = function() imp:_closeSettings() end })
|
|
cy = cy + math.max(Kit.textHeight("stat"), m.btnH) + math.floor(6 * m.s)
|
|
-- WRAPPED, not printed flat: on a portrait panel this line ran straight off
|
|
-- the right edge and the sentence ended mid-word at the card border.
|
|
cy = cy + Kit.textWrapped("micro", Strings(
|
|
"Saved to your options file; the game applies these on its next start."),
|
|
px + pad, cy, pw - 2 * pad, PAL.muted, 2)
|
|
+ math.floor(10 * m.s)
|
|
|
|
-- Settings rows are PAGINATED, flattened across sections so a page is a
|
|
-- uniform run of rows. Section titles ride along as their own entry.
|
|
local flat = imp._settingsFlat
|
|
if not flat or flat.model ~= model then
|
|
flat = { model = model }
|
|
for _, section in ipairs(model.sections) do
|
|
flat[#flat + 1] = { header = section.title }
|
|
for _, row in ipairs(section.rows) do
|
|
flat[#flat + 1] = { row = row }
|
|
end
|
|
end
|
|
imp._settingsFlat = flat
|
|
end
|
|
-- The widest label in the whole model decides the row shape (below), so it
|
|
-- is measured once per model rather than per row per frame. Measuring the
|
|
-- WIDEST rather than each row keeps every row the same height, which is
|
|
-- what lets the list paginate off a uniform row.
|
|
if not flat.labelW or flat.labelFont ~= Kit.fonts.scale then
|
|
local widest = 0
|
|
for _, item in ipairs(flat) do
|
|
if item.row then
|
|
widest = math.max(widest, Kit.textWidth("small", item.row.label))
|
|
end
|
|
end
|
|
flat.labelW, flat.labelFont = widest, Kit.fonts.scale
|
|
end
|
|
|
|
local stepW = math.floor(34 * m.s)
|
|
local valW = math.floor(140 * m.s)
|
|
local inner = pw - 2 * pad - math.floor(24 * m.s)
|
|
-- STACKED ROWS. Side by side, a row spends most of its width on the value
|
|
-- ladder and leaves the label whatever remains -- on a portrait phone that
|
|
-- was three characters and an ellipsis ("TEX...", "BAT...", "BAT..."), so
|
|
-- the panel listed a dozen settings none of which could be identified.
|
|
-- When the widest label does not fit beside its control, every row puts the
|
|
-- label on its own line ABOVE the control instead. All-or-nothing, because
|
|
-- a list that switches shape row by row is harder to scan than either form.
|
|
local stacked = flat.labelW
|
|
> (inner - 2 * stepW - valW - math.floor(24 * m.s))
|
|
local rowH
|
|
if stacked then
|
|
rowH = Kit.textHeight("small") + math.floor(4 * m.s) + m.btnH
|
|
+ math.floor(10 * m.s)
|
|
else
|
|
rowH = math.max(Kit.tapMin(), math.floor(36 * m.s))
|
|
end
|
|
local gap = math.floor(4 * m.s)
|
|
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
|
local listH = (py + ph - pad) - cy - pagerH - math.floor(8 * m.s)
|
|
local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 24)
|
|
local n = #flat
|
|
-- POKEPORT_LAUNCHER_SETTINGS_PAGE jumps straight to a page, so a shot can
|
|
-- capture a row that is not on page one.
|
|
local wanted = tonumber(os.getenv("POKEPORT_LAUNCHER_SETTINGS_PAGE") or "")
|
|
if wanted and not imp._settingsPaged then
|
|
imp._settingsPaged = true
|
|
setPage(imp, "settings", wanted)
|
|
end
|
|
local first, last, cur = Kit.pageBounds(page(imp, "settings"), n, perPage)
|
|
setPage(imp, "settings", cur)
|
|
setPage(imp, "settings", Kit.wheelPage(px, cy, pw, listH, cur, n, perPage))
|
|
|
|
for i = first, last do
|
|
local item = flat[i]
|
|
local ry = cy + (i - first) * (rowH + gap)
|
|
if item.header then
|
|
Kit.caption(px + pad, ry + (rowH - Kit.textHeight("caption")) / 2,
|
|
item.header)
|
|
else
|
|
local row = item.row
|
|
local key = "set-" .. i
|
|
Theme.strokeRounded(px + pad, ry, pw - 2 * pad, rowH, PAL.line,
|
|
Theme.A.hairline, 1)
|
|
local ix = px + pad + math.floor(12 * m.s)
|
|
-- Where the label prints, and where the control band starts. Stacked:
|
|
-- label on its own full-width line, controls on the line below it.
|
|
-- Inline: both centred on one line, label left, controls right.
|
|
local labelY, ctlY, labelW
|
|
if stacked then
|
|
labelY = ry + math.floor(6 * m.s)
|
|
ctlY = labelY + Kit.textHeight("small") + math.floor(4 * m.s)
|
|
labelW = inner
|
|
else
|
|
labelY = ry + (rowH - Kit.textHeight("small")) / 2
|
|
ctlY = ry + (rowH - m.btnH) / 2
|
|
labelW = nil -- per-shape below: what the controls leave over
|
|
end
|
|
local rx = ix + inner
|
|
|
|
if row.editText then
|
|
local ew = Kit.textWidth("small", Strings("Edit")) + math.floor(20 * m.s)
|
|
local vw = math.floor(160 * m.s)
|
|
Kit.text("small", Kit.ellipsize("small", row.label,
|
|
labelW or (inner - ew - vw - math.floor(20 * m.s))),
|
|
ix, labelY, PAL.text)
|
|
Kit.textRight("small", Kit.ellipsize("small", tostring(row.value()), vw),
|
|
rx - ew - math.floor(10 * m.s),
|
|
ctlY + (m.btnH - Kit.textHeight("small")) / 2, PAL.detail)
|
|
btn(imp, rx - ew, ctlY, ew, m.btnH,
|
|
key .. "-edit", Strings("Edit"), { kind = "accent", font = "small",
|
|
action = function()
|
|
imp._settingsText = { row = row, text = tostring(row.value() or ""),
|
|
maxLen = row.editText.maxLen }
|
|
imp:_armTextInput()
|
|
end })
|
|
elseif row.action then
|
|
-- A plain action row (Reset rebinds, Touch controls): the whole right
|
|
-- side is one button rather than a value ladder.
|
|
local aw = Kit.textWidth("small", row.actionLabel or Strings("Run"))
|
|
+ math.floor(24 * m.s)
|
|
Kit.text("small", Kit.ellipsize("small", row.label,
|
|
labelW or (inner - aw - math.floor(12 * m.s))), ix, labelY, PAL.text)
|
|
btn(imp, rx - aw, ctlY, aw, m.btnH,
|
|
key .. "-act", row.actionLabel or Strings("Run"), {
|
|
kind = row.danger and "danger" or "ghost", font = "small",
|
|
action = function()
|
|
if row.action() ~= false then model.save() end
|
|
end })
|
|
else
|
|
Kit.text("small", Kit.ellipsize("small", row.label,
|
|
labelW or (inner - 2 * stepW - valW - math.floor(24 * m.s))),
|
|
ix, labelY, PAL.text)
|
|
-- Stacked rows give the value the whole span between the steppers,
|
|
-- which is where the extra width goes now that the label is not
|
|
-- competing for it.
|
|
local vw = stacked and (inner - 2 * stepW - math.floor(16 * m.s))
|
|
or valW
|
|
btn(imp, rx - stepW, ctlY, stepW, m.btnH,
|
|
key .. "-next", ">", { font = "small",
|
|
action = function() if row.step and row.step(1) then model.save() end end })
|
|
Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), vw),
|
|
rx - stepW - vw, ctlY + (m.btnH - Kit.textHeight("small")) / 2, vw,
|
|
PAL.heading)
|
|
btn(imp, rx - stepW - vw - stepW, ctlY, stepW,
|
|
m.btnH, key .. "-prev", "<", { font = "small",
|
|
action = function() if row.step and row.step(-1) then model.save() end end })
|
|
end
|
|
end
|
|
end
|
|
cy = cy + listH + math.floor(8 * m.s)
|
|
setPage(imp, "settings",
|
|
Kit.pager(px + pad, cy, pw - 2 * pad, cur, n, perPage, "settings"))
|
|
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 function modalUp(imp)
|
|
return (imp._settingsText or imp._settings or imp._rename
|
|
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
|
|
or imp._findDetails or imp._modVersions or imp._sortPopup
|
|
or imp._filterPopup or imp._indexManage or imp._modActions
|
|
or imp._findEntry or imp._gameManage) ~= nil
|
|
end
|
|
|
|
local function buildModals(imp, m)
|
|
if imp._settingsText then
|
|
local st = imp._settingsText
|
|
buildPrompt(imp, m, {
|
|
key = "settext", 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._settings then buildSettingsModal(imp, m) return true end
|
|
if imp._rename then
|
|
buildPrompt(imp, m, {
|
|
key = "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", 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._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 = Strings("(No description.)") end
|
|
buildTextModal(imp, m, "find-details", d.title, body,
|
|
function() imp._findDetails = nil end)
|
|
return true
|
|
end
|
|
if imp._modVersions then buildVersionsModal(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._sortPopup then buildSortModal(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._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
|
|
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
|
|
-- The boot prewarm runs without an overlay (the user did not ask for it and
|
|
-- must be able to use the launcher meanwhile), but if they reach the Find
|
|
-- Mods tab before it lands, THEN they are waiting on it and it earns one.
|
|
if imp.tab == "find" and imp._findFetch and not imp.findLoaded then
|
|
return { title = Strings("Loading mod index") }
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function drawPadCursor(imp)
|
|
if not imp._padCursorActive then return end
|
|
-- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch
|
|
-- framebuffer when the stick advances by fractional pixels each frame.
|
|
local x, y = imp._padCursor.x, imp._padCursor.y
|
|
if imp.isNX 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 pad).
|
|
local function headerHeight(m)
|
|
return m.railH + m.logoH + math.floor(12 * m.s) + math.floor(6 * m.s)
|
|
+ m.chip + math.floor(8 * m.s) + math.floor(10 * m.s)
|
|
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
|
|
|
|
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 - m.gap
|
|
local scrollMax = math.max(0, minPanelHeight(m) - naturalAvail)
|
|
local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax))
|
|
if scrollMax > 0 and (imp._wheelY or 0) ~= 0 then
|
|
scroll = math.max(0, math.min(
|
|
scroll - imp._wheelY * math.floor(48 * m.s), scrollMax))
|
|
imp._wheelY = 0 -- the page consumed the wheel; lists page by tap here
|
|
end
|
|
imp._pageScroll, imp._pageScrollMax = scroll, scrollMax
|
|
|
|
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
|
|
imp._clickPt = nil
|
|
imp._wheelY = 0
|
|
|
|
Theme.field()
|
|
|
|
-- 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.
|
|
Kit.blockClicks = modalUp(imp)
|
|
|
|
local ms = m
|
|
if scroll > 0 then
|
|
ms = setmetatable({ top = m.top - scroll }, { __index = m })
|
|
end
|
|
local contentY = buildHeader(imp, ms)
|
|
local footY, availH
|
|
if scrollMax > 0 then
|
|
availH = minPanelHeight(m)
|
|
footY = contentY + availH + m.gap
|
|
else
|
|
footY = m.top + m.h - footH
|
|
availH = footY - contentY - m.gap
|
|
end
|
|
|
|
local x, w = m.contentX, m.contentW
|
|
if imp.tab == "mods" then
|
|
buildModsPanel(imp, x, contentY, w, availH, m)
|
|
elseif imp.tab == "find" then
|
|
buildFindPanel(imp, x, contentY, w, availH, m)
|
|
else
|
|
buildGamePanel(imp, x, contentY, w, availH, m, imp.tab)
|
|
end
|
|
|
|
buildFooter(imp, m, footY)
|
|
Kit.blockClicks = false
|
|
buildModals(imp, m)
|
|
|
|
-- The loader sits above everything, including modals: it is the one thing
|
|
-- that must never be clicked around.
|
|
local spec = loaderSpec(imp)
|
|
if spec then
|
|
if Loader.overlay(m, spec) and spec.onCancel then
|
|
queueAction(imp, "loader-cancel", spec.onCancel)
|
|
end
|
|
end
|
|
|
|
Kit.endFrame()
|
|
drawPadCursor(imp)
|
|
end
|
|
|
|
return LauncherView
|