Files
gen1recomp/src/ui/kit/Loader.lua
T
bryanthaboi af47e19e1a 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.
2026-08-04 15:17:09 -04:00

134 lines
5.3 KiB
Lua

-- Non-dismissable loading overlays.
--
-- The rule this module enforces: ANY operation that can make the UI wait --
-- a network fetch, a ROM extraction, a mod install, an update check -- puts
-- something obvious on screen for its whole duration. The old launcher
-- failed this twice over: slow work ran synchronously on the main thread, so
-- the window simply stopped responding (the Find Mods tab could hang for
-- minutes with no indication it was doing anything at all), and the few
-- operations that did report progress did so as a small line of text.
--
-- Two presentations:
-- Loader.overlay(...) a modal scrim + panel, for work the user must wait
-- on before doing anything else. It BLOCKS input
-- (Kit.blockClicks) and offers no dismiss control --
-- that is deliberate, so a half-finished install can
-- never be clicked around. Cancellable work passes
-- an onCancel and gets exactly one Cancel button.
-- Loader.inline(...) a spinner + label sized to a control, for work that
-- only blocks part of the UI (a row's update check).
--
-- Callers drive both from a state table; nothing here owns state or time, so
-- the same overlay renders identically in a screenshot test.
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local PAL = Theme.PAL
local Loader = {}
-- Scrim alpha. Not opaque: the user keeps the context of what they were
-- doing, which is most of why a modal beats a blank screen.
local SCRIM_A = 0.82
-- spec = {
-- title = "Fetching mod index", -- required, the verb in progress
-- detail = "index.json from ...", -- optional second line
-- progress = 0..1 or nil, -- nil = indeterminate (spinner)
-- count = "3 of 12", -- optional right-aligned counter
-- onCancel = function() end, -- optional; adds a Cancel button
-- cancelLabel = "Cancel",
-- }
-- Returns true when the cancel button was activated this frame.
function Loader.overlay(m, spec)
if not spec then return false end
local G = love and love.graphics
local W, H = m.W, m.H
-- The scrim covers the whole window, not just the app column: a modal that
-- leaves the letterboxed margins live is a modal you can click around.
if G then
Theme.fill(0, 0, W, H, PAL.bg, SCRIM_A)
end
-- Everything drawn BEFORE this call is now shielded; the panel below
-- lowers the shield for its own controls.
Kit.blockClicks = true
local pw = math.floor(math.min(m.w - 2 * m.pad, 460 * m.s))
local ph = math.floor((spec.onCancel and 210 or 160) * m.s)
local px = math.floor((W - pw) / 2)
local py = math.floor((H - ph) / 2)
Kit.card(px, py, pw, ph, true)
local pad = math.floor(18 * m.s)
local cx = px + pw / 2
-- Spinner (indeterminate) or a progress bar (determinate). Never both.
local y = py + pad
if spec.progress then
Kit.textCenter("button", spec.title, px + pad, y, pw - 2 * pad, PAL.heading)
y = y + Kit.textHeight("button") + math.floor(14 * m.s)
Kit.progress(px + pad, y, pw - 2 * pad, math.floor(10 * m.s), spec.progress)
y = y + math.floor(10 * m.s) + math.floor(10 * m.s)
local pct = ("%d%%"):format(math.floor(spec.progress * 100 + 0.5))
Kit.textCenter("small", pct, px + pad, y, pw - 2 * pad, PAL.detail)
y = y + Kit.textHeight("small") + math.floor(6 * m.s)
else
local r = math.floor(16 * m.s)
Kit.spinner(cx, y + r, r)
y = y + 2 * r + math.floor(14 * m.s)
Kit.textCenter("button", spec.title, px + pad, y, pw - 2 * pad, PAL.heading)
y = y + Kit.textHeight("button") + math.floor(6 * m.s)
end
if spec.detail and spec.detail ~= "" then
Kit.textCenter("small",
Kit.ellipsize("small", spec.detail, pw - 2 * pad),
px + pad, y, pw - 2 * pad, PAL.muted)
y = y + Kit.textHeight("small") + math.floor(4 * m.s)
end
if spec.count and spec.count ~= "" then
Kit.textCenter("micro", spec.count, px + pad, y, pw - 2 * pad, PAL.faint)
end
local cancelled = false
if spec.onCancel then
-- The one control a blocking overlay may have. It lives inside the
-- panel, so it is the only thing on screen that can take a click.
Kit.blockClicks = false
local bw = math.floor(math.min(pw - 2 * pad, 160 * m.s))
local bh = m.btnH
if Kit.button(px + (pw - bw) / 2, py + ph - pad - bh, bw, bh,
spec.cancelLabel or "Cancel",
{ kind = "ghost", id = "loader:cancel" }) then
cancelled = true
end
Kit.blockClicks = true
end
return cancelled
end
-- A spinner plus label occupying a control-sized rect. Used in place of the
-- button that started the work, so the row does not reflow while it runs.
function Loader.inline(x, y, w, h, label)
local r = math.floor(math.min(h, 20 * Kit.scale) / 2)
local cx = x + r + 4
Kit.spinner(cx, y + h / 2, r)
if label then
local lx = cx + r + 8
Kit.text("small", Kit.ellipsize("small", label, math.max(0, x + w - lx)),
lx, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
end
end
-- A tiny spinner sized to sit inside a text run (a mod row checking for
-- updates). Returns the width it consumed.
function Loader.dot(x, y, size)
local r = size / 2
Kit.spinner(x + r, y + r, r)
return size
end
return Loader