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"