-- The launcher's FlexLove view. RomImporter owns every piece of state and -- all import/platform logic; this module rebuilds the immediate-mode element -- tree from that state once per frame, so the UI can never drift from the -- importer and every window size lays out fresh (no cached geometry, no hand -- hit-testing). Flat design: solid fills and hairline borders only, no -- gradients or glows. -- -- Interaction contract with RomImporter: -- * every click handler only QUEUES work (imp._uiActions); update() drains -- the queue after FlexLove.update, so a handler that tears the view down -- (Play, Edit save) never destroys the tree that is dispatching it. -- * clicks are deduped per control key (a touch tap can surface as both a -- touch release and a synthesized mouse click; one action must not fire -- twice -- the shape of #553's double import). -- * hover state lives in imp._hot, written by events this frame and read -- by styles next frame (immediate mode recreates elements every frame). -- * the gamepad virtual cursor clicks through clickAt(), which dispatches -- a synthetic event to the element under the pad pointer. local FlexLove = require("libs.flexlove.FlexLove") local Color = FlexLove.Color local SafeArea = require("src.core.SafeArea") local GameVersion = require("src.core.GameVersion") local Strings = require("src.core.Strings") local LauncherView = {} -- ------- palette (flat; alpha per use site) local function rgba(r, g, b, a) return Color.new(r / 255, g / 255, b / 255, a or 1) end local PAL = { bg = { 10, 15, 34 }, card = { 16, 23, 48 }, rowBg = { 9, 14, 34 }, border = { 120, 150, 220 }, red = { 255, 60, 72 }, blue = { 70, 150, 255 }, gold = { 255, 203, 5 }, green = { 62, 224, 138 }, greenDark = { 22, 163, 90 }, greenInk = { 6, 32, 18 }, white = { 255, 255, 255 }, detail = { 198, 208, 230 }, warn = { 159, 176, 208 }, gray = { 143, 163, 200 }, disabled = { 120, 132, 158 }, link = { 127, 208, 255 }, danger = { 255, 83, 97 }, chipModTop = { 61, 74, 109 }, } local function C(name, a) local c = PAL[name] return rgba(c[1], c[2], c[3], a) end -- Every element in this view refuses to flex-shrink: overflow is always a -- scroll container's job here, and the engine otherwise compresses -- auto-height children inside height-constrained columns until their text -- overlaps (the portrait single-column layout was the visible case). -- horizontal padding of a props table, for content-width bookkeeping local function propsPadH(p) local pad = p.padding if type(pad) == "number" then return pad * 2 end if type(pad) == "table" then return (pad.left or pad.horizontal or 0) + (pad.right or pad.horizontal or 0) end return 0 end local function mk(props) if props.flexShrink == nil then props.flexShrink = 0 end -- Resolve "100%" here, against the parent's CONTENT width: the engine -- resolves a percentage against the parent's border box and ignores its -- padding, so every percent child of a padded container overflowed to the -- right by exactly that padding (the clipped LOADED/Delete chips). -- Parents are always created before their children in this view, so the -- tracked inner width is available by the time a child asks. if props.width == "100%" and props.parent and props.parent._innerW then props.width = props.parent._innerW end local el = FlexLove.new(props) if el then if type(props.width) == "number" then el._innerW = props.width - propsPadH(props) elseif props.parent and props.parent._innerW then el._innerW = props.parent._innerW - propsPadH(props) end end return el end local COMMUNITY_URL = "https://bois.icu" local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end -- ------- lifecycle -- NX-only: FlexLove's init maps `performanceMonitoring = false` to true -- (`false or true`), which leaves layout/render timers + memory sampling on -- every immediate-mode frame and makes the pad cursor feel lagged. Force -- them off after init. Desktop keeps the library default. Exported so the -- engine tier can assert the Switch guards without drawing the full tree. function LauncherView.applyNxPerfGuards(imp) if not (imp and imp.isNX and FlexLove.isReady() and FlexLove._Performance) then return false end FlexLove._Performance.enabled = false local mp = FlexLove._Performance._memoryProfiler if mp then mp.enabled = false end return true end local function ensureFlex(imp) if not FlexLove.isReady() then FlexLove.init({ immediateMode = true, performanceMonitoring = false, keyboardNavigation = false, }) end -- Re-apply on every ensure: FlexLove may already be ready from a prior -- init (hot reload / editor round-trip). No-op when not NX. LauncherView.applyNxPerfGuards(imp) if not imp._flex then imp._flex = true imp._hot = imp._hot or {} imp._actAt = imp._actAt or {} imp._uiActions = imp._uiActions 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 -- Tear the tree down before handing the screen to the game / editor: the -- engine draws with raw love.graphics and must not share canvases or input -- polling with a live UI toolkit. function LauncherView.detach(imp) -- Restore the NX mouse shim even if _flex was never set (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 pcall(FlexLove.destroy) end function LauncherView.update(imp, dt) if not imp._flex then return end FlexLove.update(dt) -- Drain the action queue OUTSIDE FlexLove's dispatch, so an action is free -- to destroy the view (Play/Edit) or block in a native picker. local queue = imp._uiActions if queue and #queue > 0 then imp._uiActions = {} for _, fn in ipairs(queue) do local ok, err = pcall(fn) if not ok then print("launcher action error: " .. tostring(err)) end end end end function LauncherView.wheelmoved(imp, dx, dy) if not imp._flex then return end pcall(FlexLove.wheelmoved, dx, dy) end -- Synthetic click for the gamepad virtual cursor: find the element under the -- pad pointer and run its handler with a click-shaped event. function LauncherView.clickAt(imp, x, y) if not imp._flex then return end local ok, el = pcall(FlexLove.getElementAtPosition, x, y) if not ok then return end while el and not el.onEvent do el = el.parent end if el and el.onEvent then pcall(el.onEvent, el, { type = "click", button = 1, x = x, y = y, modifiers = {}, clickCount = 1 }) end end -- ------- shared widget helpers -- One dedup window covers a touch release plus the mouse click SDL -- synthesizes for the same tap. local ACT_DEDUP = 0.35 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 imp._actAt[key] = now -- Any press that is not a Delete's own second click disarms the pending -- delete confirm (#433's rule, preserved from the hit-rect launcher). if not keepArm then imp._confirmDelete = nil end imp._uiActions[#imp._uiActions + 1] = fn end local function handler(imp, key, action, keepArm) return function(_, ev) if ev.type == "hover" then imp._hot[key] = true elseif ev.type == "unhover" then imp._hot[key] = nil elseif action and (ev.type == "click" or ev.type == "touchrelease") then queueAction(imp, key, action, keepArm) end end end -- Shared measuring fonts, cached by integer size: control widths and heights -- come from the same faces the elements render with. Estimating them from -- character counts broke at every scale except the one it was tuned on -- (clipped Delete chips, button rows spilling out of their cards). local measureFonts = {} local function mfont(size) size = math.max(8, math.floor(size + 0.5)) local f = measureFonts[size] if not f then f = love.graphics.newFont(size) measureFonts[size] = f end return f end local function textWidth(size, text) return mfont(size):getWidth(text) end local function textHeight(size) return mfont(size):getHeight() end -- wrapped text height at a width, from the same font the element renders local function wrapHeight(size, text, width) if not text or text == "" or (width or 0) <= 0 then return 0 end local f = mfont(size) local _, lines = f:getWrap(text, width) return math.max(1, #lines) * f:getHeight() end -- Every text size in this file is already scaled by m.s, so FlexLove's own -- viewport text scaling must stay off: with it on, the layout boxes shrink -- away from the rendered glyphs on non-reference window sizes and lines -- overlap. local function label(parent, text, size, color, props) -- integer sizes only: the measuring fonts are integer-sized, and a -- fractional rendered size drifting a few percent wider than its measure -- is exactly how button rows crept out of their cards on some displays size = math.floor(size + 0.5) local p = { parent = parent, text = text, textSize = size, textColor = color, textWrap = "word", autoScaleText = false, -- id keyed by the text: the engine's Persistable behavior snapshots -- every scalar prop (text included) per id and stomps it back onto the -- recreated element, freezing any label whose text changes between -- frames (typed search text stuck on its first letter). A new text is -- a new id, so it always renders fresh. id = "lbl:" .. tostring(text), } for k, v in pairs(props or {}) do p[k] = v end return mk(p) end -- kinds: primary (solid green), accent (green outline), danger (red outline), -- dangerArmed (solid red), neutral (translucent white), disabled (inert) local function button(imp, parent, key, text, opts) opts = opts or {} local hot = imp._hot[key] local kind = opts.kind or "neutral" local bgc, fgc, brc if kind == "primary" then bgc = hot and C("green") or C("greenDark") fgc, brc = C("greenInk"), C("green", 0.9) elseif kind == "accent" then bgc = C("green", hot and 0.30 or 0.12) fgc, brc = hot and C("white") or C("green"), C("green", 0.7) elseif kind == "danger" then bgc = C("danger", hot and 0.30 or 0.12) fgc, brc = hot and C("white") or C("danger"), C("danger", 0.7) elseif kind == "dangerArmed" then bgc, fgc, brc = C("danger"), C("white"), C("danger") elseif kind == "disabled" then bgc = C("disabled", 0.22) fgc, brc = C("disabled"), C("disabled", 0.35) else bgc = C("white", hot and 0.22 or 0.10) fgc, brc = C("white"), C("white", hot and 0.4 or 0.2) end -- Explicit measured size always: the layout engine measures an auto-sized -- button as zero-height while its parent card is auto-sizing, which let -- bottom action rows spill past their card's edge. local size = math.floor((opts.size or 14) + 0.5) local pad = opts.pad or { horizontal = 12, vertical = 6 } local padX = pad.horizontal or 12 local padY = pad.vertical or 6 local w = opts.w if not w and not opts.flex then w = math.ceil(textWidth(size, text)) + 2 * padX + 2 end local h = opts.h or (math.ceil(textHeight(size)) + 2 * padY + 2) local p = { parent = parent, -- same Persistable-stomp guard as label(): a button whose caption -- changes (Delete -> Sure?, Update ladders) must not keep frame one's id = "btn:" .. key .. ":" .. tostring(text), width = w, height = h, flex = opts.flex, backgroundColor = bgc, border = 1, borderColor = brc, cornerRadius = opts.r or 8, text = text, textColor = fgc, textSize = size, textAlign = "center-center", autoScaleText = false, } if kind ~= "disabled" and opts.action then p.onEvent = handler(imp, key, opts.action, opts.keepArm) elseif kind ~= "disabled" then p.onEvent = handler(imp, key, nil) end return mk(p) end local function card(parent, props) local p = { parent = parent, width = "100%", backgroundColor = C("card", 0.75), border = 1, borderColor = C("border", 0.28), cornerRadius = 14, positioning = "flex", flexDirection = "vertical", } for k, v in pairs(props or {}) do p[k] = v end return mk(p) end local function pill(parent, text, colName, size) size = math.floor((size or 12) + 0.5) local h = math.ceil(textHeight(size)) + 8 return mk({ parent = parent, text = text, textColor = C(colName), id = "pill:" .. tostring(text), textSize = size, textAlign = "center-center", autoScaleText = false, width = math.ceil(textWidth(size, text)) + 20, height = h, backgroundColor = C(colName, 0.12), border = 1, borderColor = C(colName, 0.55), cornerRadius = h / 2, }) end local function progressBar(parent, frac, colName, h) h = h or 10 frac = clamp(frac or 0, 0, 1) local track = mk({ parent = parent, width = "100%", height = h, backgroundColor = C("bg", 0.9), cornerRadius = h / 2, }) mk({ parent = track, width = (frac * 100) .. "%", -- id keyed by the fraction, or Persistable pins the bar at frame one id = "prog:" .. math.floor(frac * 1000), height = "100%", backgroundColor = C(colName), cornerRadius = h / 2, }) return track end -- Flat toggle switch (read-only visual; the pressable area is the caller's). -- The knob is flex-aligned rather than absolutely positioned: absolute -- children resolve in screen space here, not against the parent. local function toggleSwitch(parent, on, w, h, idKey) w, h = w or 46, h or 24 local track = mk({ parent = parent, width = w, height = h, -- state-keyed id: justifyContent is a persisted scalar, and a stale -- snapshot would hold the knob on its frame-one side after a toggle id = idKey and (idKey .. (on and ":on" or ":off")) or nil, backgroundColor = on and C("greenDark") or C("disabled", 0.4), border = 1, borderColor = on and C("green", 0.8) or C("disabled", 0.6), cornerRadius = h / 2, positioning = "flex", flexDirection = "horizontal", alignItems = "center", justifyContent = on and "flex-end" or "flex-start", padding = 3, }) mk({ parent = track, width = h - 6, height = h - 6, backgroundColor = C("white"), cornerRadius = (h - 6) / 2, }) return track end -- a darkened copy of a palette color, for the embossed chips' bottom ledge local function darken(name, f, a) local c = PAL[name] return rgba(c[1] * f, c[2] * f, c[3] * f, a or 1) end -- Hand-rolled text field: the importer owns the string (textinput / -- keypressed routing), this draws it. The field clips its content, keeps -- the TAIL of the text visible while typing (the interesting end), and -- shows a font-height caret on the importer's pulse clock. local function dropFirstChar(t) local i = 2 while i <= #t do local b = t:byte(i) if b < 0x80 or b >= 0xC0 then break end i = i + 1 end return t:sub(i) end local function textField(imp, parent, key, rawText, placeholder, focused, action) local size = 14 local h = math.max(36, math.ceil(textHeight(size)) + 18) local field = mk({ parent = parent, width = "100%", height = h, backgroundColor = C("bg", focused and 1 or 0.85), border = focused and 2 or 1, borderColor = focused and C("green", 0.85) or C("border", imp._hot[key] and 0.7 or 0.4), cornerRadius = 8, overflow = "hidden", positioning = "flex", flexDirection = "horizontal", alignItems = "center", gap = 2, padding = { horizontal = 12 }, onEvent = action and handler(imp, key, action) or nil, }) local avail = (field._innerW or 200) - 6 local shown = rawText or "" local f = mfont(size) while #shown > 1 and f:getWidth(shown) > avail do shown = dropFirstChar(shown) end if shown ~= "" then label(field, shown, size, C("white"), { textWrap = false }) elseif placeholder and not focused then label(field, placeholder, size, C("disabled"), { textWrap = false }) end if focused and (imp.pulse * 2 % 1) < 0.5 then mk({ parent = field, width = 2, height = math.ceil(textHeight(size)) + 2, backgroundColor = C("green"), cornerRadius = 1 }) end return field end -- ------- status derivations shared with the old panels local function modStatusChip(status) if status == "ok" then return Strings("Ready"), "green" end if status == "conflict" then return Strings("Conflict"), "danger" end return Strings("Incompatible"), "gold" 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 DELETE_LABEL = function(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 -- ------- header: strip, logo row (with the settings gear), tab bar local function buildHeader(imp, root, m) -- tricolor strip local strip = mk({ parent = root, width = "100%", height = math.max(4, 5 * m.s), positioning = "flex", flexDirection = "horizontal", }) for _, name in ipairs({ "red", "blue", "gold" }) do mk({ parent = strip, flex = 1, height = "100%", backgroundColor = C(name) }) end -- logo row: spacer / centered logo / settings gear, so the logo stays -- centered while the gear holds the app's top-right corner local gearSize = math.max(34, 40 * m.s) local row = mk({ parent = root, width = "100%", height = m.logoH + 12 * m.s, positioning = "flex", flexDirection = "horizontal", alignItems = "center", gap = 8 * m.s, padding = { horizontal = m.pad }, }) mk({ parent = row, width = gearSize, height = 1 }) local mid = mk({ parent = row, flex = 1, positioning = "flex", justifyContent = "center", alignItems = "center" }) mk({ parent = mid, image = imp.logo, objectFit = "contain", width = math.min(320 * m.s, m.w * 0.6), height = m.logoH, }) imp._gearIcon = imp._gearIcon or love.graphics.newImage("assets/launcher/gear.png") mk({ parent = row, width = gearSize, height = gearSize, backgroundColor = C("white", imp._hot.gear and 0.20 or 0.07), border = 1, borderColor = C("border", imp._hot.gear and 0.85 or 0.4), cornerRadius = 10, image = imp._gearIcon, objectFit = "contain", imageTint = imp._hot.gear and C("white") or C("detail"), padding = math.floor(gearSize * 0.18), onEvent = handler(imp, "gear", function() imp:_openSettings() end), }) -- tab bar local bar = mk({ parent = root, width = "100%", positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap", alignItems = "center", gap = 8 * m.s, padding = { horizontal = m.pad, vertical = 8 * m.s }, }) imp._modsIcon = imp._modsIcon or love.graphics.newImage("assets/launcher/mods.png") imp._findIcon = imp._findIcon or love.graphics.newImage("assets/launcher/find.png") local tabs = { { id = "red", letter = "R", col = "red", ink = "white", labelText = Strings("RED") }, { id = "blue", letter = "B", col = "blue", ink = "white", labelText = Strings("BLUE") }, { id = "yellow", letter = "Y", col = "gold", ink = "bg", labelText = Strings("YELLOW") }, { id = "mods", icon = imp._modsIcon, col = "chipModTop", ink = "white", labelText = Strings("MODS") }, { id = "find", icon = imp._findIcon, col = "chipModTop", ink = "white", labelText = Strings("FIND MODS") }, } for _, t in ipairs(tabs) do if t.id == "mods" then mk({ parent = bar, width = 1, height = m.chip * 0.8, backgroundColor = C("border", 0.3) }) end local active = imp.tab == t.id local key = "tab-" .. t.id local labelSize = 13 * m.s + 4 local hot = imp._hot[key] -- embossed chip: a darker base ledge under the face gives the tab a -- raised look; hover lifts the face brightness and rims it white local ledge = math.max(2, math.floor(3 * m.s)) local baseEl = mk({ parent = bar, width = m.chip, height = m.chip + ledge, backgroundColor = darken(t.col, 0.35, active and 1 or 0.8), cornerRadius = 10, onEvent = handler(imp, key, function() imp:_switchTab(t.id) end), }) local face = { parent = baseEl, width = "100%", height = m.chip, backgroundColor = C(t.col, active and 1 or (hot and 0.75 or 0.42)), border = (active or hot) and 1 or false, borderColor = C("white", active and 0.6 or 0.35), cornerRadius = 10, } if t.icon then face.image = t.icon face.objectFit = "contain" face.padding = math.floor(m.chip * 0.2) face.imageTint = C("white", active and 1 or 0.85) else face.text = t.letter -- the gold chip's dark ink only reads on the full-strength active -- fill; dimmed inactive chips all take light ink face.textColor = (active and t.ink == "bg") and C("bg") or C("white") face.textSize = math.floor(m.chip * 0.45) face.textAlign = "center-center" face.autoScaleText = false end mk(face) if active then -- explicit width: a percentage inside this auto-sized wrap would not -- resolve (LayoutEngine LAY_004), so the underline takes the label's -- measured pixel width local lw = math.ceil(textWidth(labelSize, t.labelText)) + 2 local wrap = mk({ parent = bar, width = lw, positioning = "flex", flexDirection = "vertical", gap = 3 * m.s }) label(wrap, t.labelText, labelSize, C("white"), { textWrap = false }) mk({ parent = wrap, width = lw, height = 3, backgroundColor = C(t.col) }) end end -- "N of 3 ready" right-aligned filler local ready = 0 for _, v in ipairs(GameVersion.ORDER) do if imp.ready[v] then ready = ready + 1 end end mk({ parent = bar, flex = 1 }) label(bar, Strings("%d of 3 ready", ready), 12 * m.s + 2, C("gray"), { textWrap = false }) mk({ parent = root, width = "100%", height = 1, backgroundColor = C("border", 0.22) }) end -- ------- game panel local function buildRomCard(imp, parent, m, version, info, ready, locked) local dropHint = imp.isNX and Strings("Copy the .gb/.gbc via MTP into imports/.") or (imp.android and Strings("Copy the .gb/.gbc via USB.") or Strings("Or drop the .gb/.gbc file here.")) local importLabel = imp.isNX and Strings("Scan again") or Strings("Import ROM") local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress if locked then romState, romDetail = Strings("Not supported yet"), Strings("Support for this game is on the way.") romBtnLabel, romBtnEnabled = Strings("Import unavailable"), false else 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 if importing and (imp.workState == "working" or imp.workState == "complete") then romState = imp.status or Strings("Importing") romDetail = imp.detail or "" romProgress = imp.progress or 0 elseif ready then romState = imp.romName[version] or Strings("ROM imported") romDetail = Strings("Verified.") romBtnLabel, romBtnEnabled = Strings("Re-import ROM"), true elseif erroring then romState = Strings("Import failed") romDetail = imp.detail or Strings("That ROM could not be imported.") romBtnLabel, romBtnEnabled = importLabel, true elseif notice then romState = Strings("No ROM imported") romDetail = ((notice.status or "") .. " " .. (notice.detail or "")) :gsub("^%s+", ""):gsub("%s+$", "") romBtnLabel, romBtnEnabled = importLabel, true elseif imp.returning[version] then romState = Strings("Update required") romDetail = Strings("This build needs a few more things from your ") .. info.label .. Strings(" ROM. Re-import to continue.") romBtnLabel, romBtnEnabled = Strings("Re-import ROM"), true else romState = Strings("No ROM imported") romDetail = Strings("The ROM is verified before any files are created. ") .. dropHint romBtnLabel, romBtnEnabled = importLabel, true end end local accent = version == "yellow" and "gold" or version local c = card(parent, { padding = m.cardPad, gap = 8 * m.s }) label(c, "ROM", 12 * m.s + 1, C("gray")) label(c, romState, 15 * m.s + 2, C("white")) label(c, romDetail, 12 * m.s + 2, C("detail")) if romProgress ~= nil then progressBar(c, romProgress, accent, math.max(8, 10 * m.s)) else button(imp, c, "rom-" .. version, romBtnLabel, { w = "100%", h = m.btnH, size = 14 * m.s, kind = romBtnEnabled and "neutral" or "disabled", action = romBtnEnabled and function() if imp.ready[version] then imp:reimport(version) else imp:choose(version) end end or nil, }) end end local function buildSaveFilesCard(imp, parent, m, version, ready, locked) local sfImportEnabled, sfExportEnabled = false, false if not locked then imp:_ensureSlots(version) sfImportEnabled = ready and true or false local activeId = imp.activeSlot[version] for _, sl in ipairs(imp.slots[version] or {}) do if sl.id == activeId and sl.exists then sfExportEnabled = true break end end end local sfNotice = (not locked) and imp.saveNotice[version] or nil local hintText, hintCol if sfNotice then hintText, hintCol = sfNotice.text, (sfNotice.ok and "green" or "danger") elseif locked then hintText, hintCol = Strings("Not available yet."), "warn" else hintText = imp:_savesDefaultHint(version) hintCol = "warn" end local savImportLabel = imp.isNX and Strings("Scan again") or Strings("Import save") local c = card(parent, { padding = m.cardPad, gap = 8 * m.s }) label(c, "SAVE FILES", 12 * m.s + 1, C("gray")) local row = mk({ parent = c, width = "100%", positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s }) -- explicit halves rather than flex growth, which mis-distributed inside -- an auto-height card local halfW = math.floor((m.colW - 32 - 10 * m.s) / 2) button(imp, row, "sav-import-" .. version, savImportLabel, { w = halfW, h = m.btnH, size = 13 * m.s + 1, kind = sfImportEnabled and "neutral" or "disabled", action = sfImportEnabled and function() imp:chooseSaveImport(version) end or nil, }) button(imp, row, "sav-export-" .. version, Strings("Export save"), { w = halfW, h = m.btnH, size = 13 * m.s + 1, kind = sfExportEnabled and "neutral" or "disabled", action = sfExportEnabled and function() imp:exportSave(version) end or nil, }) label(c, hintText, 12 * m.s + 1, C(hintCol)) if sfNotice and sfNotice.dir then local dir = sfNotice.dir label(c, Strings("Open folder"), 12 * m.s + 1, C("link", imp._hot["sav-folder-" .. version] and 1 or 0.85), { onEvent = handler(imp, "sav-folder-" .. version, function() love.system.openURL(imp:fileUrl(dir)) end), }) end end local function buildSlotCard(imp, parent, m, version) imp:_ensureSlots(version) local slots = imp.slots[version] or {} local active = imp.activeSlot[version] local n = #slots local c = card(parent, { padding = m.cardPad, gap = 10 * m.s }) local head = mk({ parent = c, width = "100%", positioning = "flex", flexDirection = "horizontal", justifyContent = "space-between", alignItems = "center" }) label(head, "SAVE SLOT", 12 * m.s + 1, C("gray"), { textWrap = false }) label(head, n == 1 and Strings("1 slot") or Strings("%d slots", n), 12 * m.s + 1, C("gray"), { textWrap = false }) if n == 0 then local box = mk({ parent = c, width = "100%", height = 90 * m.s, border = 1, borderColor = C("border", 0.45), cornerRadius = 12, positioning = "flex", justifyContent = "center", alignItems = "center", padding = { horizontal = 12 }, }) label(box, Strings("No saves yet - start a new game or import one."), 12 * m.s + 2, C("warn"), { textAlign = "center" }) end -- Taller stacked rows: name + LOADED line, meta line, then the action -- buttons on their own full-width line, so no control can ever clip -- against the card's right edge at any scale. Every line height is -- measured, and the row height is their explicit sum: the engine's -- auto-height came up short on some displays and let the button row fall -- out of the card. local chipSize = math.floor(11 * m.s + 1.5) local nameSize = math.floor(14 * m.s + 2.5) local metaSize = math.floor(11 * m.s + 2.5) local pillSize = math.floor(10 * m.s + 1.5) local btnH = math.ceil(textHeight(chipSize)) + 14 local headH = math.max(math.ceil(textHeight(nameSize)), math.ceil(textHeight(pillSize)) + 8) local metaH = math.ceil(textHeight(metaSize)) local rowH = 10 + headH + 5 + metaH + 8 + btnH + 10 for _, slot in ipairs(slots) do local selected = slot.id == active local rowKey = "slot-" .. version .. "-" .. slot.id local row = mk({ parent = c, width = "100%", height = rowH, backgroundColor = C("rowBg", imp._hot[rowKey] and 0.85 or 0.6), border = 1, borderColor = selected and C("green", 0.9) or C("border", 0.25), cornerRadius = 12, positioning = "flex", flexDirection = "vertical", gap = 5, padding = { horizontal = 12, vertical = 10 }, onEvent = handler(imp, rowKey, function() imp:_selectSlot(version, slot.id) end), }) local rowInner = row._innerW local headRow = mk({ parent = row, width = rowInner, height = headH, positioning = "flex", flexDirection = "horizontal", justifyContent = "space-between", alignItems = "center" }) local name = slot.label or slot.name or Strings("NEW GAME") local pillW = selected and (math.ceil(textWidth(pillSize, Strings("LOADED"))) + 20) or 0 label(headRow, name, nameSize, C("white"), { width = rowInner - pillW - 8, textWrap = false, textOverflow = "ellipsis" }) if selected then pill(headRow, Strings("LOADED"), "green", pillSize) end 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 label(row, metaTxt, metaSize, C("warn"), { width = rowInner, textWrap = false, textOverflow = "ellipsis" }) local btnRow = mk({ parent = row, width = rowInner, height = btnH, positioning = "flex", flexDirection = "horizontal", justifyContent = "flex-end", gap = 6 }) if not imp.android then button(imp, btnRow, rowKey .. "-rename", Strings("Rename"), { size = chipSize, kind = "neutral", action = function() imp:_beginRename(version, slot.id) end, }) end if imp.onEditSave and slot.exists then button(imp, btnRow, rowKey .. "-edit", Strings("Edit"), { size = chipSize, kind = "accent", action = function() imp.onEditSave(version, slot.id) end, }) end local armed = deleteArmed(imp, "slot", slot.id, version) -- width pinned to the unarmed label so arming to "Sure?" never reflows -- the row under the pointer (#433) button(imp, btnRow, rowKey .. "-del", DELETE_LABEL(armed), { w = math.ceil(textWidth(chipSize, DELETE_LABEL(false))) + 26, size = chipSize, kind = armed and "dangerArmed" or "danger", keepArm = true, action = function() imp:pressDelete("slot", slot.id, version, function() imp:_deleteSlot(version, slot.id) end) end, }) end button(imp, c, "slot-new-" .. version, Strings("+ New save slot"), { w = "100%", h = m.btnH, size = 13 * m.s + 1, kind = "neutral", action = function() imp:_newSlot(version) end, }) end local function buildGamePanel(imp, parent, 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 -- header: name + status pill local head = mk({ parent = parent, width = "100%", positioning = "flex", flexDirection = "horizontal", alignItems = "center", gap = 12 * m.s }) label(head, gameName, 22 * m.s + 4, C("white"), { textWrap = false }) if ready then pill(head, Strings("GOOD TO GO"), "green", 11 * m.s + 1) elseif locked then pill(head, Strings("COMING SOON"), "disabled", 11 * m.s + 1) else pill(head, Strings("ROM REQUIRED"), "gold", 11 * m.s + 1) end -- Two columns get explicit pixel widths (percentage children inside a -- flex-grown column do not resolve, LayoutEngine LAY_004). Single-column -- mode adds the cards straight to the page instead of nesting columns: -- the engine under-measures a vertical column-of-columns' auto height, -- which pushed the footer up over the save-slot card on phone shapes. local left, right if m.twoCol then local grid = mk({ parent = parent, width = "100%", positioning = "flex", flexDirection = "horizontal", gap = m.colGap, alignItems = "flex-start" }) left = mk({ parent = grid, width = m.colW, positioning = "flex", flexDirection = "vertical", gap = 12 * m.s }) right = mk({ parent = grid, width = m.colW, positioning = "flex", flexDirection = "vertical" }) else left, right = parent, parent end buildRomCard(imp, left, m, version, info, ready, locked) buildSaveFilesCard(imp, left, m, version, ready, locked) if imp.onEditTouchControls then button(imp, left, "touch-controls", Strings("Touch Controls"), { w = "100%", h = m.btnH, size = 13 * m.s + 1, kind = "neutral", action = function() imp.onEditTouchControls() end, }) end button(imp, left, "play-" .. version, ready and (Strings("Play ") .. gameName) or (locked and Strings("Coming soon") or Strings("Import a ROM to play")), { w = "100%", h = math.max(48, 52 * m.s), size = 18 * m.s + 2, kind = ready and "primary" or "disabled", action = ready and function() imp:play(version) end or nil, }) if not locked then buildSlotCard(imp, right, m, version) end end -- ------- mods panel local function buildModsPanel(imp, parent, m) imp:_ensureMods() local mods = imp.mods or {} local enabledCount = 0 for _, mod in ipairs(mods) do if mod.enabled then enabledCount = enabledCount + 1 end end local head = mk({ parent = parent, width = "100%", positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap", alignItems = "center", gap = 10 * m.s }) label(head, Strings("Mods"), 22 * m.s + 4, C("white"), { textWrap = false }) label(head, Strings("%d of %d enabled", enabledCount, #mods), 12 * m.s + 2, C("warn"), { textWrap = false }) mk({ parent = head, flex = 1 }) if #mods > 0 then button(imp, head, "mods-enable-all", Strings("Enable all"), { size = 11 * m.s + 1, kind = "neutral", action = function() imp:_setAllMods(true) end, }) button(imp, head, "mods-disable-all", Strings("Disable all"), { size = 11 * m.s + 1, kind = "neutral", action = function() imp:_setAllMods(false) end, }) end button(imp, head, "mods-import", imp:_modsImportButtonLabel(), { h = m.btnH, size = 13 * m.s + 1, kind = "neutral", action = function() imp:chooseMod() end, }) if imp.modNotice then label(parent, imp.modNotice.text, 12 * m.s + 2, C(imp.modNotice.ok and "green" or "danger")) else label(parent, imp:_modsDefaultHint(), 12 * m.s + 2, C("warn")) end if #mods == 0 then local box = mk({ parent = parent, width = "100%", height = 110 * m.s, backgroundColor = C("card", 0.4), border = 1, borderColor = C("border", 0.3), cornerRadius = 14, positioning = "flex", justifyContent = "center", alignItems = "center", padding = { horizontal = 16 }, }) label(box, imp:_modsEmptyHint(), math.floor(12 * m.s + 2.5), C("detail"), { textAlign = "center" }) return end -- Explicit column widths AND heights: a flex-grown container collapses -- its children's layout in this engine, and card auto-height came up -- short on some displays, dropping the bottom action row out of the card. -- Everything is measured with the same integer-sized fonts the labels -- render with, and the card gets the exact sum. local innerW = m.contentW - 32 local clusterW = math.max(96, math.floor(110 * m.s)) local bodyW = innerW - clusterW - 10 local nameSize = math.floor(15 * m.s + 2.5) local smallSize = math.floor(12 * m.s + 1.5) local badgeSize = math.floor(10 * m.s + 1.5) local chipSize = math.floor(11 * m.s + 1.5) local btnH = math.ceil(textHeight(chipSize)) + 14 local badgeH = math.ceil(textHeight(badgeSize)) + 6 local toggleH = math.floor(24 * m.s + 2) + 8 local pillH = math.ceil(textHeight(chipSize)) + 8 local clusterH = pillH + 6 + toggleH for _, mod in ipairs(mods) do local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) local checkLine, checkCol if info and info.status == "available" then checkLine = Strings("Checked for updates - v%s available", tostring(info.latest)) checkCol = "green" elseif info and info.status == "current" then checkLine, checkCol = Strings("Checked for updates - up to date"), "green" elseif info and info.status == "error" then checkLine, checkCol = Strings("Checked for updates - failed"), "danger" elseif mod.github and mod.github ~= "" then checkLine, checkCol = Strings("Not checked for updates yet"), "warn" end -- measure the body: name (with the badge beside it only when it fits), -- version, check line, wrapped description local badgeW = math.ceil(textWidth(badgeSize, mod.badge)) + 14 local nameH = math.ceil(textHeight(nameSize)) local badgeBesideName = math.ceil(textWidth(nameSize, mod.name)) + 8 + badgeW <= bodyW local bodyH = badgeBesideName and math.max(nameH, badgeH) or (nameH + 4 + badgeH) bodyH = bodyH + 4 + math.ceil(textHeight(smallSize)) if checkLine then bodyH = bodyH + 4 + wrapHeight(smallSize, checkLine, bodyW) end if mod.description ~= "" then bodyH = bodyH + 4 + wrapHeight(smallSize, mod.description, bodyW) end local rowH = math.max(bodyH, clusterH) -- how many lines the right-aligned action row needs local btnRowW = math.ceil(textWidth(chipSize, DELETE_LABEL(false))) + 26 local updLabel, updKind = Strings("Check for updates"), "neutral" if info and info.status == "available" then updLabel, updKind = Strings("Update"), "accent" elseif info and info.status == "current" then updLabel = Strings("Check again") end if mod.github and mod.github ~= "" then btnRowW = btnRowW + math.ceil(textWidth(chipSize, updLabel)) + 26 + 6 + math.ceil(textWidth(chipSize, Strings("Versions"))) + 26 + 6 end local btnLines = math.max(1, math.ceil(btnRowW / innerW)) local cardH = 28 + rowH + 8 + btnLines * btnH + (btnLines - 1) * 6 local c = card(parent, { padding = m.cardPad, gap = 8, height = cardH }) local row = mk({ parent = c, width = "100%", height = rowH, positioning = "flex", flexDirection = "horizontal", gap = 10, alignItems = "flex-start" }) local body = mk({ parent = row, width = bodyW, height = rowH, positioning = "flex", flexDirection = "vertical", gap = 4 }) local function badge(parent2) mk({ parent = parent2, text = mod.badge, autoScaleText = false, textColor = mod.experimental and C("gold") or C("warn"), textSize = badgeSize, textAlign = "center-center", width = badgeW, height = badgeH, border = 1, borderColor = C("border", 0.5), cornerRadius = 5, }) end if badgeBesideName then local nameRow = mk({ parent = body, width = "100%", height = math.max(nameH, badgeH), positioning = "flex", flexDirection = "horizontal", alignItems = "center", gap = 8 }) label(nameRow, mod.name, nameSize, C("white"), { textWrap = false }) badge(nameRow) else label(body, mod.name, nameSize, C("white"), { width = "100%", textWrap = false, textOverflow = "ellipsis" }) badge(body) end label(body, "v" .. tostring(mod.version or "?"), smallSize, C("detail")) if checkLine then label(body, checkLine, smallSize, C(checkCol), { width = "100%" }) end if mod.description ~= "" then label(body, mod.description, smallSize, C("detail"), { width = "100%" }) end local cluster = mk({ parent = row, width = clusterW, height = clusterH, positioning = "flex", flexDirection = "vertical", alignItems = "flex-end", gap = 6 }) local chipText, chipCol = modStatusChip(mod.status) pill(cluster, chipText, chipCol, chipSize) local togKey = "mod-toggle-" .. mod.id local togWrap = mk({ parent = cluster, width = math.floor(46 * m.s + 4) + 8, height = toggleH, padding = 4, onEvent = handler(imp, togKey, function() imp:_toggleMod(mod.id) end), }) toggleSwitch(togWrap, mod.enabled, math.floor(46 * m.s + 4), math.floor(24 * m.s + 2), "tog:" .. mod.id) local btnRow = mk({ parent = c, width = "100%", height = btnLines * btnH + (btnLines - 1) * 6, positioning = "flex", flexDirection = "horizontal", justifyContent = "flex-end", flexWrap = "wrap", gap = 6 }) if mod.github and mod.github ~= "" then button(imp, btnRow, "mod-upd-" .. mod.id, updLabel, { size = chipSize, kind = updKind, action = function() imp:_modGithubAction(mod.id, "update") end, }) button(imp, btnRow, "mod-ver-" .. mod.id, Strings("Versions"), { size = chipSize, kind = "neutral", action = function() imp:_modGithubAction(mod.id, "versions") end, }) end local armed = deleteArmed(imp, "mod", mod.id, nil) button(imp, btnRow, "mod-del-" .. mod.id, DELETE_LABEL(armed), { w = math.ceil(textWidth(chipSize, DELETE_LABEL(false))) + 26, size = chipSize, kind = armed and "dangerArmed" or "danger", keepArm = true, action = function() imp:pressDelete("mod", mod.id, nil, function() imp:_deleteMod(mod.id) end) end, }) end end -- ------- find mods panel local function buildFindPanel(imp, parent, m) imp._findThumbFetched = false imp:_ensureFind() imp:_ensureMods() local ModIndex = require("src.mods.ModIndex") local sources = imp.findSources or {} local rows = imp:_findRows() local total = #((imp.findIndex and imp.findIndex.mods) or {}) local head = mk({ parent = parent, width = "100%", positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap", alignItems = "center", gap = 10 * m.s }) label(head, Strings("Find Mods"), 22 * m.s + 4, C("white"), { textWrap = false }) if #sources > 0 then label(head, (#rows == total) and Strings("%d mods listed", total) or Strings("%d of %d mods", #rows, total), 12 * m.s + 2, C("warn"), { textWrap = false }) end mk({ parent = head, flex = 1 }) if #sources > 0 then button(imp, head, "find-refresh", Strings("Refresh"), { h = m.btnH, size = 13 * m.s + 1, kind = "neutral", action = function() imp._findSearchFocus = false imp:_disarmTextInput() imp:_refreshFind(true) end, }) end button(imp, head, "find-add", (#sources == 0) and Strings("Add an index") or Strings("Add index"), { h = m.btnH, size = 13 * m.s + 1, kind = "neutral", action = function() imp:_promptAddIndex() end, }) if imp.findNotice then label(parent, imp.findNotice.text, 12 * m.s + 2, C(imp.findNotice.ok and "green" or "danger")) else label(parent, Strings( "Mods here are listed, not reviewed - read the source and trust the author."), 12 * m.s + 2, C("warn")) end if #sources == 0 then local box = mk({ parent = parent, width = "100%", height = 150 * m.s, border = 1, borderColor = C("border", 0.45), cornerRadius = 14, positioning = "flex", flexDirection = "vertical", justifyContent = "center", alignItems = "center", gap = 6 * m.s, padding = { horizontal = 20 }, }) label(box, Strings("No mod index added"), 15 * m.s + 2, C("white"), { textAlign = "center", width = "100%" }) label(box, Strings( "Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."), 12 * m.s + 1, C("warn"), { textAlign = "center", width = "100%" }) return end for _, source in ipairs(sources) do local srow = mk({ parent = parent, width = "100%", positioning = "flex", flexDirection = "horizontal", alignItems = "center", gap = 8 * m.s }) label(srow, source.label or source.feed, 12 * m.s + 1, C("detail"), { width = m.contentW - (math.ceil(textWidth(11 * m.s + 1, Strings("Remove"))) + 26) - 8 * m.s, textWrap = false, textOverflow = "ellipsis" }) button(imp, srow, "find-src-rm-" .. tostring(source.feed), Strings("Remove"), { size = 11 * m.s + 1, kind = "danger", action = function() imp:_removeIndex(source.feed) end, }) end -- search field (hand-rolled text state, same routing as the rename modal) textField(imp, parent, "find-search", imp.findQuery or "", Strings("Search mods"), imp._findSearchFocus == true, function() imp._findSearchFocus = true imp:_armTextInput() end) local cats = (imp.findIndex and imp.findIndex.categories) or {} if #cats > 0 then local catRow = mk({ parent = parent, width = "100%", positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap", gap = 6 * m.s }) local function catChip(name, id, active) local key = "find-cat-" .. id mk({ parent = catRow, text = name, textColor = active and C("green") or (imp._hot[key] and C("white") or C("detail")), textSize = 11 * m.s + 2, textAlign = "center-center", autoScaleText = false, backgroundColor = active and C("green", 0.18) or C("border", 0.10), border = 1, borderColor = active and C("green", 0.6) or C("border", 0.35), cornerRadius = 999, padding = { horizontal = 10, vertical = 4 }, onEvent = handler(imp, key, function() imp.findCategory = (id ~= "" and imp.findCategory ~= id) and id or nil end), }) end catChip(Strings("All"), "", imp.findCategory == nil) for _, cat in ipairs(cats) do catChip(cat, cat, imp.findCategory == cat) end end if #rows == 0 then local box = mk({ parent = parent, width = "100%", height = 130 * m.s, backgroundColor = C("card", 0.4), border = 1, borderColor = C("border", 0.3), cornerRadius = 14, positioning = "flex", flexDirection = "vertical", justifyContent = "center", alignItems = "center", gap = 8, padding = { horizontal = 20 }, }) mk({ parent = box, width = math.floor(30 * m.s), height = math.floor(30 * m.s), image = imp._findIcon, objectFit = "contain", imageTint = C("gray", 0.6) }) label(box, (total == 0) and Strings("This index lists no mods yet.") or Strings("No mods match that search."), math.floor(13 * m.s + 1.5), C("detail"), { textAlign = "center" }) if total > 0 then label(box, Strings("Try a different search, or clear the category filter."), math.floor(11 * m.s + 1.5), C("warn"), { textAlign = "center" }) end return end local installed = imp:_findInstalledMap() local thumbW = 64 * m.s -- Explicit measured widths AND heights, same reasoning as the mods card: -- the engine's card auto-height dropped the Details/Source/Install row -- past the card's bottom edge on some displays. local innerW = m.contentW - 32 local bodyW = innerW - thumbW - 10 local titleSize = math.floor(15 * m.s + 2.5) local smallSize = math.floor(12 * m.s + 1.5) local chipSize = math.floor(11 * m.s + 1.5) local btnH = math.ceil(textHeight(chipSize)) + 14 for _, entry in ipairs(rows) do local action, note = findActionFor(entry, installed[entry.id]) local bodyH = math.ceil(textHeight(titleSize)) + 4 + math.ceil(textHeight(smallSize)) if note then bodyH = bodyH + 4 + wrapHeight(smallSize, note, bodyW) end if entry.summary and entry.summary ~= "" then bodyH = bodyH + 4 + wrapHeight(smallSize, entry.summary, bodyW) end local rowH = math.max(thumbW, bodyH) local btnRowW = math.ceil(textWidth(chipSize, Strings("Details"))) + 26 if entry.repo then btnRowW = btnRowW + 6 + math.ceil(textWidth(chipSize, Strings("Source"))) + 26 end if action then btnRowW = btnRowW + 6 + math.ceil(textWidth(chipSize, action)) + 26 else btnRowW = btnRowW + 6 + math.ceil(textWidth(chipSize, Strings("Unavailable"))) + 20 end local btnLines = math.max(1, math.ceil(btnRowW / innerW)) local cardH = 28 + rowH + 8 + btnLines * btnH + (btnLines - 1) * 6 local c = card(parent, { padding = m.cardPad, gap = 8, height = cardH }) local row = mk({ parent = c, width = "100%", height = rowH, positioning = "flex", flexDirection = "horizontal", gap = 10, alignItems = "flex-start" }) local image = imp:_findThumb(entry) if image then mk({ parent = row, image = image, objectFit = "contain", width = thumbW, height = thumbW, cornerRadius = 8 }) else mk({ parent = row, width = thumbW, height = thumbW, backgroundColor = C("border", 0.18), cornerRadius = 8, text = "MOD", textColor = C("disabled"), textSize = math.floor(10 * m.s + 1.5), textAlign = "center-center", autoScaleText = false }) end local body = mk({ parent = row, width = bodyW, height = rowH, positioning = "flex", flexDirection = "vertical", gap = 4 }) label(body, entry.title or entry.id, titleSize, C("white"), { width = "100%", textWrap = false, textOverflow = "ellipsis" }) local meta = "v" .. tostring(ModIndex.displayVersion(entry)) if entry.author then meta = meta .. " - " .. entry.author end if entry.categories and entry.categories[1] then meta = meta .. " - " .. entry.categories[1] end label(body, meta, smallSize, C("detail"), { width = "100%", textWrap = false, textOverflow = "ellipsis" }) if note then label(body, note, smallSize, C("green"), { width = "100%" }) end if entry.summary and entry.summary ~= "" then label(body, entry.summary, smallSize, C("detail"), { width = "100%" }) end local btnRow = mk({ parent = c, width = "100%", height = btnLines * btnH + (btnLines - 1) * 6, positioning = "flex", flexDirection = "horizontal", justifyContent = "flex-end", flexWrap = "wrap", gap = 6 }) if not action then pill(btnRow, Strings("Unavailable"), "gold", chipSize) end button(imp, btnRow, "find-det-" .. entry.id, Strings("Details"), { size = chipSize, kind = "neutral", action = function() imp:_findShowDetails(entry) end, }) if entry.repo then button(imp, btnRow, "find-repo-" .. entry.id, Strings("Source"), { size = chipSize, kind = "neutral", action = function() love.system.openURL(entry.repo) end, }) end if action then button(imp, btnRow, "find-inst-" .. entry.id, action, { size = chipSize, kind = "accent", action = function() imp:_findConfirmInstall(entry) end, }) end end end -- ------- updater banner + footer local function buildBanner(imp, parent, m) if not imp.Check then return end local ok, st = pcall(imp.Check.state) st = (ok and type(st) == "table") and st or nil local status = st and st.status if status ~= "available" and status ~= "downloading" and status ~= "ready" and status ~= "needs_full" then return end local c = card(parent, { padding = { horizontal = 16, vertical = 12 }, borderColor = C("gold", 0.5), gap = 8 * m.s, }) local row = mk({ parent = c, width = "100%", positioning = "flex", flexDirection = "horizontal", alignItems = "center", gap = 10 * m.s }) if status == "downloading" then label(row, Strings("Downloading update"), 13 * m.s + 1, C("detail"), { flex = 1 }) progressBar(c, st.progress, "gold", math.max(8, 10 * m.s)) else local msg, btnLabel, action if status == "available" then msg = st.latest and (Strings("Update v") .. st.latest .. Strings(" available")) or Strings("An update is available") btnLabel = Strings("Update") action = function() pcall(imp.Check.download) end elseif status == "needs_full" then msg = Strings("A new version needs a fresh download") btnLabel = Strings("Open releases") action = function() love.system.openURL(imp.Check.releaseUrl()) end else msg = Strings("Update downloaded") btnLabel = Strings("Restart to update") action = function() require("src.core.HostShell").restart() end end label(row, msg, 13 * m.s + 1, C("white"), { flex = 1 }) button(imp, row, "updater", btnLabel, { h = m.btnH, size = 13 * m.s, kind = "primary", action = action, }) end end 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)" local function buildFooter(imp, parent, m) mk({ parent = parent, width = "100%", height = 1, backgroundColor = C("border", 0.18) }) -- The BCG mark is dark ink; invert it to white for the dark panel. 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((180 * m.s) / bw, (44 * m.s) / bh) mk({ parent = parent, width = bw * scale, height = bh * scale, alignSelf = "center", customDraw = function(el) love.graphics.setShader(imp.invertShader) love.graphics.setColor(1, 1, 1, imp._hot.bcg and 1 or 0.85) love.graphics.draw(imp.bcg, el.x, el.y, 0, el.width / bw, el.height / bh) love.graphics.setShader() end, onEvent = handler(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end), }) label(parent, TRUST_WARNING, 10 * m.s + 2, C("warn"), { width = "100%", textAlign = "center" }) -- the link gets a full-width, center-aligned row of its own: alignSelf on -- an auto-width label was not honored and left it hugging the margin local linkRow = mk({ parent = parent, width = "100%", positioning = "flex", justifyContent = "center", padding = { bottom = 18 } }) label(linkRow, COMMUNITY_URL, 11 * m.s + 2, C("link", imp._hot.bois and 1 or 0.85), { textWrap = false, onEvent = handler(imp, "bois", function() love.system.openURL(COMMUNITY_URL) end), }) end -- ------- modals local function modalOverlay(imp, m, closeKey, onClose) local overlay = mk({ z = 1000, x = 0, y = 0, width = m.W, height = m.H, backgroundColor = rgba(4, 6, 16, 0.72), positioning = "flex", justifyContent = "center", alignItems = "center", onEvent = onClose and handler(imp, closeKey, onClose) or function() end, }) return overlay end local function modalPanel(overlay, m, w, props) local p = { parent = overlay, width = math.min(w, m.W - 24), backgroundColor = rgba(12, 17, 38, 0.98), border = 1, borderColor = C("border", 0.5), cornerRadius = 12, positioning = "flex", flexDirection = "vertical", gap = 10 * m.s, padding = { horizontal = 16, vertical = 14 }, -- swallow clicks so the overlay's close handler stays outside the panel onEvent = function() end, } for k, v in pairs(props or {}) do p[k] = v end return mk(p) end -- Shared prompt: title, hand-rolled text field, hint, action row. local function buildPrompt(imp, m, spec) local overlay = modalOverlay(imp, m, spec.key .. "-out") local panel = modalPanel(overlay, m, spec.w or 460 * m.s) label(panel, spec.title, 15 * m.s + 2, C("white")) if spec.hint then label(panel, spec.hint, 12 * m.s + 1, C("detail")) end textField(imp, panel, spec.key .. "-field", spec.text or "", nil, true) local btnRow = mk({ parent = panel, width = "100%", positioning = "flex", flexDirection = "horizontal", justifyContent = "flex-end", gap = 8 * m.s }) if spec.paste then button(imp, btnRow, spec.key .. "-paste", Strings("Paste"), { size = 12 * m.s + 1, kind = "accent", action = spec.paste, }) mk({ parent = btnRow, flex = 1 }) end button(imp, btnRow, spec.key .. "-cancel", Strings("Cancel"), { size = 12 * m.s + 1, kind = "neutral", action = spec.cancel, }) button(imp, btnRow, spec.key .. "-ok", spec.okLabel or Strings("Save"), { size = 12 * m.s + 1, kind = "primary", action = spec.commit, }) if spec.footnote then label(panel, spec.footnote, 11 * m.s + 1, C("warn")) end end local function buildConfirmModal(imp, m) local c = imp._modConfirm local overlay = modalOverlay(imp, m, "confirm-out") local panel = modalPanel(overlay, m, 420 * m.s) label(panel, c.title or Strings("Confirm"), 15 * m.s + 2, C("white")) for _, line in ipairs(c.lines or {}) do label(panel, line, 12 * m.s + 1, C("detail")) end local btnRow = mk({ parent = panel, width = "100%", positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s }) button(imp, btnRow, "confirm-yes", c.yesLabel or Strings("OK"), { flex = 1, h = m.btnH, size = 13 * m.s + 1, kind = "primary", 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) else imp:_toggleMod(c.id, true) end end, }) button(imp, btnRow, "confirm-no", Strings("Cancel"), { flex = 1, h = m.btnH, size = 13 * m.s + 1, kind = "neutral", action = function() imp._modConfirm = nil end, }) end local function buildTextModal(imp, m, title, body, closeFn, scrollId) local overlay = modalOverlay(imp, m, "textmodal-out") local panel = modalPanel(overlay, m, 520 * m.s) label(panel, title, 15 * m.s + 2, C("white")) local scroller = mk({ parent = panel, id = scrollId, width = "100%", height = math.min(m.H * 0.5, 340 * m.s), overflowY = "scroll", hideScrollbars = true, positioning = "flex", flexDirection = "vertical", padding = { right = 8 }, }) label(scroller, body, 12 * m.s + 1, C("detail"), { width = "100%" }) button(imp, panel, "textmodal-close", Strings("Close"), { w = "100%", h = m.btnH, size = 13 * m.s, kind = "neutral", action = closeFn, }) end local function buildVersionsModal(imp, m) local ModUpdate = require("src.mods.ModUpdate") local v = imp._modVersions local overlay = modalOverlay(imp, m, "versions-out") local panel = modalPanel(overlay, m, 520 * m.s) label(panel, Strings("Other versions: ") .. tostring(v.name), 15 * m.s + 2, C("white")) local info = imp:_modUpdateInfo(v.id) local statusTxt = Strings("Installed: v") .. tostring(v.current) local statusCol = "detail" if info and info.status == "available" then statusTxt = statusTxt .. " - " .. Strings("Update v") .. tostring(info.latest) statusCol = "green" elseif info and info.status == "current" then statusTxt = statusTxt .. " - " .. Strings("Up to date") statusCol = "green" end label(panel, statusTxt, 12 * m.s + 1, C(statusCol)) local scroller = mk({ parent = panel, id = "modversions", width = "100%", height = math.min(m.H * 0.5, 320 * m.s), overflowY = "scroll", hideScrollbars = true, positioning = "flex", flexDirection = "vertical", gap = 6 * m.s, padding = { right = 8 }, }) for i, rel in ipairs(v.releases) do local row = mk({ parent = scroller, width = "100%", backgroundColor = C("bg", 0.5), border = 1, borderColor = C("border", 0.35), cornerRadius = 8, positioning = "flex", flexDirection = "horizontal", alignItems = "center", gap = 8 * m.s, padding = { horizontal = 10, vertical = 8 } }) local text = "v" .. rel.version if rel.version == v.current then text = text .. Strings(" (installed)") end if rel.prerelease then text = text .. " pre" end local body = mk({ parent = row, flex = 1, positioning = "flex", flexDirection = "vertical", gap = 2 * m.s }) label(body, text, 12 * m.s + 1, rel.version == v.current and C("warn") or C("white"), { textWrap = false }) local preview = ModUpdate.previewLine(rel.body or "", 90) if preview ~= "" then label(body, preview, 11 * m.s + 1, C("detail"), { textWrap = false, textOverflow = "ellipsis" }) end if type(rel.body) == "string" and rel.body:match("%S") then button(imp, row, "ver-notes-" .. i, Strings("Read more"), { size = 11 * m.s, kind = "neutral", action = function() imp._modReleaseNotes = { version = rel.version, body = rel.body or "", scroll = 0 } end, }) end if rel.version ~= v.current then button(imp, row, "ver-inst-" .. i, Strings("Install"), { size = 11 * m.s, kind = "accent", action = function() imp:_installModVersion(v.id, rel) end, }) end end button(imp, panel, "versions-close", Strings("Close"), { w = "100%", h = m.btnH, size = 13 * m.s, kind = "neutral", action = function() imp._modVersions = nil end, }) end local function buildSettingsModal(imp, m) local model = imp._settings local overlay = modalOverlay(imp, m, "settings-out") local panel = modalPanel(overlay, m, 640 * m.s, { height = math.min(m.H - 40, m.H * 0.88), }) local head = mk({ parent = panel, width = "100%", positioning = "flex", flexDirection = "horizontal", justifyContent = "space-between", alignItems = "center" }) label(head, Strings("Settings"), 17 * m.s + 3, C("white"), { textWrap = false }) button(imp, head, "settings-close", Strings("Close"), { size = 12 * m.s + 1, kind = "neutral", action = function() imp:_closeSettings() end, }) label(panel, Strings( "Saved to your options file; the game applies these on its next start."), 11 * m.s + 2, C("warn")) local scroller = mk({ parent = panel, id = "settings-scroll", width = "100%", flex = 1, overflowY = "scroll", hideScrollbars = true, positioning = "flex", flexDirection = "vertical", gap = 6 * m.s, padding = { right = 8 }, }) for si, section in ipairs(model.sections) do label(scroller, section.title, 12 * m.s + 2, C("gray"), { width = "100%", margin = { top = si == 1 and 0 or 12 }, }) for ri, row in ipairs(section.rows) do local key = "set-" .. si .. "-" .. ri local rowEl = mk({ parent = scroller, width = "100%", backgroundColor = C("rowBg", 0.6), border = 1, borderColor = C("border", 0.22), cornerRadius = 8, positioning = "flex", flexDirection = "horizontal", alignItems = "center", gap = 8 * m.s, padding = { horizontal = 12, vertical = 8 } }) label(rowEl, row.label, 13 * m.s + 1, C("white"), { flex = 1, textWrap = false, textOverflow = "ellipsis" }) if row.editText then label(rowEl, row.value(), 13 * m.s + 1, C("detail"), { textWrap = false }) button(imp, rowEl, key .. "-edit", Strings("Edit"), { size = 11 * m.s + 1, kind = "accent", action = function() imp._settingsText = { row = row, text = tostring(row.value() or ""), maxLen = row.editText.maxLen } imp:_armTextInput() end, }) else button(imp, rowEl, key .. "-prev", "<", { size = 13 * m.s + 1, kind = "neutral", pad = { horizontal = 10, vertical = 4 }, action = function() if row.step and row.step(-1) then model.save() end end, }) label(rowEl, row.value(), 13 * m.s + 1, C("green"), { width = 110 * m.s, textAlign = "center", textWrap = false, }) button(imp, rowEl, key .. "-next", ">", { size = 13 * m.s + 1, kind = "neutral", pad = { horizontal = 10, vertical = 4 }, action = function() if row.step and row.step(1) then model.save() end end, }) end end end 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 end if imp._settings then buildSettingsModal(imp, m) return 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 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 end if imp._modConfirm then buildConfirmModal(imp, m) return 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, "v" .. tostring(n.version) .. Strings(" notes"), body, function() imp._modReleaseNotes = nil end, "release-notes") return 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, d.title, body, function() imp._findDetails = nil end, "find-details") return end if imp._modVersions then buildVersionsModal(imp, m) return end end -- ------- pad cursor overlay (drawn after FlexLove, plain love.graphics) local function drawPadCursor(imp) if not imp._padCursorActive then return end local x, y = imp._padCursor.x, imp._padCursor.y 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.05, 0.07, 0.12, 1) love.graphics.polygon("line", x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) love.graphics.pop() end -- ------- frame assembly function LauncherView.draw(imp) ensureFlex(imp) local W, H = love.graphics.getDimensions() if imp._lastW ~= W or imp._lastH ~= H then imp._lastW, imp._lastH = W, H pcall(FlexLove.resize) -- Persisted immediate-mode state (scroll offsets and the scroll -- manager's cached geometry) survives a resize keyed by element id, so -- the old window's scissors and scrollbar metrics kept clipping the new -- layout. Drop it all; losing the scroll position on a resize is the -- lesser cost. pcall(FlexLove.clearAllStates) end -- flat backdrop, painted before the element tree renders over it love.graphics.setColor(C("bg"):toRGBA()) love.graphics.rectangle("fill", 0, 0, W, H) love.graphics.setColor(1, 1, 1, 1) local ox, oy, sw, sh = SafeArea.rect() local s = clamp(sh / 768, 0.62, 1.5) local appW = math.min(sw, 1200 * s) local m = { W = W, H = H, s = s, x = ox + (sw - appW) / 2, top = oy, w = appW, h = sh, pad = clamp(appW * 0.03, 10, 24), chip = math.max(34, 42 * s), logoH = clamp(sh * 0.11, 40, 96), btnH = math.max(34, 40 * s), cardPad = { horizontal = 16, vertical = 14 }, twoCol = appW >= 640, } m.colGap = 16 * m.s -- scrollbars are hidden (wheel and touch drag still scroll); the slim -- gutter is breathing room so content never touches the window edge m.gutter = 8 m.contentW = appW - 2 * m.pad - m.gutter m.colW = m.twoCol and math.floor((m.contentW - m.colGap) / 2) or m.contentW local root = mk({ x = m.x, y = m.top, width = m.w, height = m.h, positioning = "flex", flexDirection = "vertical", }) buildHeader(imp, root, m) -- One scroll region per tab (stable id keeps its offset across frames and -- separate per tab), holding the panel, the updater banner and the footer. local page = mk({ parent = root, id = "page-" .. imp.tab, width = "100%", flex = 1, overflowY = "scroll", hideScrollbars = true, positioning = "flex", flexDirection = "vertical", gap = 12 * m.s, padding = { left = m.pad, right = m.pad + m.gutter, top = 14 * m.s, bottom = 10 * m.s }, }) if imp.tab == "mods" then buildModsPanel(imp, page, m) elseif imp.tab == "find" then buildFindPanel(imp, page, m) else buildGamePanel(imp, page, m, imp.tab) end buildBanner(imp, page, m) buildFooter(imp, page, m) buildModals(imp, m) FlexLove.draw() drawPadCursor(imp) -- Dev harness: POKEPORT_LAUNCHER_DUMP=1 prints the laid-out tree once -- (id/text, x, y, w, h) so geometry bugs are read off numbers instead of -- guessed from screenshots. if os.getenv("POKEPORT_LAUNCHER_DUMP") == "1" and not imp._dumped and imp._shotTimer and imp._shotTimer > 1.0 then imp._dumped = true local function walk(el, depth) local tag = el.id or (el.text and ("%q"):format( tostring(el.text):sub(1, 24))) or "-" print(("%s%s x=%.0f y=%.0f w=%.0f h=%.0f"):format( (" "):rep(depth), tag, el.x or -1, el.y or -1, el.width or -1, el.height or -1)) for _, ch in ipairs(el.children or {}) do walk(ch, depth + 1) end end for _, el in ipairs(FlexLove.topElements or {}) do walk(el, 0) end end end return LauncherView