Closes #919, closes #982, closes #1003, closes #1012, closes #1022, closes #1028, closes #1033

This commit is contained in:
bryanthaboi
2026-08-14 10:05:51 -04:00
parent 2010ba71a6
commit a94fecfec8
48 changed files with 1263 additions and 541 deletions
+128 -78
View File
@@ -368,6 +368,7 @@ Kit._navPrevN = 0
Kit._navSeen = {}
Kit._navQueue = nil
Kit._activateId = nil
Kit._ringShown = false
-- Register a focusable. Returns true when it currently holds the ring.
-- Shielded widgets do not register: while a modal owns the frame the ring
@@ -383,11 +384,12 @@ function Kit.focusable(id, x, y, w, h)
-- 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
return Kit._ringShown and Kit.focusId == id
end
function Kit.navigate(dir)
Kit._navQueue = dir
Kit._ringShown = true
end
function Kit.activateFocused()
@@ -396,6 +398,7 @@ end
function Kit.setFocus(id)
Kit.focusId = id
Kit._ringShown = id ~= nil
end
-- Pick the nearest focusable in `dir` from the current one. Candidates must
@@ -530,13 +533,10 @@ Kit._audit = audit
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)
function Kit.card(x, y, w, h, variant)
Theme.card(x, y, w, h, variant)
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
@@ -560,7 +560,7 @@ end
-- hairline says the same thing for one rect.)
function Kit.emptyBox(x, y, w, h, message)
if not G then return end
Theme.strokeRounded(x, y, w, h, PAL.line, 0.22, 1, Theme.radius())
Theme.card(x, y, w, h, "empty")
Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale),
x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted)
end
@@ -598,61 +598,130 @@ local KINDS = {
disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true },
}
Kit.KINDS = KINDS
local NO_OPTS = {}
-- opts: { kind, font, enabled, align, id, glow, fill, ink }
-- 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.
-- fill/ink -- override the kind's colours. The ONE caller is the
-- launcher's Play button, which wears its cartridge colour
-- (red/blue/gold) rather than a semantic one: on that screen
-- "which game am I launching" outranks "what kind of verb is
-- this", and the colour is already the tab's identity.
-- 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 {}
opts = opts or NO_OPTS
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"]
if enabled and opts.fill then
kind = { fill = opts.fill, ink = opts.ink or PAL.inverse }
end
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)
local face = opts.face or "fill"
local B = Theme.BUTTON
local radius = opts.radius or B.radius
local active = opts.active or opts.on
local invert = false
local fill, ink, stroke, strokeA, doEmboss, doRing, glowA
if face == "invert" then
invert = hot
fill = invert and (opts.hotFill or PAL.ink) or (opts.fill or PAL.surface)
ink = invert and (opts.hotInk or PAL.inverse) or (opts.ink or PAL.heading)
stroke = opts.stroke or PAL.line
strokeA = invert and Theme.A.focus or Theme.A.hairline
doRing = focused and not hot
elseif face == "tab" then
invert = active or focused or hot
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 = opts.color and Theme.A.hover or Theme.A.hairline
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)
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
Kit.textCenterBold(fname, shown, x, ty, w, ink)
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 = true
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
Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius)
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, 1, radius)
end
if doRing then
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
Theme.A.focus, B.ringWidth, radius + B.ringPad)
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)
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" end
if 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)
if invert then Theme.col(PAL.inverse, 1)
else Theme.col(PAL.ink, B.iconRestA) end
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 or hot or focused)
elseif letter then
Kit.textCenter(fname, letter, x, ty, h, ink)
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)
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 not enabled then return false end
@@ -661,7 +730,6 @@ function Kit.button(x, y, w, h, label, opts)
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"
@@ -669,31 +737,13 @@ function Kit.stepper(x, y, w, h, glyph, opts)
return Kit.button(x, y, w, h, glyph, opts)
end
-- A pill toggle (badges, dex SEEN/OWN, sub-tabs). `on` inverts it.
local CHIP_OPTS = { face = "chip", font = "micro" }
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)
CHIP_OPTS.active = on
CHIP_OPTS.color = color
CHIP_OPTS.id = id
return Kit.button(x, y, w, h, label, CHIP_OPTS)
end
-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE"
+95 -20
View File
@@ -35,8 +35,8 @@ local PAL = {
-- 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 = { 16, 8, 10 }, -- the page BEHIND the cards
bg = { 0, 0, 0 }, -- true black: button rests, field interiors
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
@@ -82,6 +82,67 @@ Theme.A = {
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 = {}
@@ -123,11 +184,11 @@ end
-- 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 8
return Theme.BUTTON.radius
end
function Theme.cardRadius()
return 14
return Theme.CARD.radius
end
-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider
@@ -206,28 +267,42 @@ 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, emphasis)
local r = Theme.cardRadius()
Theme.shadow(x, y, w, h, r)
Theme.fillRounded(x, y, w, h, PAL.surface, 1, r)
Theme.strokeRounded(x, y, w, h, PAL.line,
emphasis and Theme.A.focus or Theme.A.hairline, 1, r)
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
-- A list row. Three states, each one rect plus one outline:
-- normal one value below the card it sits in, hairline
-- hover lifted fill, brighter hairline
-- selected WHITE fill (callers print ink = PAL.inverse over it)
function Theme.row(x, y, w, h, state)
local r = Theme.radius()
if state == "selected" then
Theme.fillRounded(x, y, w, h, PAL.ink, 1, r)
Theme.card(x, y, w, h, "rowSelected")
return PAL.inverse
end
Theme.fillRounded(x, y, w, h,
state == "hover" and PAL.raised or PAL.rowBg, 1, r)
Theme.strokeRounded(x, y, w, h, PAL.line,
state == "hover" and Theme.A.hover or Theme.A.hairline, 1, r)
Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row")
return PAL.text
end