Rebuild the launcher and save editor on a small immediate-mode UI kit

The launcher spent ~9ms per frame building and drawing, and the Find Mods
tab could hang the window for minutes. Both had the same root cause: a
retained UI tree rebuilt every frame, and blocking curl calls made from the
draw path.

Replace the vendored FlexLove engine (28.5k lines) with src/ui/kit/ (Kit,
Theme, Layout, Loader). The kit caches Text objects and all measurement,
allocates nothing in the steady state, and draws flat. Build+draw is now
under 1ms at every window size and on every tab (POKEPORT_LAUNCHER_PROF).

Move every network call off the render thread onto a love.thread pool
(src/net/Fetch.lua): mod index fetches, per-mod release checks, find-tab
stats, thumbnails and mod installs. Mod indexes prewarm at boot so the
Find Mods tab is populated before it is opened.

Paginate every list -- mods, find, save slots, settings, release notes,
versions -- with the page size derived from the real viewport height, so a
500-mod index costs what a 10-mod one does. Scrolling is gone.

Anything that waits now raises a non-dismissable loader; per-row background
work shows an inline spinner instead. The in-app updater moves to the top
right beside the settings gear and pulses when an update is waiting.

Theme is black with white outlines, no gradients or glows, and solid
colour-coded embossed buttons with bold labels. The game tabs keep their
cartridge colours. Everything is 1.3x larger. The save editor shares the
theme, and adding an item there is now a searchable pop-up like adding a
Pokemon.

Also:
- Reset rebinds, in Settings and under Touch Controls. Rebinds are additive
  (Input:applyBindings layers them over the defaults), so there was no
  in-game way to undo one.
- Launch options: --game red [--slot N] / POKEPORT_GAME boots straight into
  a game for shortcuts and frontends, falling back to that game's tab when
  its ROM is not imported.

Fixes found while porting:
- Ellipsis and letterspacing truncated bytes, not codepoints, so a
  multi-byte mod name crashed the first frame on a Japanese index.
  Measurement no longer throws on malformed input either.
- The new font set missed UiFont's kana fallback, rendering translated
  builds as tofu.
- Fetch workers idle in Channel:demand() and LOVE waits for live threads at
  exit, so the process outlived the window; quitting mid-download also
  waited on curl's 300s ceiling. Shut the pool down in love.quit and bound
  its transfer timeouts.
- In one column the save-slot card drew below the fold, over the footer,
  with no scrollbar left to reach it.

The two FlexLove engine tests guarded a scroll manager and an auto-height
propagation bug that no longer exist; replace them with a kit suite covering
page bounds, viewport sizing and UTF-8 truncation, and retarget the NX test
to assert the dependency is gone rather than that its perf guards are set.
This commit is contained in:
bryanthaboi
2026-08-04 15:17:09 -04:00
parent 8fbe819493
commit af47e19e1a
78 changed files with 4785 additions and 33397 deletions
+113
View File
@@ -0,0 +1,113 @@
-- Launch options: boot straight into a game, skipping the launcher.
--
-- love . --game red -- boot Red
-- love . --game yellow --slot 2 -- boot Yellow on save slot 2
-- love . --game red --launcher -- open the launcher anyway (a shortcut
-- the player wants to edit)
-- POKEPORT_GAME=blue love . -- same, for launchers that only pass env
--
-- This exists for the click-once cases: a desktop shortcut per game, a Steam
-- entry, an EmulationStation/Playnite entry, a handheld frontend. Those all
-- want "start the thing" and treat any menu in between as a defect.
--
-- Everything here is pure resolution and validation -- no love.* beyond the
-- filesystem read that slot selection needs -- so the engine test tier can
-- cover the parsing without a window.
local GameVersion = require("src.core.GameVersion")
local LaunchOptions = {}
-- Set by main.lua when a requested game turns out not to be importable yet:
-- the launcher opens on that tab instead of booting.
LaunchOptions.pendingTab = nil
local function normalizeVersion(v)
if type(v) ~= "string" then return nil end
v = v:lower():gsub("^%s+", ""):gsub("%s+$", "")
if v == "" then return nil end
-- Accept the aliases people actually type.
local alias = {
r = "red", red = "red",
b = "blue", blue = "blue",
y = "yellow", yellow = "yellow",
}
v = alias[v] or v
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
return v
end
-- Pull "--flag value" (and "--flag=value") out of LOVE's arg table.
local function argValue(argv, name)
if type(argv) ~= "table" then return nil end
for i = 1, #argv do
local a = argv[i]
if a == "--" .. name then
return argv[i + 1]
end
local inline = type(a) == "string" and a:match("^%-%-" .. name .. "=(.*)$")
if inline then return inline end
end
return nil
end
local function argFlag(argv, name)
if type(argv) ~= "table" then return false end
for i = 1, #argv do
if argv[i] == "--" .. name then return true end
end
return false
end
-- Returns version, slotId (either may be nil). Command line wins over env,
-- so a shortcut can override a machine-wide default.
function LaunchOptions.resolve(argv)
local game = normalizeVersion(argValue(argv, "game"))
or normalizeVersion(os.getenv("POKEPORT_GAME"))
or normalizeVersion(os.getenv("POKEPORT_LAUNCH"))
local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT")
if type(slot) == "string" then
slot = slot:gsub("^%s+", ""):gsub("%s+$", "")
if slot == "" then slot = nil end
end
return game, slot
end
function LaunchOptions.forceLauncher(argv)
return argFlag(argv, "launcher") or os.getenv("POKEPORT_FORCE_LAUNCHER") == "1"
end
-- Point a version at a save slot before it boots. Accepts either a slot id
-- ("slot2") or a 1-based index ("2"), because a shortcut author should not
-- have to know the internal id scheme. A slot that does not exist is
-- ignored: booting the game on its previous slot beats refusing to start.
-- Returns the id actually selected, or nil.
function LaunchOptions.selectSlot(version, slot)
local ok, SaveData = pcall(require, "src.core.SaveData")
if not ok then return nil end
local listed = SaveData.listSlots and SaveData.listSlots(version) or nil
if type(listed) ~= "table" or #listed == 0 then return nil end
local target
local index = tonumber(slot)
if index and listed[index] then
target = listed[index].id
else
for _, s in ipairs(listed) do
if s.id == slot then target = s.id break end
end
end
if not target then return nil end
pcall(SaveData.setActiveSlot, version, target)
return target
end
-- The shortcut command a player would use for this game, for the launcher to
-- show and for docs to quote.
function LaunchOptions.commandFor(version, slot)
local cmd = "--game " .. tostring(version)
if slot then cmd = cmd .. " --slot " .. tostring(slot) end
return cmd
end
return LaunchOptions