Files
gen1recomp/src/ui/kit/Button.lua
T
2026-10-06 11:00:16 -04:00

349 lines
12 KiB
Lua

-- Shared button faces for launcher and save-editor input adapters.
-- The owner supplies fonts, pointer state and text drawing; this module only paints.
local Theme = require("src.ui.kit.Theme")
local PAL = Theme.PAL
local Icons = require("src.ui.kit.Icons")
local Button = {}
local KINDS = {
primary = { fill = PAL.green, ink = PAL.inverse },
good = { fill = PAL.green, ink = PAL.inverse },
accent = { fill = PAL.buttonBlue, ink = PAL.heading },
warn = { fill = PAL.yellow, ink = PAL.inverse },
danger = { fill = PAL.surface, ink = PAL.red, stroke = PAL.red },
ghost = { fill = PAL.surface, ink = PAL.text, stroke = PAL.line },
disabled = { fill = PAL.surface, ink = PAL.muted, stroke = PAL.line },
}
Button.KINDS = KINDS
-- Complete labels wrap between words. The input adapter sizes the control
-- from these same metrics; the painter never substitutes an ellipsis.
function Button.labelLayout(Kit, w, h, label, opts)
local name = opts.font or "button"
local lineH = Kit.textHeight(name)
local pad = Theme.BUTTON.labelInset * Kit.scale / 2
local trailing = opts.trailingIcon and h * 0.65 or 0
local available = w - 2 * pad - trailing
local function wrap(width)
local lines, line, widest = {}, "", 0
for word in tostring(label):gmatch("%S+") do
local nextLine = line == "" and word or (line .. " " .. word)
if line ~= "" and Kit.textWidth(name, nextLine) > width then
lines[#lines + 1] = line
widest = math.max(widest, Kit.textWidth(name, line))
line = word
else
line = nextLine
end
end
if line ~= "" then
lines[#lines + 1] = line
widest = math.max(widest, Kit.textWidth(name, line))
end
return lines, widest
end
local lines, widest = wrap(available)
local mode, size, gap
if opts.icon then
size = math.floor(math.min(w, h) * 0.42)
gap = math.floor(7 * Kit.scale)
local sideW = available - size - gap
local sideLines, sideWidth = wrap(sideW)
if not opts.iconStack and sideWidth <= sideW and #sideLines * lineH <= h - 4 * Kit.scale then
lines, widest, available, mode = sideLines, sideWidth, sideW, "side"
elseif size + math.max(2, 3 * Kit.scale) + #lines * lineH <= h - 4 * Kit.scale then
mode, gap = "stack", math.max(2, 3 * Kit.scale)
end
end
return {
font = name,
lines = lines,
width = widest,
available = available,
pad = pad,
trailing = trailing,
lineH = lineH,
height = #lines * lineH + (mode == "stack" and size + gap or 0),
mode = mode,
iconSize = size,
gap = gap,
}
end
function Button.draw(Kit, x, y, w, h, label, opts, hot, focused)
local G = love and love.graphics
local enabled = opts.enabled ~= false
local face = opts.face or "fill"
local B = Theme.BUTTON
local radius = opts.radius or B.radius
local segments = opts.segments
local active = opts.active or opts.on
local invert = false
local fill, ink, stroke, strokeA, strokeWidth, doEmboss, doRing, glowA
if face == "bare" then
ink = opts.ink or PAL.heading
doRing = focused
elseif face == "selection" then
-- A selected control keeps its geometry, dark face and label layout.
-- Selection is an inset accent outline, never a filled keycap.
fill = PAL.surface
ink = PAL.text
stroke = (active or focused) and PAL.blue or PAL.line
strokeA = (active or focused) and Theme.A.focus or (hot and Theme.A.hover or Theme.A.hairline)
strokeWidth = (active or focused) and 2 or 1
doRing = focused or hot
elseif face == "invert" then
invert = false
fill = (hot or focused) and PAL.raised or (opts.fill or PAL.surface)
ink = opts.ink or PAL.heading
stroke = opts.stroke or PAL.line
strokeA = (hot or focused) and Theme.A.focus or Theme.A.hairline
doRing = focused or hot
elseif face == "tab" then
invert = active and true or false
local tint = opts.color or opts.fill or PAL.ink
fill = invert and tint or PAL.surface
ink = invert and PAL.inverse or (opts.color or PAL.text)
if not invert then
stroke = tint
strokeA = (focused or hot) and Theme.A.focus
or (opts.color and Theme.A.hover or Theme.A.hairline)
end
doRing = focused or hot
elseif face == "chip" then
local c = opts.color or PAL.line
invert = active and true or false
if active then
fill = c
ink = PAL.inverse
doEmboss = true
else
fill = PAL.bg
ink = c
stroke = c
strokeA = (focused or hot) and Theme.A.focus or Theme.A.hover
end
doRing = focused or hot
else
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
fill = (enabled and opts.fill) or kind.fill
ink = (enabled and opts.ink) or kind.ink
doEmboss = (opts.kind == "primary" or opts.kind == "good" or opts.kind == "accent")
stroke = kind.stroke
strokeA = stroke and Theme.A.hover or nil
doRing = hot or focused
if opts.glow and enabled and not doRing then
glowA = B.glowBase + B.glowAmp * (0.5 + 0.5 * math.sin(Kit.time * B.glowHz))
end
end
if opts.emboss ~= nil then
doEmboss = opts.emboss
end
if opts.ring ~= nil then
doRing = opts.ring
end
if G then
if face ~= "bare" then
Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius, segments)
end
if doEmboss then
local es = enabled and ((hot or focused) and B.embossHot or B.embossRest) or B.embossDisabled
Theme.emboss(x, y, w, h, es)
end
if strokeA then
Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeWidth or 1, radius, segments)
end
if doRing then
Theme.fillRounded(
x,
y,
w,
h,
PAL.ink,
Kit.mouseDown and hot and 0.04 or 0.09,
radius,
segments
)
if face ~= "selection" then
Theme.strokeRounded(
x,
y,
w,
h,
PAL.ink,
focused and 1 or 0.7,
focused and 2 or 1,
radius,
segments
)
end
elseif glowA then
Theme.strokeRounded(
x - B.ringPad,
y - B.ringPad,
w + 2 * B.ringPad,
h + 2 * B.ringPad,
PAL.lineStrong,
glowA,
B.ringWidth,
radius + B.ringPad,
segments
)
end
local fname = opts.font or ((face == "chip") and "micro" or "button")
local ty = y + (h - Kit.textHeight(fname)) / 2
local image = opts.image
local drawFn = opts.drawFn
local letter = opts.letter
local hasLabel = label and label ~= ""
local bold = opts.bold
if bold == nil then
bold = face ~= "tab" and face ~= "selection"
end
if opts.fullLabel and hasLabel then
local layout = opts.labelLayout or Button.labelLayout(Kit, w, h, label, opts)
local lx, ly = x + layout.pad, y + (h - #layout.lines * layout.lineH) / 2
if layout.mode == "side" then
local groupW = layout.iconSize + layout.gap + layout.width
local ix = opts.align == "left" and lx or x + (w - layout.trailing - groupW) / 2
Icons.draw(
opts.icon,
ix,
y + (h - layout.iconSize) / 2,
layout.iconSize,
ink,
enabled and 1 or B.disabledA
)
lx = ix + layout.iconSize + layout.gap
elseif layout.mode == "stack" then
local top = y + (h - layout.height) / 2
Icons.draw(
opts.icon,
x + (w - layout.trailing - layout.iconSize) / 2,
top,
layout.iconSize,
ink,
enabled and 1 or B.disabledA
)
ly = top + layout.iconSize + layout.gap
end
for i, line in ipairs(layout.lines) do
local lineY = ly + (i - 1) * layout.lineH
if layout.mode == "side" or opts.align == "left" then
if bold then
Kit.textBold(layout.font, line, lx, lineY, ink)
else
Kit.text(layout.font, line, lx, lineY, ink)
end
elseif bold then
Kit.textCenterBold(layout.font, line, lx, lineY, layout.available, ink)
else
Kit.textCenter(layout.font, line, lx, lineY, layout.available, ink)
end
end
elseif opts.icon and opts.iconStack then
local size = math.floor(math.min(w * 0.5, h * 0.4))
local labelH = Kit.textHeight(fname)
local gap = math.max(2, math.floor(3 * Kit.scale))
local top = y + (h - size - gap - labelH) / 2
Icons.draw(opts.icon, x + (w - size) / 2, top, size, ink, enabled and 1 or B.disabledA)
local shown = Kit.ellipsize(fname, label or "", w - B.labelInset * Kit.scale)
Kit.textCenter(fname, shown, x, top + size + gap, w, ink)
elseif opts.icon then
local box = math.min(w, h)
local size = math.floor(box * (hasLabel and 0.42 or 0.5) * (opts.iconScale or 1))
local gap = math.floor(7 * Kit.scale)
local shown = hasLabel
and Kit.ellipsize(
fname,
label,
w - size - gap - 2 * B.labelPad * Kit.scale - (opts.trailingIcon and h * 0.65 or 0)
)
or ""
local groupW = size + (hasLabel and gap + Kit.textWidth(fname, shown) or 0)
local ix = opts.align == "left" and x + B.labelPad * Kit.scale or x + (w - groupW) / 2
Icons.draw(opts.icon, ix, y + (h - size) / 2, size, ink, enabled and 1 or B.disabledA)
if hasLabel then
if bold then
Kit.textBold(fname, shown, ix + size + gap, ty, ink)
else
Kit.text(fname, shown, ix + size + gap, ty, ink)
end
end
elseif image then
local box = h
local boxX, boxY = x, y
if not hasLabel then
box = math.min(w, h)
boxX = x + (w - box) / 2
boxY = y + (h - box) / 2
end
local iw, ih = image:getDimensions()
local pad = math.floor(box * (opts.iconPad or B.iconPad))
local s = math.min((box - 2 * pad) / iw, (box - 2 * pad) / ih)
Theme.col(ink, enabled and 1 or B.disabledA)
G.draw(
image,
Theme.snap(boxX + (box - iw * s) / 2),
Theme.snap(boxY + (box - ih * s) / 2),
0,
s,
s
)
if hasLabel then
local lx = x + h + B.letterGap * Kit.scale
if bold then
Kit.textBold(fname, label, lx, ty, ink)
else
Kit.text(fname, label, lx, ty, ink)
end
end
elseif drawFn then
drawFn(x, y, w, h, invert)
elseif letter then
if opts.letterBold then
Kit.textCenterBold(fname, letter, x, ty, h, ink)
else
Kit.textCenter(fname, letter, x, ty, h, ink)
end
if hasLabel then
local lx = x + h + B.letterGap * Kit.scale
if bold then
Kit.textBold(fname, label, lx, ty, ink)
else
Kit.text(fname, label, lx, ty, ink)
end
end
elseif hasLabel then
local shown = Kit.ellipsize(
fname,
label,
w - B.labelInset * Kit.scale - (opts.trailingIcon and h * 0.65 or 0)
)
if opts.align == "left" then
local lx = x + B.labelPad * Kit.scale
if bold then
Kit.textBold(fname, shown, lx, ty, ink)
else
Kit.text(fname, shown, lx, ty, ink)
end
elseif bold then
Kit.textCenterBold(fname, shown, x, ty, w, ink)
else
Kit.textCenter(fname, shown, x, ty, w, ink)
end
end
end
if opts.trailingIcon and G then
local size = math.floor(h * 0.4)
Icons.draw(
opts.trailingIcon,
x + w - size - B.labelPad * Kit.scale,
y + (h - size) / 2,
size,
ink,
enabled and 1 or B.disabledA
)
end
end
return Button