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
+188
View File
@@ -0,0 +1,188 @@
-- Async HTTP for the launcher: a small job queue over a pool of love.thread
-- workers.
--
-- WHY THIS EXISTS. Every network call in the launcher used to run on the
-- render thread. HostShell.httpGet shells out to curl through io.popen and
-- reads the pipe to EOF, so refreshing the mod index, checking one mod's
-- releases, or opening the Find Mods tab froze the window for as long as the
-- server took -- measured at over two minutes on a cold Find Mods open, with
-- no spinner, no progress and no way to cancel, because the frame that would
-- have drawn them never ran. The self-updater already did this correctly on
-- a worker (src/update/check_worker.lua); this generalises that pattern so
-- everything else can follow it.
--
-- CONTRACT. Callers get an opaque job id back immediately and poll it:
-- local job = Fetch.get(url)
-- ...
-- local st = Fetch.poll(job) -- { status, body, err, progress }
-- if st.status == "ok" then ... end
-- status is: pending | ok | error | cancelled. poll() never blocks and
-- never throws. A job's result is retained until Fetch.release(job), so a
-- caller that polls once per frame cannot miss it.
--
-- DEGRADATION. With no love.thread (the headless test stub), no curl and no
-- Android bridge, jobs complete immediately with status "error" and a reason.
-- The UI shows that as a failed fetch, which is the same path an offline
-- machine takes -- there is no code path where the launcher waits forever.
local Fetch = {}
local CMD = "fetch_cmd"
local RESULT = "fetch_result"
-- Worker count. Three is enough to overlap the common burst (a mod index
-- refresh plus a couple of per-mod release checks) without spawning a thread
-- per row on a 200-mod list; extra jobs queue on the channel.
local POOL = 3
local workers = {}
local cmdCh, resCh
local ready -- nil = untried, true = running, false = unavailable
local jobs = {} -- id -> { status, body, err, progress, path }
local nextId = 0
local unavailableReason
local function ensureWorkers()
if ready ~= nil then return ready end
if not (love and love.thread and love.thread.newThread) then
ready, unavailableReason = false, "background threads unavailable"
return false
end
cmdCh = love.thread.getChannel(CMD)
resCh = love.thread.getChannel(RESULT)
for i = 1, POOL do
local ok, th = pcall(love.thread.newThread, "src/net/fetch_worker.lua")
if ok and th and pcall(function() th:start() end) then
workers[#workers + 1] = th
end
end
if #workers == 0 then
ready, unavailableReason = false, "could not start fetch workers"
return false
end
ready = true
return true
end
-- Move every finished result off the channel into the job table. Called by
-- poll() and pending(), so a caller that polls any job drains all of them.
local function drain()
if not resCh then return end
local msg = resCh:pop()
while msg do
if type(msg) == "table" and msg.id then
local j = jobs[msg.id]
if j and j.status == "pending" then
if msg.progress and not msg.done then
j.progress = msg.progress
else
j.status = msg.ok and "ok" or "error"
j.body, j.err, j.path = msg.body, msg.err, msg.path
j.progress = msg.ok and 1 or j.progress
end
end
end
msg = resCh:pop()
end
-- A worker that died takes its in-flight job with it; surface that rather
-- than leaving the job pending forever (which would hang a loader overlay).
for _, th in ipairs(workers) do
local err = th:getError()
if err then
for _, j in pairs(jobs) do
if j.status == "pending" then
j.status, j.err = "error", tostring(err)
end
end
break
end
end
end
local function submit(cmd)
nextId = nextId + 1
local id = nextId
cmd.id = id
jobs[id] = { status = "pending", progress = 0 }
if not ensureWorkers() then
jobs[id].status = "error"
jobs[id].err = unavailableReason
return id
end
cmdCh:push(cmd)
return id
end
-- GET a URL, returning the body as a string.
-- opts: { userAgent, accept }
function Fetch.get(url, opts)
opts = opts or {}
return submit({ kind = "get", url = url,
userAgent = opts.userAgent or "gen1recomp",
accept = opts.accept })
end
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
-- Progress is reported as a 0..1 fraction when `size` is known.
function Fetch.download(url, saveRel, opts)
opts = opts or {}
return submit({ kind = "download", url = url, dest = saveRel,
size = opts.size,
userAgent = opts.userAgent or "gen1recomp",
accept = opts.accept })
end
-- Non-blocking status. Returns a table; never nil, even for an unknown id
-- (an unknown id reads as an error, so a caller that dropped its handle
-- cannot deadlock a loader).
local MISSING = { status = "error", err = "unknown job" }
function Fetch.poll(id)
drain()
return jobs[id] or MISSING
end
function Fetch.isPending(id)
return Fetch.poll(id).status == "pending"
end
-- Forget a finished job. Callers should do this once they have consumed the
-- result, or the table grows for the life of the process.
function Fetch.release(id)
jobs[id] = nil
end
-- Mark a job cancelled on the main thread. The worker's curl is NOT killed
-- (there is no portable way to signal it), but the result is dropped when it
-- lands, so a cancelled download cannot resurrect a closed overlay.
function Fetch.cancel(id)
local j = jobs[id]
if j and j.status == "pending" then j.status = "cancelled" end
end
-- True while any job is still running -- drives the "working" indicator in
-- the launcher chrome.
function Fetch.busy()
drain()
for _, j in pairs(jobs) do
if j.status == "pending" then return true end
end
return false
end
function Fetch.available()
return ensureWorkers()
end
-- End every worker. Their command loops sit in Channel:demand(), which never
-- returns on its own, and LOVE waits for every live love.thread before the
-- process exits (#339).
function Fetch.shutdown()
if cmdCh then
for _ = 1, #workers do cmdCh:push({ kind = "quit" }) end
end
for _, th in ipairs(workers) do pcall(function() th:wait() end) end
workers = {}
cmdCh, resCh, ready = nil, nil, false
end
return Fetch