mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
508 lines
18 KiB
Lua
508 lines
18 KiB
Lua
-- High-contrast theme for the launcher (src/import/LauncherView.lua). The
|
|
-- save editor keeps its own tools/save-editor/Theme.lua, whose primitives
|
|
-- take a radius where these take a colour -- do not cross-wire them. This
|
|
-- replaces the old navy gradient look wholesale: a near-black field with the
|
|
-- faintest red cast, cards a few values above it, 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. What this theme DOES pay for
|
|
-- is extra vertices at the same pipeline state: rounded corners, the
|
|
-- two-rect emboss on a control, and the three stacked rounded rects that make
|
|
-- a card's drop shadow (Theme.shadow). Vertices are the tier of expense this
|
|
-- theme is willing to pay; the tier above it -- stencils, meshes, blend-mode
|
|
-- changes, canvases, shaders -- is the one it will not, and a shadow drawn as
|
|
-- a blurred canvas would land squarely in it.
|
|
--
|
|
-- 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. The field carries a FAINT red cast (a few points of
|
|
-- red over an otherwise neutral near-black) and cards sit a few steps above
|
|
-- it in the same hue, so a card reads as a raised object rather than as an
|
|
-- outline drawn on the page. These are still flat fills -- the depth comes
|
|
-- from the value step plus Theme.shadow, not from a gradient.
|
|
field = { 20, 20, 20 }, -- the page BEHIND the cards
|
|
bg = { 20, 20, 20 }, -- light black
|
|
surface = { 28, 21, 24 }, -- card interiors
|
|
rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it
|
|
raised = { 44, 34, 38 }, -- 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 version rail is the one piece of brand colour that stays
|
|
railRed = { 255, 60, 72 },
|
|
railBlue = { 70, 150, 255 },
|
|
railGold = { 255, 203, 5 }, -- Yellow cartridge (bright)
|
|
railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal)
|
|
}
|
|
-- Semantic aliases kept so ported call sites read the same as before.
|
|
PAL.cardBorder = PAL.line
|
|
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,
|
|
}
|
|
|
|
Theme.BUTTON = {
|
|
radius = 8,
|
|
ringPad = 2,
|
|
ringWidth = 2,
|
|
labelInset = 16,
|
|
labelPad = 10,
|
|
iconPad = 0.24,
|
|
letterGap = 4,
|
|
disabledA = 0.45,
|
|
embossHot = 1.3,
|
|
embossRest = 1,
|
|
embossDisabled = 0.4,
|
|
glowHz = 3,
|
|
glowBase = 0.25,
|
|
glowAmp = 0.75,
|
|
iconRestA = 0.85,
|
|
}
|
|
|
|
Theme.CARD = {
|
|
radius = 14,
|
|
shadow = true,
|
|
fill = PAL.surface,
|
|
fillA = 1,
|
|
stroke = PAL.line,
|
|
strokeA = Theme.A.hairline,
|
|
}
|
|
|
|
Theme.CARD_VARIANT = {
|
|
emphasis = { strokeA = Theme.A.focus },
|
|
muted = {
|
|
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = 0.25, shadow = false,
|
|
},
|
|
mutedHot = {
|
|
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = Theme.A.hover, shadow = false,
|
|
},
|
|
empty = { fillA = 0, strokeA = 0.22, shadow = false, radius = "ctl" },
|
|
warn = {
|
|
fill = PAL.rowBg, stroke = PAL.yellow, strokeA = Theme.A.hover,
|
|
shadow = false, radius = "ctl",
|
|
},
|
|
row = {
|
|
fill = PAL.rowBg, stroke = PAL.line, strokeA = Theme.A.hairline,
|
|
shadow = false, radius = "ctl",
|
|
},
|
|
rowHover = {
|
|
fill = PAL.raised, stroke = PAL.line, strokeA = Theme.A.hover,
|
|
shadow = false, radius = "ctl",
|
|
},
|
|
rowSelected = {
|
|
fill = PAL.ink, strokeA = 0, shadow = false, radius = "ctl",
|
|
},
|
|
hairline = {
|
|
fillA = 0, stroke = PAL.line, strokeA = Theme.A.hairline,
|
|
shadow = false, radius = "ctl",
|
|
},
|
|
badge = {
|
|
fill = PAL.bg, stroke = PAL.yellow, strokeA = Theme.A.hover,
|
|
shadow = false, radius = 0,
|
|
},
|
|
}
|
|
|
|
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. Fixed rather than scaled: LOVE tessellates a
|
|
-- rounded rect by radius, so a scale-driven radius would change the vertex
|
|
-- count with the window size, and these are the two tiers the design needs.
|
|
-- Controls get the smaller one, containers the larger, so a button never
|
|
-- looks like a card and a card never looks like a button.
|
|
function Theme.radius()
|
|
return Theme.BUTTON.radius
|
|
end
|
|
|
|
function Theme.cardRadius()
|
|
return Theme.CARD.radius
|
|
end
|
|
|
|
-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider
|
|
-- and one step lower than the last -- a cheap falloff that needs no blur, no
|
|
-- canvas and no blend-mode change, so it stays inside the pipeline budget the
|
|
-- rest of this file is written to. Drawn BEFORE the surface it belongs to,
|
|
-- and never for a control (only containers cast one, or the whole screen
|
|
-- reads as floating debris).
|
|
function Theme.shadow(x, y, w, h, r)
|
|
if not G or w <= 0 or h <= 0 then return end
|
|
r = r or Theme.cardRadius()
|
|
for i = 1, 3 do
|
|
local spread = i * 2
|
|
col(PAL.bg, 0.13)
|
|
G.rectangle("fill", snap(x - spread), snap(y - spread + i * 3),
|
|
snap(w + 2 * spread), snap(h + 2 * spread), r + spread, r + spread)
|
|
end
|
|
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))
|
|
-- The inset must clear the corner arc, but a narrow control (a stepper, a
|
|
-- row chip) is thinner than two radii -- clamp or the highlight rect goes
|
|
-- negative-width and vanishes.
|
|
local r = math.min(Theme.radius(), math.floor(w / 3))
|
|
-- 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: a rounded surface a few values above the
|
|
-- field, its own drop shadow, and a white hairline. `emphasis` raises the
|
|
-- outline to full white (used for the focused/active card).
|
|
function Theme.card(x, y, w, h, variant)
|
|
local spec = Theme.CARD
|
|
local v
|
|
if variant == true then
|
|
v = Theme.CARD_VARIANT.emphasis
|
|
elseif type(variant) == "string" then
|
|
v = Theme.CARD_VARIANT[variant]
|
|
elseif type(variant) == "table" then
|
|
v = variant
|
|
end
|
|
local radius = (v and v.radius) or spec.radius
|
|
if radius == "ctl" then radius = Theme.BUTTON.radius end
|
|
local fill = (v and v.fill) or spec.fill
|
|
local fillA = spec.fillA
|
|
if v and v.fillA ~= nil then fillA = v.fillA end
|
|
local stroke = (v and v.stroke) or spec.stroke
|
|
local strokeA = spec.strokeA
|
|
if v and v.strokeA ~= nil then strokeA = v.strokeA end
|
|
local shadow = spec.shadow
|
|
if v and v.shadow ~= nil then shadow = v.shadow end
|
|
local strokeW = (v and v.strokeW) or 1
|
|
if fillA > 0 and fill then
|
|
if shadow then Theme.shadow(x, y, w, h, radius) end
|
|
Theme.fillRounded(x, y, w, h, fill, fillA, radius)
|
|
end
|
|
if strokeA > 0 and stroke then
|
|
Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeW, radius)
|
|
end
|
|
end
|
|
|
|
function Theme.row(x, y, w, h, state)
|
|
if state == "selected" then
|
|
Theme.card(x, y, w, h, "rowSelected")
|
|
return PAL.inverse
|
|
end
|
|
Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row")
|
|
return PAL.text
|
|
end
|
|
|
|
-- A percentage meter (HP, box fill, dex completion, import progress).
|
|
-- pct is 0-100. Outline + solid fill, rounded to the track's own half-height
|
|
-- so a thin bar reads as a capsule instead of a clipped rectangle.
|
|
function Theme.meter(x, y, w, h, pct, c)
|
|
if not G then return end
|
|
local r = math.min(Theme.radius(), h / 2)
|
|
Theme.strokeRounded(x, y, w, h, PAL.line, Theme.A.hairline, 1, r)
|
|
local fill = (w - 2) * clamp((pct or 0) / 100, 0, 1)
|
|
if fill > 0 then
|
|
Theme.fillRounded(x + 1, y + 1, fill, h - 2, c or PAL.ink, 1,
|
|
math.min(r, fill / 2))
|
|
end
|
|
end
|
|
|
|
-- The 4px version rail across the top of both windows: the only brand
|
|
-- colour on screen (Red / Blue / Yellow / Gold cartridge colours).
|
|
function Theme.versionRail(x, y, w, h)
|
|
if not G then return end
|
|
local bars = { PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber }
|
|
local seg = w / #bars
|
|
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: one flat clear to the faintly red-cast field colour. One
|
|
-- call, no mesh, no fan, no allocation -- the old radial field built a
|
|
-- 66-vertex mesh EVERY frame. The tint is deliberately small (a handful of
|
|
-- points of red at near-black): enough that the cards read as sitting ON
|
|
-- something, not enough to compete with the tri-colour rail for brand duty.
|
|
function Theme.field()
|
|
if not G then return end
|
|
G.clear(PAL.field[1] / 255, PAL.field[2] / 255, PAL.field[3] / 255, 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
|