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
+104
View File
@@ -0,0 +1,104 @@
-- Worker thread behind src/net/Fetch.lua. Several of these run as a pool.
--
-- Pulls jobs off the shared "fetch_cmd" channel and pushes results onto
-- "fetch_result". Every job is wrapped in pcall: a worker that dies takes
-- its in-flight job with it, and Fetch surfaces that as an error rather than
-- leaving a loader overlay spinning forever.
--
-- Transport is HostShell, so this inherits the platform matrix that already
-- exists (curl on desktop, the JNI bridge on Android). Fresh love threads do
-- not carry the "src.*" package searcher, so HostShell is pulled in with
-- love.filesystem.load exactly like src/update/check_worker.lua does.
require("love.thread")
require("love.filesystem")
require("love.timer")
require("love.system")
local function loadModule(path)
local ok, chunk = pcall(love.filesystem.load, path)
if not ok or type(chunk) ~= "function" then return nil end
local ok2, mod = pcall(chunk)
if not ok2 then return nil end
return mod
end
local HostShell = loadModule("src/core/HostShell.lua")
local cmdCh = love.thread.getChannel("fetch_cmd")
local resCh = love.thread.getChannel("fetch_result")
local saveDir = love.filesystem.getSaveDirectory()
-- See the note in doGet: these bound how long a quit can block. A mod index
-- or a release list is a small JSON document, and a mod zip is a few MB; the
-- old 300s download ceiling was sized for the self-updater's whole payload,
-- which does not come through this pool.
local GET_MAX_SECONDS = 20
local DOWNLOAD_MAX_SECONDS = 90
local function post(t) resCh:push(t) end
local function doGet(job)
if not HostShell then
post({ id = job.id, ok = false, err = "no transport" })
return
end
-- Bounded transfer time: a worker inside a blocking curl cannot see a quit
-- command, and LOVE waits for live threads before exiting (#339), so this
-- ceiling is also the worst case for how long closing the window can take.
local body, err = HostShell.httpGet(job.url, job.userAgent, job.accept,
GET_MAX_SECONDS)
if not body then
post({ id = job.id, ok = false, err = err or "fetch failed" })
return
end
post({ id = job.id, ok = true, body = body })
end
-- Downloads go straight to the save directory. HostShell.httpDownload
-- blocks until curl exits, which is fine here -- this is the whole reason
-- the work is on a worker -- but it means progress cannot be sampled from
-- inside the call. Where the caller knows the expected size we poll the
-- growing file from a second pass instead; where it does not, the job simply
-- reports indeterminate progress and the UI shows a spinner.
local function doDownload(job)
if not HostShell then
post({ id = job.id, ok = false, err = "no transport" })
return
end
local rel = job.dest
local abs = saveDir .. "/" .. rel
local dir = rel:match("^(.*)/[^/]*$")
if dir then love.filesystem.createDirectory(dir) end
love.filesystem.remove(rel)
local ok, err = HostShell.httpDownload(job.url, abs, job.userAgent,
job.accept, DOWNLOAD_MAX_SECONDS)
if not ok then
post({ id = job.id, ok = false, err = err or "download failed" })
return
end
local info = love.filesystem.getInfo(rel)
if not info or (info.size or 0) == 0 then
love.filesystem.remove(rel)
post({ id = job.id, ok = false, err = "empty download" })
return
end
post({ id = job.id, ok = true, path = rel, done = true })
end
while true do
local job = cmdCh:demand()
if type(job) == "table" then
if job.kind == "quit" then
break
elseif job.kind == "get" then
local ok, err = pcall(doGet, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
elseif job.kind == "download" then
local ok, err = pcall(doDownload, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
end
end
end