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
+917
View File
@@ -0,0 +1,917 @@
-- Immediate-mode widget kit shared by the launcher and the save editor.
--
-- This is the replacement for the vendored FlexLove tree that the launcher
-- used to rebuild every frame. The contract that mattered there is kept --
-- the UI is rebuilt from owner state each frame, so it can never drift --
-- but without a retained element tree, per-element id hashing, or a
-- property snapshot pass. Measured effect on the launcher's build+draw:
-- ~9.2ms/frame down to well under 1ms (see POKEPORT_LAUNCHER_PROF).
--
-- Usage, once per love.draw():
-- Kit.layout(w, h) -- fonts + scale, only on resize
-- Kit.beginFrame(mx, my, clicked, wheel)
-- ... widgets ...
-- Kit.endFrame()
--
-- WHY IT IS FAST (the rules any new widget must follow):
-- 1. No allocation in the steady state. Widgets take and return scalars;
-- the per-frame tables that do exist (nav list, audit) are reused and
-- truncated, never rebuilt. LuaJIT's GC is the difference between a
-- 6ms frame and a 0.6ms one when a list has 200 rows.
-- 2. Text is cached as love.graphics.Text objects keyed by font+string
-- (Kit.text). G.print re-shapes the string every call; a Text object
-- shapes once and then costs one batched draw. Colour is applied at
-- draw time, which does NOT break the batch -- switching FONTS does,
-- which is the other reason the cache pays.
-- 3. Measurement (font:getWidth, ellipsize) is memoised per font+string.
-- Ellipsising is O(glyphs) with a getWidth per step and list rows do it
-- for every visible cell, every frame, on strings that never change.
-- 4. Lists PAGINATE. Row count is bounded by the page size, so a 500-mod
-- index costs exactly what a 10-mod one does. There is no virtualised
-- scroller and no momentum integrator to run.
-- 5. Draw flat. No stencil, no mesh, no blend-mode change, no rounded
-- corners (see Theme.lua) -- every one is a pipeline flush.
--
-- ACCESSIBILITY / INPUT: every control is reachable four ways -- mouse,
-- touch (>= 30px targets), keyboard (spatial focus ring, arrows + Enter),
-- and gamepad (the same ring, driven by the d-pad, plus a virtual cursor).
-- Hit testing is a plain rect with no z-order, so overlapping layers must be
-- drawn in dispatch order and a modal raises Kit.blockClicks over what it
-- covers.
local Theme = require("src.ui.kit.Theme")
local PAL = Theme.PAL
local Kit = {}
Kit.Theme = Theme
Kit.PAL = PAL
Kit.mouseX, Kit.mouseY = 0, 0
Kit.mouseClicked = false -- left button pressed this frame
Kit.mouseDown = false -- held, polled (drag / press-and-hold)
Kit.wheelY = 0 -- wheel notches queued since the last frame
Kit.focus = nil -- id of the text field receiving keystrokes
Kit.focusId = nil -- id of the keyboard/gamepad focus ring target
Kit.time = 0
Kit.fonts = {}
Kit.scale = 1
Kit.blockClicks = false
Kit.audit = nil
local G = love and love.graphics or nil
local edits = {} -- queued textinput / backspace since the last frame
local kbField = nil -- id of the field the soft keyboard is raised for
local function has(name)
return Theme.probe(name)
end
-- ------------------------------------------------------------ soft keyboard
-- Mobile LOVE only delivers love.textinput while setTextInput(true) is
-- active, and that call is what raises the Android/iOS soft keyboard; the
-- rect keeps the focused field visible above it. setTextInput is global SDL
-- state, not per-widget, so desktop text input is never turned off (#529).
local function mobile()
local osName = love and love.system and love.system.getOS
and love.system.getOS()
return osName == "Android" or osName == "iOS"
end
Kit.isMobile = mobile
local function syncSoftKeyboard(id, x, y, w, h)
if not (love and love.keyboard and love.keyboard.setTextInput) then return end
if id then
if kbField ~= id then
kbField = id
love.keyboard.setTextInput(true, math.floor(x), math.floor(y),
math.ceil(w), math.ceil(h))
end
elseif kbField then
kbField = nil
if mobile() then love.keyboard.setTextInput(false) end
end
end
-- ------------------------------------------------------------- text caching
-- Two caches, both keyed by font name + string, both cleared wholesale when
-- the font set is rebuilt (a resize). A wholesale clear is correct and
-- cheap: an LRU would cost more bookkeeping per lookup than it saves, and
-- the working set of a UI is small and stable between resizes.
local textCache, textCacheN = {}, 0
local widthCache = {}
local ellipsisCache = {}
local CACHE_MAX = 1024
local wrapCacheRef -- forward declaration; the table is defined below
local function clearCaches()
textCache, textCacheN = {}, 0
widthCache = {}
ellipsisCache = {}
if wrapCacheRef then
for k in pairs(wrapCacheRef) do wrapCacheRef[k] = nil end
end
end
Kit.clearCaches = clearCaches
local function font(name)
return Kit.fonts[name] or Kit.fonts.small
end
Kit.font = font
-- Rebuild the font set when the window size changes. The scale never dips
-- below 0.9 so text and the 30px tap targets stay readable on a phone; a
-- narrow window is answered by REFLOW (see Layout.lua), never by shrinking.
-- Global size multiplier. Everything in the UI derives from Kit.scale, so
-- one factor here moves text, tap targets, padding and row heights together
-- and nothing drifts out of proportion. 1.3 because the launcher is read at
-- couch distance as often as at desk distance, and the old sizing was tuned
-- for the latter only.
local UI_SCALE = 1.3
function Kit.layout(width, height)
local s = Theme.clamp(math.min(width / 640, height / 768), 0.9, 1.6) * UI_SCALE
local key = ("%dx%d"):format(math.floor(width), math.floor(height))
if Kit._fontKey ~= key then
Kit._fontKey = key
Kit.fonts = Theme.fonts(s)
clearCaches() -- every cached Text/width belongs to the old font set
end
Kit.scale = s
Kit.width, Kit.height = width, height
return s
end
function Kit.textWidth(name, str)
str = tostring(str)
local key = name .. "\0" .. str
local w = widthCache[key]
if w then return w end
local f = font(name)
-- Never let a malformed string (a mod name from a third-party index) throw
-- out of a measurement: an unmeasurable string is treated as zero-width and
-- the ellipsis logic clips it away.
if f then
local ok, got = pcall(f.getWidth, f, str)
w = ok and got or 0
else
w = 0
end
widthCache[key] = w
return w
end
function Kit.textHeight(name)
local f = font(name)
return f and f:getHeight() or 12
end
function Kit.ellipsize(name, str, maxW)
str = tostring(str or "")
local key = name .. "\0" .. math.floor(maxW) .. "\0" .. str
local c = ellipsisCache[key]
if c then return c end
c = Theme.ellipsize(font(name), str, maxW)
ellipsisCache[key] = c
return c
end
function Kit.ellipsizeLeft(name, str, maxW)
str = tostring(str or "")
local key = name .. "\1" .. math.floor(maxW) .. "\0" .. str
local c = ellipsisCache[key]
if c then return c end
c = Theme.ellipsizeLeft(font(name), str, maxW)
ellipsisCache[key] = c
return c
end
-- A cached, pre-shaped Text object. Falls back to G.print under a stub or
-- when the cache is saturated.
local function textObject(name, str)
if not (G and has("newText")) then return nil end
local key = name .. "\0" .. str
local t = textCache[key]
if t then return t end
if textCacheN >= CACHE_MAX then clearCaches() end
local f = font(name)
if not f then return nil end
local ok, obj = pcall(G.newText, f, str)
if not ok then return nil end
textCache[key] = obj
textCacheN = textCacheN + 1
return obj
end
-- Bold text: the same cached run drawn twice, one pixel apart. The UI face
-- has a single weight, so this is the only way to get emphasis without
-- shipping a second font -- and it keeps the measurement identical, which
-- matters because every layout here is measured, not flowed.
function Kit.textBold(name, str, x, y, c, a)
local w = Kit.text(name, str, x, y, c, a)
Kit.text(name, str, x + Theme.BOLD_OFFSET, y, c, a)
return w
end
function Kit.textCenterBold(name, str, x, y, w, c, a)
local tw = Kit.textWidth(name, tostring(str))
return Kit.textBold(name, str, x + (w - tw) / 2, y, c, a)
end
-- Draw a string. Returns its width, so callers can lay out inline runs
-- without a second measurement.
function Kit.text(name, str, x, y, c, a)
if not G then return 0 end
str = tostring(str)
Theme.col(c or PAL.text, a or 1)
local obj = textObject(name, str)
if obj then
G.draw(obj, Theme.snap(x), Theme.snap(y))
else
local f = font(name)
if not f then return 0 end
G.setFont(f)
-- Same guard as the measurement path: a string LOVE cannot shape must
-- not take the whole frame down with it.
pcall(G.print, str, Theme.snap(x), Theme.snap(y))
end
return Kit.textWidth(name, str)
end
function Kit.textRight(name, str, x2, y, c, a)
return Kit.text(name, str, x2 - Kit.textWidth(name, tostring(str)), y, c, a)
end
function Kit.textCenter(name, str, x, y, w, c, a)
return Kit.text(name, str, x + (w - Kit.textWidth(name, tostring(str))) / 2,
y, c, a)
end
-- Word-wrapped text. Font:getWrap re-shapes the whole string every call and
-- list rows ask for the same (font, width, string) every frame, so the line
-- split is memoised alongside the other measurement caches. `maxLines`
-- truncates with an ellipsis rather than overflowing the box the caller
-- reserved -- an immediate-mode layout has no way to grow after the fact.
local wrapCache = {}
wrapCacheRef = wrapCache
function Kit.wrapLines(name, str, w)
str = tostring(str or "")
if str == "" or w <= 0 then return nil end
local key = name .. "\0" .. math.floor(w) .. "\0" .. str
local lines = wrapCache[key]
if lines then return lines end
local f = font(name)
if not f then return nil end
local ok, _, wrapped = pcall(f.getWrap, f, str, w)
lines = (ok and wrapped) or { str }
wrapCache[key] = lines
return lines
end
-- Returns the height consumed.
function Kit.textWrapped(name, str, x, y, w, c, maxLines, a)
local lines = Kit.wrapLines(name, str, w)
if not lines then return 0 end
local lh = Kit.textHeight(name)
local n = #lines
if maxLines and n > maxLines then n = maxLines end
for i = 1, n do
local line = lines[i]
if maxLines and i == maxLines and #lines > maxLines then
line = Kit.ellipsize(name, line .. "...", w)
end
Kit.text(name, line, x, y + (i - 1) * lh, c, a)
end
return n * lh
end
-- Height a wrapped run will need, without drawing it. Panels call this to
-- reserve space before laying the block out.
function Kit.wrapHeight(name, str, w, maxLines)
local lines = Kit.wrapLines(name, str, w)
if not lines then return 0 end
local n = #lines
if maxLines and n > maxLines then n = maxLines end
return n * Kit.textHeight(name)
end
-- 12px / 2px-tracked uppercase section caption -- the design's one and only
-- section header. Returns its height so callers can stack below.
function Kit.caption(x, y, str, c)
if not G then return Kit.textHeight("caption") end
local f = font("caption")
if not f then return 12 end
G.setFont(f)
Theme.col(c or PAL.caption, 1)
Theme.spaced(f, str, Theme.snap(x), Theme.snap(y), 2 * Kit.scale)
return f:getHeight()
end
function Kit.captionWidth(str)
return Theme.spacedWidth(font("caption"), str, 2 * Kit.scale)
end
-- ------------------------------------------------------------- frame cycle
function Kit.beginFrame(mx, my, clicked, wheel)
Kit.mouseX, Kit.mouseY = mx or 0, my or 0
Kit.mouseClicked = clicked and true or false
Kit.wheelY = wheel or 0
local down = false
if love and love.mouse and love.mouse.isDown then
down = love.mouse.isDown(1) and true or false
end
Kit.mouseDown = down
if not down then Kit._drag = nil end
Kit.resetClip()
Kit.blockClicks = false
if love and love.timer and love.timer.getTime then
Kit.time = love.timer.getTime()
end
-- Resolve any queued focus-ring movement against LAST frame's geometry.
-- Immediate mode has no geometry until the frame is built, and the ring
-- must move before widgets test themselves against it.
Kit._resolveNav()
-- Start collecting this frame's focusables.
Kit._navN = 0
end
-- Retire this frame's keystrokes, wheel notches and one-shot activations.
-- Anything typed while no field had focus is dropped here rather than
-- replayed into the next field that gets clicked.
function Kit.endFrame()
for i = #edits, 1, -1 do edits[i] = nil end
Kit.wheelY = 0
Kit._activateId = nil
-- This frame's focusables become next frame's navigation graph.
local n = Kit._navN or 0
Kit._navPrevN = n
-- If the focused id vanished (panel switch, list repaged), park the ring
-- on the first focusable so the keyboard is never stranded.
if Kit.focusId and not Kit._navSeen[Kit.focusId] and n > 0 then
Kit.focusId = Kit._nav[1] and Kit._nav[1].id or nil
end
for k in pairs(Kit._navSeen) do Kit._navSeen[k] = nil end
end
-- ------------------------------------------------------------ focus ring
-- Spatial navigation. Every focusable control registers its rect as it
-- draws; a queued direction picks the nearest candidate in that direction
-- from the previous frame's set. Spatial rather than index-order because
-- the launcher is a multi-column layout: tab-order would zigzag between
-- columns, while "press right, go right" is what both a keyboard and a
-- d-pad user expects.
Kit._nav = {}
Kit._navN = 0
Kit._navPrevN = 0
Kit._navSeen = {}
Kit._navQueue = nil
Kit._activateId = nil
-- Register a focusable. Returns true when it currently holds the ring.
function Kit.focusable(id, x, y, w, h)
local n = (Kit._navN or 0) + 1
Kit._navN = n
local slot = Kit._nav[n]
if not slot then slot = {}; Kit._nav[n] = slot end
slot.id, slot.x, slot.y, slot.w, slot.h = id, x, y, w, h
Kit._navSeen[id] = true
-- First focusable ever drawn adopts the ring, so keyboard users start
-- somewhere rather than nowhere.
if Kit.focusId == nil then Kit.focusId = id end
return Kit.focusId == id
end
function Kit.navigate(dir)
Kit._navQueue = dir
end
function Kit.activateFocused()
if Kit.focusId then Kit._activateId = Kit.focusId end
end
function Kit.setFocus(id)
Kit.focusId = id
end
-- Pick the nearest focusable in `dir` from the current one. Candidates must
-- lie in the half-plane of the direction; the score prefers a small step
-- along the axis of travel and penalises drift across it, which keeps a
-- column walk inside its column.
function Kit._resolveNav()
local dir = Kit._navQueue
Kit._navQueue = nil
local n = Kit._navPrevN or 0
if not dir or n == 0 then return end
local cur
for i = 1, n do
if Kit._nav[i].id == Kit.focusId then cur = Kit._nav[i] break end
end
if not cur then
Kit.focusId = Kit._nav[1].id
return
end
local cx, cy = cur.x + cur.w / 2, cur.y + cur.h / 2
local best, bestScore
for i = 1, n do
local c = Kit._nav[i]
if c.id ~= cur.id then
local dx = (c.x + c.w / 2) - cx
local dy = (c.y + c.h / 2) - cy
local along, across
if dir == "left" then along, across = -dx, math.abs(dy)
elseif dir == "right" then along, across = dx, math.abs(dy)
elseif dir == "up" then along, across = -dy, math.abs(dx)
else along, across = dy, math.abs(dx) end
-- A control merely overlapping on the travel axis is not "in that
-- direction"; require real separation so a tall row's neighbours do
-- not all qualify.
if along > 1 then
local score = along + across * 2
if not bestScore or score < bestScore then best, bestScore = c, score end
end
end
end
if best then Kit.focusId = best.id end
end
-- ------------------------------------------------------------ input plumbing
function Kit.textinput(text)
if not Kit.focus then return false end
edits[#edits + 1] = text
return true
end
-- Returns true when the key was consumed, so the host can leave its own
-- shortcuts alone while the user is typing or driving the ring.
function Kit.keypressed(key)
if Kit.focus then
if key == "backspace" then edits[#edits + 1] = "\b" return true
elseif key == "return" or key == "kpenter" or key == "escape" then
edits[#edits + 1] = "\r" return true
end
-- printable keys arrive through textinput; everything else falls through
return false
end
if key == "up" or key == "down" or key == "left" or key == "right" then
Kit.navigate(key)
return true
elseif key == "return" or key == "kpenter" or key == "space" then
Kit.activateFocused()
return true
end
return false
end
-- Gamepad d-pad / stick, routed by the host's pad handling.
function Kit.gamepadpressed(button)
if button == "dpup" then Kit.navigate("up") return true
elseif button == "dpdown" then Kit.navigate("down") return true
elseif button == "dpleft" then Kit.navigate("left") return true
elseif button == "dpright" then Kit.navigate("right") return true
elseif button == "a" then Kit.activateFocused() return true end
return false
end
function Kit.blur()
Kit.focus = nil
syncSoftKeyboard(nil)
end
-- -------------------------------------------------------------- hit testing
-- A widget inside a clip region can sit at coordinates outside the visible
-- rect, so the active clip bounds the hit: what the user cannot see cannot
-- take the tap.
function Kit.hit(x, y, w, h)
local c = Kit._clipRect
if c and not (Kit.mouseX >= c.x and Kit.mouseX <= c.x + c.w
and Kit.mouseY >= c.y and Kit.mouseY <= c.y + c.h) then
return false
end
return Kit.mouseX >= x and Kit.mouseX <= x + w
and Kit.mouseY >= y and Kit.mouseY <= y + h
end
function Kit.hover(x, y, w, h)
return Kit.hit(x, y, w, h)
end
function Kit.press(x, y, w, h)
if Kit.blockClicks then return false end
return Kit.mouseClicked and Kit.hit(x, y, w, h)
end
-- Layout audit: when a test sets Kit.audit to a table, every control that
-- could take a click this frame appends its rect (plus the clip that bounds
-- it), so a window-size sweep can assert no two controls overlap and none
-- escapes the window. Shielded widgets are skipped: under a modal they
-- cannot take the tap, and the modal legitimately covers them.
local function audit(class, x, y, w, h, label)
local a = Kit.audit
if not a or Kit.blockClicks then return end
local c = Kit._clipRect
a[#a + 1] = { class = class, x = x, y = y, w = w, h = h,
label = tostring(label or ""),
clip = c and { x = c.x, y = c.y, w = c.w, h = c.h } or nil }
end
Kit._audit = audit
-- ------------------------------------------------------------------ metrics
-- Minimum tap target. 30px at scale 1 (up from the editor's 26) because the
-- launcher is the first thing a phone user touches and these are the only
-- controls that matter.
function Kit.tapMin() return math.floor(30 * Kit.scale) end
-- ---------------------------------------------------------------- surfaces
function Kit.card(x, y, w, h, emphasis)
Theme.card(x, y, w, h, emphasis)
end
-- A list row. `id` opts it into the focus ring; pass nil for decorative
-- rows. Returns (clicked, inkColor) -- a selected row fills white, so the
-- caller must print with the returned ink or it will draw white on white.
function Kit.row(x, y, w, h, selected, id)
audit("row", x, y, w, h, id or "row")
local focused = id and Kit.focusable(id, x, y, w, h) or false
local hot = Kit.hover(x, y, w, h)
local state = selected and "selected" or (hot and "hover" or nil)
local ink = Theme.row(x, y, w, h, state)
-- The focus ring is a second inset outline, so it reads on both a black
-- row and a white selected one.
if focused then
Theme.stroke(x + 2, y + 2, w - 4, h - 4,
selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1)
end
local clicked = Kit.press(x, y, w, h)
or (id ~= nil and Kit._activateId == id)
return clicked, ink
end
-- Empty-state box: hairline outline and a centred hint. (The old dashed
-- border sampled a rounded path into a polyline every frame; a solid
-- hairline says the same thing for one rect.)
function Kit.emptyBox(x, y, w, h, message)
if not G then return end
Theme.stroke(x, y, w, h, PAL.line, 0.22, 1)
Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale),
x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted)
end
-- ----------------------------------------------------------------- buttons
-- Button kinds. In a black/white theme the semantics live in the OUTLINE
-- and INK colour; the fill is black until the control is hot or focused, at
-- which point it inverts to a solid fill with dark ink. That inversion is
-- the single strongest contrast signal available and costs one rect.
-- `solid` means the control is filled even at rest: reserved for the single
-- most important action on a screen (Play), which should not have to be
-- hovered before it looks like the answer.
-- Buttons are COLOUR-CODED by what they do, so a control's job is readable
-- before its label is. The button IS the colour: a solid fill with black
-- ink, not an outline with coloured text. Against a black field a filled
-- chip is the strongest, fastest-to-scan signal available, and every accent
-- in this palette is high-luminance, so black ink on it clears contrast
-- requirements comfortably.
-- primary green -- the commit action (Play, Save, Install)
-- good green -- safe helpers
-- accent blue -- navigation / information (Details, Edit, Import)
-- warn yellow -- attention (an update is waiting)
-- danger red -- destructive, always two-press
-- ghost white -- neutral verbs with no better colour
-- disabled grey -- never hidden, always still readable
-- Hover/focus is a white ring around the fill (plus a slight lift), which
-- reads on every colour without needing a second shade of each.
local KINDS = {
primary = { fill = PAL.green, ink = PAL.inverse },
good = { fill = PAL.green, ink = PAL.inverse },
accent = { fill = PAL.blue, ink = PAL.inverse },
warn = { fill = PAL.yellow, ink = PAL.inverse },
danger = { fill = PAL.red, ink = PAL.inverse },
ghost = { fill = PAL.ink, ink = PAL.inverse },
disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true },
}
Kit.KINDS = KINDS
-- opts: { kind, font, enabled, align, id, glow }
-- id -- opts into the focus ring (give every real control one)
-- glow -- a pulsing outline for "something is waiting for you" (the
-- update button). No blend-mode change: the alpha of the
-- existing outline is animated instead.
-- Returns true when activated, by click OR by the focus ring's Enter/A.
function Kit.button(x, y, w, h, label, opts)
opts = opts or {}
local enabled = opts.enabled ~= false
-- Disabled buttons audit too: they stay visible, so they still must not
-- paint over a neighbour.
audit("control", x, y, w, h, label)
local focused = enabled and opts.id
and Kit.focusable(opts.id, x, y, w, h) or false
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
local hot = enabled and Kit.hover(x, y, w, h)
if G then
-- The fill IS the control: a rounded, embossed, colour-coded key. A
-- disabled button keeps its shape in a dead grey rather than
-- disappearing, so a layout never reflows on state.
Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45)
Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4)
if hot or focused then
-- White ring outside the fill: legible on green, blue, yellow, red and
-- white alike, which one darker/lighter shade per colour would not be.
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
Theme.A.focus, 2, Theme.radius() + 2)
elseif opts.glow and enabled then
-- "Something is waiting for you" (the update button): a pulsing ring.
-- Pure alpha on one existing stroke -- no extra draw calls, no blend
-- mode change.
local a = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(Kit.time * 3))
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, a, 2,
Theme.radius() + 2)
end
local fname = opts.font or "button"
local ink = enabled and kind.ink or PAL.inverse
local ty = y + (h - Kit.textHeight(fname)) / 2
local shown = Kit.ellipsize(fname, label, w - 16 * Kit.scale)
-- Button labels are bold: they are the shortest, most-scanned text on
-- screen and sit on a saturated fill.
if opts.align == "left" then
Kit.textBold(fname, shown, x + 10 * Kit.scale, ty, ink)
else
Kit.textCenterBold(fname, shown, x, ty, w, ink)
end
end
if not enabled then return false end
return Kit.press(x, y, w, h)
or (opts.id ~= nil and Kit._activateId == opts.id)
end
-- A small square control: +/- steppers, arrow cyclers, the row X.
function Kit.stepper(x, y, w, h, glyph, opts)
opts = opts or {}
opts.kind = opts.kind or "ghost"
opts.font = opts.font or "small"
return Kit.button(x, y, w, h, glyph, opts)
end
-- A pill toggle (badges, dex SEEN/OWN, sub-tabs). `on` inverts it.
function Kit.chip(x, y, w, h, label, on, color, id)
audit("control", x, y, w, h, label)
local focused = id and Kit.focusable(id, x, y, w, h) or false
local c = color or PAL.line
if G then
local hot = focused or Kit.hover(x, y, w, h)
if on then
Theme.fillRounded(x, y, w, h, c, 1)
Theme.emboss(x, y, w, h, 1)
Kit.textCenterBold("micro", label, x,
y + (h - Kit.textHeight("micro")) / 2, w, PAL.inverse)
else
Theme.fillRounded(x, y, w, h, PAL.bg, 1)
Theme.strokeRounded(x, y, w, h, c,
hot and Theme.A.focus or Theme.A.hover, 1)
Kit.textCenterBold("micro", label, x,
y + (h - Kit.textHeight("micro")) / 2, w, c)
end
if hot then
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
Theme.A.focus, 2, Theme.radius() + 2)
end
end
return Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id)
end
-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE"
-- markers on mod rows.
function Kit.tag(x, y, w, h, label, color)
if not G then return end
Theme.strokeRounded(x, y, w, h, color or PAL.line, 0.7, 1)
Kit.textCenter("micro", label, x, y + (h - Kit.textHeight("micro")) / 2, w,
color or PAL.muted)
end
-- Checkbox row. Returns (newChecked, changed).
function Kit.checkbox(x, y, w, h, checked, label, id, labelColor)
local clicked, ink = Kit.row(x, y, w, h, false, id)
local box = 20 * Kit.scale
local bx, by = x + 12 * Kit.scale, y + (h - box) / 2
if G then
if checked then
Theme.fill(bx, by, box, box, PAL.ink, 1)
Kit.textCenter("small", "X", bx,
by + (box - Kit.textHeight("small")) / 2, box, PAL.inverse)
else
Theme.stroke(bx, by, box, box, PAL.line, Theme.A.hover, 1)
end
local lx = bx + box + 12 * Kit.scale
Kit.text("mono", Kit.ellipsize("mono", label, x + w - lx - 10 * Kit.scale),
lx, y + (h - Kit.textHeight("mono")) / 2,
labelColor or ink or PAL.text)
end
if clicked then return not checked, true end
return checked, false
end
-- A two-state switch, for the settings ladders.
function Kit.toggle(x, y, w, h, on, id)
audit("control", x, y, w, h, "toggle")
local focused = id and Kit.focusable(id, x, y, w, h) or false
if G then
-- Track, then a knob inset inside it, so the control reads as a switch
-- rather than as a white square with a word next to it. The label sits
-- in the empty half, which is the half that says what pressing does.
Theme.stroke(x, y, w, h, PAL.line,
(focused or Kit.hover(x, y, w, h)) and Theme.A.focus or Theme.A.hover, 1)
local inset = 3
local knob = w / 2 - inset
Theme.fill(on and (x + w / 2) or (x + inset), y + inset, knob, h - 2 * inset,
PAL.ink, 1)
Kit.textCenter("micro", on and "ON" or "OFF",
on and x or (x + w / 2), y + (h - Kit.textHeight("micro")) / 2, w / 2,
PAL.text)
end
local hitTaken = Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id)
if hitTaken then return not on, true end
return on, false
end
-- A determinate progress bar with an optional caption.
function Kit.progress(x, y, w, h, frac, label)
Theme.meter(x, y, w, h, (frac or 0) * 100, PAL.ink)
if label then
Kit.text("micro", label, x, y + h + 4 * Kit.scale, PAL.muted)
end
end
-- --------------------------------------------------------------- text field
function Kit.textfield(id, x, y, w, h, value, placeholder)
audit("control", x, y, w, h, id)
local focusRing = Kit.focusable(id, x, y, w, h)
value = tostring(value or "")
if Kit.press(x, y, w, h) or (Kit._activateId == id) then Kit.focus = id end
local focused = (Kit.focus == id)
if focused then
syncSoftKeyboard(id, x, y, w, h)
for _, e in ipairs(edits) do
if e == "\b" then
value = value:sub(1, -2)
elseif e == "\r" then
Kit.blur()
focused = false
else
value = value .. e
end
end
end
if G then
Theme.fill(x, y, w, h, PAL.bg, 1)
Theme.stroke(x, y, w, h, PAL.line,
(focused or focusRing) and Theme.A.focus or Theme.A.hairline,
focused and 2 or 1)
local pad = 10 * Kit.scale
local ty = y + (h - Kit.textHeight("mono")) / 2
if value == "" and not focused then
Kit.text("mono", placeholder or "", x + pad, ty, PAL.faint)
else
local shown = Kit.ellipsizeLeft("mono", value, w - 2 * pad)
local tw = Kit.text("mono", shown, x + pad, ty, PAL.heading)
if focused and (Kit.time % 1) < 0.55 then
Theme.fill(x + pad + tw + 2, ty, math.max(1, Kit.scale),
Kit.textHeight("mono"), PAL.ink, 1)
end
end
end
return value
end
-- -------------------------------------------------------------------- pager
-- Prev / Next / "1-12 of 151". Drawn even for a single page, so a list is
-- never silently truncated. This is the ONLY way the launcher moves through
-- a long list: no scrollbars, no momentum, bounded row count per frame.
-- Returns the new page (1-based) and the row height consumed.
function Kit.pager(x, y, w, page, total, perPage, idPrefix)
local h = math.max(Kit.tapMin(), 30 * Kit.scale)
local bw = 74 * Kit.scale
local pages = math.max(1, math.ceil(total / math.max(1, perPage)))
page = math.floor(Theme.clamp(page or 1, 1, pages))
local gap = 8 * Kit.scale
idPrefix = idPrefix or "pager"
if Kit.button(x, y, bw, h, "< Prev", { kind = "ghost", font = "small",
enabled = page > 1, id = idPrefix .. ":prev" }) then
page = math.max(1, page - 1)
end
if Kit.button(x + bw + gap, y, bw, h, "Next >", { kind = "ghost",
font = "small", enabled = page < pages, id = idPrefix .. ":next" }) then
page = math.min(pages, page + 1)
end
local first = total > 0 and ((page - 1) * perPage + 1) or 0
local last = math.min(total, page * perPage)
local label = ("%d-%d of %d (page %d/%d)"):format(first, last, total, page, pages)
local labelX = x + 2 * bw + 2 * gap + gap
Kit.text("mono", Kit.ellipsize("mono", label, math.max(0, x + w - labelX)),
labelX, y + (h - Kit.textHeight("mono")) / 2, PAL.caption)
return page, h
end
-- Slice helper so callers never hand-roll page arithmetic (and never draw a
-- row that is off the page -- the entire performance claim rests on this).
function Kit.pageBounds(page, total, perPage)
local pages = math.max(1, math.ceil(total / math.max(1, perPage)))
page = math.floor(Theme.clamp(page or 1, 1, pages))
local first = (page - 1) * perPage + 1
local last = math.min(total, page * perPage)
return first, last, page, pages
end
-- How many rows of `rowH` (plus `gap`) fit in `h` pixels. Panels call this
-- to derive perPage from the real viewport instead of a magic number, so a
-- tall window shows more rows and a phone shows fewer -- with no scrolling
-- either way.
function Kit.rowsThatFit(h, rowH, gap, minRows, maxRows)
local per = math.floor((h + (gap or 0)) / math.max(1, rowH + (gap or 0)))
return math.max(minRows or 1, math.min(maxRows or 99, per))
end
-- Mouse wheel over a paginated list turns PAGES. The wheel still has to do
-- something (users expect it), but it moves a bounded page index rather than
-- driving a pixel offset, so there is no scroll state and no interpolation.
function Kit.wheelPage(x, y, w, h, page, total, perPage)
if Kit.blockClicks or (Kit.wheelY or 0) == 0 then return page end
if not Kit.hit(x, y, w, h) then return page end
local pages = math.max(1, math.ceil(total / math.max(1, perPage)))
local moved = Theme.clamp((page or 1) + (Kit.wheelY > 0 and -1 or 1), 1, pages)
Kit.wheelY = 0
return math.floor(moved)
end
-- ------------------------------------------------------------------ spinner
-- The one animated element in the UI: a rotating arc of ticks. Drawn as N
-- short lines at descending alpha, which needs no shader, no canvas and no
-- blend-mode change. `t` defaults to the frame clock so every spinner on
-- screen stays in phase.
function Kit.spinner(cx, cy, r, t)
if not G or not has("line") then return end
t = t or Kit.time
local ticks = 12
local step = (math.pi * 2) / ticks
local head = math.floor((t * 10) % ticks)
if has("setLineWidth") then G.setLineWidth(math.max(2, 2 * Kit.scale)) end
for i = 0, ticks - 1 do
local a = ((ticks - ((i - head) % ticks)) / ticks)
local ang = i * step - math.pi / 2
local c, s = math.cos(ang), math.sin(ang)
Theme.col(PAL.ink, a * a)
G.line(cx + c * r * 0.55, cy + s * r * 0.55, cx + c * r, cy + s * r)
end
if has("setLineWidth") then G.setLineWidth(1) end
end
-- ------------------------------------------------------------------- clip
-- Clip drawing to a rect. A stack: pushes intersect with the rect above and
-- a pop restores that rect rather than clearing the scissor, so a nested
-- region can never unclip its parent. The tracked rect also bounds Kit.hit,
-- so a widget clipped out of view is inert instead of taking taps aimed at
-- whatever is drawn where it left.
local clipStack = {}
local function applyClip(rect)
Kit._clipRect = rect
if not (G and G.setScissor) then return end
if not rect then
G.setScissor()
elseif rect.w <= 0 or rect.h <= 0 then
-- LOVE rejects negative scissor dimensions; an exhausted clip region is
-- empty, not invalid.
G.setScissor(0, 0, 0, 0)
else
G.setScissor(math.floor(rect.x), math.floor(rect.y),
math.ceil(rect.w), math.ceil(rect.h))
end
end
function Kit.pushClip(x, y, w, h)
local prev = clipStack[#clipStack]
local x2, y2 = x + math.max(0, w), y + math.max(0, h)
if prev then
x, y = math.max(x, prev.x), math.max(y, prev.y)
x2 = math.min(x2, prev.x + prev.w)
y2 = math.min(y2, prev.y + prev.h)
end
local rect = { x = x, y = y, w = math.max(0, x2 - x), h = math.max(0, y2 - y) }
clipStack[#clipStack + 1] = rect
applyClip(rect)
end
function Kit.popClip()
clipStack[#clipStack] = nil
applyClip(clipStack[#clipStack])
end
-- A pcall-ed draw that raised mid-clip must not leak the stack into later
-- frames (every hit test would stay fenced to the dead rect), so the frame
-- boundary clears it.
function Kit.resetClip()
for i = #clipStack, 1, -1 do clipStack[i] = nil end
applyClip(nil)
end
return Kit
+115
View File
@@ -0,0 +1,115 @@
-- Shared layout metrics for the launcher and the save editor.
--
-- Both windows derive one `m` table per frame from the real window size and
-- the platform safe area, and every panel lays itself out in explicit pixels
-- off that table. Explicit pixels are the point: the old view expressed
-- widths as "100%" and leaned on a layout engine to resolve them, which is
-- where the launcher's layout bugs lived (percentages resolving against a
-- border box instead of a content box, auto-sized children measuring zero
-- height inside an auto-sized parent, flex-shrink compressing text until it
-- overlapped). None of those failure modes exist when a column is simply
-- `math.floor((contentW - gap) / 2)`.
--
-- REFLOW, not shrink: a narrow window drops to fewer columns rather than
-- scaling the desktop layout down. Scale has a floor (Kit.layout clamps to
-- 0.9) so tap targets and text stay legible on a phone.
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local SafeArea = require("src.core.SafeArea")
local Layout = {}
-- Breakpoints, in safe-area pixels. Named so panels read intent rather than
-- magic numbers.
Layout.BP = {
twoCol = 640, -- side-by-side columns become possible
threeCol = 1100, -- wide desktop: mod list + detail + chrome
}
-- Build the frame's metrics. `maxAppW` caps the content column on an
-- ultrawide monitor so the UI stays a readable measure instead of stretching.
function Layout.metrics(maxAppW)
local W, H = 0, 0
if love and love.graphics and love.graphics.getDimensions then
W, H = love.graphics.getDimensions()
end
local ox, oy, sw, sh = SafeArea.rect()
local s = Kit.layout(sw, sh)
local appW = math.min(sw, (maxAppW or 1200) * s)
local m = {
W = W, H = H, s = s,
x = math.floor(ox + (sw - appW) / 2),
top = math.floor(oy),
w = math.floor(appW),
h = math.floor(sh),
pad = math.floor(Theme.clamp(appW * 0.03, 10, 24)),
gap = math.floor(12 * s),
colGap = math.floor(16 * s),
rowH = math.max(Kit.tapMin(), math.floor(44 * s)),
btnH = math.max(Kit.tapMin(), math.floor(38 * s)),
chip = math.max(Kit.tapMin(), math.floor(40 * s)),
railH = math.max(3, math.floor(4 * s)),
logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84)),
}
m.cols = (appW >= Layout.BP.threeCol * s and 3)
or (appW >= Layout.BP.twoCol * s and 2)
or 1
m.twoCol = m.cols >= 2
m.contentW = m.w - 2 * m.pad
m.colW = m.twoCol
and math.floor((m.contentW - m.colGap) / 2)
or m.contentW
m.contentX = m.x + m.pad
return m
end
-- A vertical cursor for stacking blocks down a column. Immediate mode has
-- no layout pass, so panels advance a y by hand; this makes that explicit
-- and keeps the arithmetic in one place.
local Cursor = {}
Cursor.__index = Cursor
function Layout.cursor(x, y, w)
return setmetatable({ x = x, y = y, w = w, y0 = y }, Cursor)
end
-- Reserve `h` pixels and return the rect that was reserved.
function Cursor:take(h, gapAfter)
local x, y = self.x, self.y
self.y = self.y + h + (gapAfter or 0)
return x, y, self.w, h
end
function Cursor:skip(h)
self.y = self.y + h
end
function Cursor:height()
return self.y - self.y0
end
-- Split the cursor's width into `n` equal columns with `gap` between them,
-- returning a function that yields the i-th column's x and width.
function Layout.columns(x, w, n, gap)
n = math.max(1, n)
local cw = math.floor((w - gap * (n - 1)) / n)
return function(i)
return x + (i - 1) * (cw + gap), cw
end
end
-- Lay a row of buttons out right-aligned within [x, x+w], returning a
-- function that yields each button's x as it is consumed right to left.
function Layout.rightCluster(x, w, gap)
local cursor = x + w
return function(bw)
cursor = cursor - bw
local bx = cursor
cursor = cursor - gap
return bx
end
end
return Layout
+133
View File
@@ -0,0 +1,133 @@
-- 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
+381
View File
@@ -0,0 +1,381 @@
-- High-contrast theme shared by the launcher (src/import/LauncherView.lua)
-- and the save editor (tools/save-editor/). This replaces the old navy
-- gradient look wholesale: black field, white hairline outlines, flat fills,
-- no gradients and no glows anywhere.
--
-- That is not only a visual choice. Every effect this theme drops was a GPU
-- pipeline flush in the old renderer:
-- * gradients needed a stencil pass + a dynamic mesh per card
-- (G.stencil / setStencilTest / draw(mesh) = 3 state changes per card),
-- * glows set blend mode "add", drew 7 stacked rects, then set it back.
-- Flat fills with a 1px outline all share one pipeline state, so LOVE batches
-- an entire panel into a couple of draw calls. Controls do carry a small
-- corner radius and a two-rect emboss, which cost extra vertices but no state
-- change -- that is the tier of expense this theme is willing to pay, and the
-- tier above it (stencils, meshes, blend modes) is the one it will not.
--
-- Emphasis is carried by INVERSION, not by colour weight: a selected or
-- focused control fills white and prints black. That keeps contrast at
-- maximum for accessibility and costs exactly one extra rect.
--
-- Every colour below is 0-255 RGB; alpha is passed per draw call to col().
-- Everything degrades under the headless love_stub used by tests/ (no fonts,
-- no line, no mesh): each primitive probes for what it needs.
local Theme = {}
local PAL = {
-- field + surfaces. Only three fills exist in the whole UI.
bg = { 0, 0, 0 }, -- the page, and every card interior
surface = { 0, 0, 0 }, -- cards/rows: same black, told apart by outline
raised = { 20, 20, 20 }, -- the one non-black fill: hover feedback
ink = { 255, 255, 255 }, -- the selected/focused fill
-- outlines. Two weights only: a hairline for structure, solid for focus.
line = { 255, 255, 255 }, -- hairline, drawn at alpha 0.35
lineStrong = { 255, 255, 255 }, -- focus / selection, drawn at alpha 1
-- text
heading = { 255, 255, 255 },
text = { 255, 255, 255 },
detail = { 200, 200, 200 },
muted = { 150, 150, 150 },
caption = { 170, 170, 170 }, -- letterspaced section captions
faint = { 110, 110, 110 }, -- slot indices, hints
inverse = { 0, 0, 0 }, -- ink on a white (selected/focused) fill
-- semantics. Used for TEXT and OUTLINES only, never as a large fill, so
-- the black/white contrast story is never diluted.
green = { 0, 255, 140 }, -- safe / confirmed / installed
yellow = { 255, 214, 0 }, -- attention / update available
red = { 255, 80, 90 }, -- destructive
blue = { 90, 190, 255 }, -- links, in-panel navigation
steel = { 120, 120, 120 }, -- disabled
-- the tri-colour version rail is the one piece of brand colour that stays
railRed = { 255, 60, 72 },
railBlue = { 70, 150, 255 },
railGold = { 255, 203, 5 },
}
-- Semantic aliases kept so ported call sites read the same as before.
PAL.cardBorder = PAL.line
PAL.rowBg = PAL.surface
PAL.greenInk = PAL.inverse
PAL.blueInk = PAL.blue
PAL.redSoft = PAL.red
PAL.greenDark = PAL.green
Theme.PAL = PAL
-- Standard alphas, so "hairline" means one thing everywhere.
Theme.A = {
hairline = 0.35,
hover = 0.65,
focus = 1.0,
fillHover= 1.0,
disabled = 0.30,
}
local G = love and love.graphics or nil
local has = {}
local function probe(name)
if has[name] == nil then has[name] = (G and type(G[name]) == "function") or false end
return has[name]
end
Theme.probe = probe
function Theme.col(c, a)
if not G then return end
G.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a or 1)
end
local col = Theme.col
function Theme.clamp(n, lo, hi)
if n < lo then return lo end
if n > hi then return hi end
return n
end
local clamp = Theme.clamp
-- --------------------------------------------------------------- primitives
-- Square, flat, snapped to whole pixels. Snapping matters at 1px line width:
-- a rect on a half pixel renders as a 2px grey smear instead of a crisp white
-- hairline, which is the whole look.
local function snap(v) return math.floor(v + 0.5) end
Theme.snap = snap
function Theme.fill(x, y, w, h, c, a)
if not G or w <= 0 or h <= 0 then return end
col(c or PAL.bg, a or 1)
G.rectangle("fill", snap(x), snap(y), snap(w), snap(h))
end
-- Corner radius for controls. Small and fixed: enough to read as a physical
-- key rather than a painted rectangle, small enough that the extra
-- tessellation is noise next to the rest of the frame.
function Theme.radius()
return 4
end
function Theme.fillRounded(x, y, w, h, c, a, r)
if not G or w <= 0 or h <= 0 then return end
r = r or Theme.radius()
col(c or PAL.bg, a or 1)
G.rectangle("fill", snap(x), snap(y), snap(w), snap(h), r, r)
end
function Theme.strokeRounded(x, y, w, h, c, a, lw, r)
if not G or w <= 0 or h <= 0 then return end
lw = lw or 1
r = r or Theme.radius()
if probe("setLineWidth") then G.setLineWidth(lw) end
col(c or PAL.line, a or Theme.A.hairline)
G.rectangle("line", snap(x) + lw / 2, snap(y) + lw / 2,
snap(w) - lw, snap(h) - lw, r, r)
if probe("setLineWidth") then G.setLineWidth(1) end
end
-- EMBOSS. A lit top edge and a shaded bottom edge inside the control, which
-- is what makes a flat fill read as a raised key. Two thin rects on top of
-- the fill -- no gradient mesh, no stencil, no blend-mode change, so it costs
-- the same pipeline state as everything around it.
function Theme.emboss(x, y, w, h, strength)
if not G or w <= 2 or h <= 2 then return end
strength = strength or 1
local t = math.max(1, math.floor(h * 0.10))
local r = Theme.radius()
-- highlight along the top
col(PAL.ink, 0.28 * strength)
G.rectangle("fill", snap(x) + r, snap(y) + 1, snap(w) - 2 * r, t)
-- shadow along the bottom
col(PAL.bg, 0.30 * strength)
G.rectangle("fill", snap(x) + r, snap(y + h) - t - 1, snap(w) - 2 * r, t)
end
-- Faux bold: the UI face ships in one weight, so a bold run is the same text
-- drawn a second time one pixel across. Callers do this only for button
-- labels, where the extra draw is bounded by the number of controls on
-- screen and the text is already a cached Text object.
Theme.BOLD_OFFSET = 1
-- A 1px outline drawn INSIDE the rect, so a bordered control never bleeds
-- into its neighbour's pixel and adjacent outlines never double up to 2px.
function Theme.stroke(x, y, w, h, c, a, lw)
if not G or w <= 0 or h <= 0 then return end
lw = lw or 1
if probe("setLineWidth") then G.setLineWidth(lw) end
col(c or PAL.line, a or Theme.A.hairline)
G.rectangle("line", snap(x) + lw / 2, snap(y) + lw / 2,
snap(w) - lw, snap(h) - lw)
if probe("setLineWidth") then G.setLineWidth(1) end
end
-- The design's only container: black interior, white hairline. `emphasis`
-- raises the outline to full white (used for the focused/active card).
function Theme.card(x, y, w, h, emphasis)
Theme.fill(x, y, w, h, PAL.bg, 1)
Theme.stroke(x, y, w, h, PAL.line, emphasis and Theme.A.focus or Theme.A.hairline, 1)
end
-- A list row. Three states, each one rect plus one outline:
-- normal black fill, hairline
-- hover near-black fill, brighter hairline
-- selected WHITE fill (callers print ink = PAL.inverse over it)
function Theme.row(x, y, w, h, state)
if state == "selected" then
Theme.fill(x, y, w, h, PAL.ink, 1)
return PAL.inverse
end
Theme.fill(x, y, w, h, state == "hover" and PAL.raised or PAL.surface, 1)
Theme.stroke(x, y, w, h, PAL.line,
state == "hover" and Theme.A.hover or Theme.A.hairline, 1)
return PAL.text
end
-- A percentage meter (HP, box fill, dex completion, import progress).
-- pct is 0-100. Outline + solid white fill, no rounding.
function Theme.meter(x, y, w, h, pct, c)
if not G then return end
Theme.stroke(x, y, w, h, PAL.line, Theme.A.hairline, 1)
local fill = (w - 2) * clamp((pct or 0) / 100, 0, 1)
if fill > 0 then Theme.fill(x + 1, y + 1, fill, h - 2, c or PAL.ink, 1) end
end
-- The 4px tri-colour rail across the top of both windows: the only brand
-- colour on screen, and the one thing that says "this is the Gen 1 launcher".
function Theme.versionRail(x, y, w, h)
if not G then return end
local bars = { PAL.railRed, PAL.railBlue, PAL.railGold }
local seg = w / 3
for i, c in ipairs(bars) do
Theme.fill(x + (i - 1) * seg, y, seg, h, c, 1)
end
end
-- ------------------------------------------------------------------- text
-- Letterspaced caption text. The UI font has no tracking control, so this
-- advances glyph by glyph; captions are short by construction.
-- Measuring never throws. Third-party strings (mod names from an index,
-- translated captions) reach these primitives unvalidated.
local function safeWidthOrZero(font, s)
local ok, w = pcall(font.getWidth, font, s)
return ok and w or 0
end
-- Steps CODEPOINTS, not bytes: a translated caption (the JP strings) is
-- multi-byte, and printing half a sequence is a "UTF-8 decoding error" that
-- takes the frame down.
local function eachChar(text, fn)
local i = 1
local n = #text
while i <= n do
local j = i + 1
while j <= n do
local b = text:byte(j)
if b < 0x80 or b >= 0xC0 then break end
j = j + 1
end
fn(text:sub(i, j - 1))
i = j
end
end
function Theme.spaced(font, text, x, y, spacing)
if not G or not font then return 0 end
local cx = x
eachChar(tostring(text), function(ch)
pcall(G.print, ch, cx, y)
cx = cx + safeWidthOrZero(font, ch) + spacing
end)
return math.max(0, cx - x - spacing)
end
function Theme.spacedWidth(font, text, spacing)
if not font then return 0 end
local w = 0
eachChar(tostring(text), function(ch)
w = w + safeWidthOrZero(font, ch) + spacing
end)
return math.max(0, w - spacing)
end
-- UTF-8 stepping. Truncation MUST move whole codepoints: LOVE's Font:getWidth
-- raises "UTF-8 decoding error" on a string cut through a multi-byte sequence,
-- and a launcher listing mods with non-ASCII names (the JP index) hits that on
-- the first frame. A continuation byte is 10xxxxxx (0x80..0xBF).
local function prevCharStart(s, i)
-- largest j < i where s:byte(j) starts a codepoint
local j = i - 1
while j > 1 do
local b = s:byte(j)
if b < 0x80 or b >= 0xC0 then break end
j = j - 1
end
return j
end
local function nextCharStart(s, i)
local j = i + 1
while j <= #s do
local b = s:byte(j)
if b < 0x80 or b >= 0xC0 then break end
j = j + 1
end
return j
end
-- Width that never throws on malformed input: a mod name can carry anything.
local function safeWidth(font, s)
local ok, w = pcall(font.getWidth, font, s)
return ok and w or math.huge
end
Theme.safeWidth = safeWidth
-- Clip text to a pixel width with a trailing ellipsis. Results are memoised
-- per (font, text, width) in Kit's measurement cache -- this function is the
-- single hottest string operation in a list-heavy frame, and it is O(n) in
-- glyphs with a getWidth call per step.
function Theme.ellipsize(font, text, maxW)
text = tostring(text or "")
if not font then return text end
-- A non-positive budget means "nothing fits", not "everything fits".
if maxW <= 0 then return "" end
if safeWidth(font, text) <= maxW then return text end
local ell = "..."
local ew = safeWidth(font, ell)
local last = #text + 1 -- one past the end of the kept prefix
while last > 1 do
last = prevCharStart(text, last)
local head = text:sub(1, last - 1)
if safeWidth(font, head) + ew <= maxW then return head .. ell end
end
return ell
end
-- Save paths truncate from the LEFT so the filename survives.
function Theme.ellipsizeLeft(font, text, maxW)
text = tostring(text or "")
if not font then return text end
if maxW <= 0 then return "" end
if safeWidth(font, text) <= maxW then return text end
local ell = "..."
local ew = safeWidth(font, ell)
local i = 1
while i <= #text do
i = nextCharStart(text, i)
local tail = text:sub(i)
if safeWidth(font, tail) + ew <= maxW then return ell .. tail end
end
return ell
end
-- The background: a flat black clear. One call, no mesh, no fan, no
-- allocation -- the old radial field built a 66-vertex mesh EVERY frame.
function Theme.field()
if not G then return end
G.clear(0, 0, 0, 1)
end
-- ------------------------------------------------------------------- fonts
-- Font set, rebuilt only when the scale changes. Sizes are integers by
-- construction: fractional sizes measure and render at different widths,
-- which is what made ported launcher text overrun its measured box.
function Theme.fonts(s)
if not probe("newFont") then return {} end
-- Every face goes through UiFont.attach, which hangs a kana/CJK fallback
-- off it. Without that a translated build renders the entire launcher as
-- tofu boxes -- LOVE's default face is Latin-only.
local UiFont
local okUi, mod = pcall(require, "src.render.UiFont")
if okUi then UiFont = mod end
local cache = {}
local function f(px)
local n = math.max(8, math.floor(px + 0.5))
if not cache[n] then
local face = G.newFont(n)
if UiFont and UiFont.attach then
local ok, attached = pcall(UiFont.attach, face, n)
if ok and attached then face = attached end
end
cache[n] = face
end
return cache[n]
end
return {
scale = s,
wordmark = f(14 * s),
brand = f(11 * s),
chip = f(11 * s),
tile = f(13 * s),
tab = f(13 * s),
button = f(14 * s),
small = f(12 * s),
tiny = f(11 * s),
micro = f(10 * s),
caption = f(12 * s),
mono = f(12 * s),
monoRow = f(13 * s),
monoBig = f(18 * s),
title = f(24 * s),
headline = f(26 * s),
stat = f(19 * s),
}
end
return Theme