mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 11:44:42 +02:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user