mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
e16a45d6ba
On compact Android viewports the item picker can end up with no list space. Passing its negative height to setScissor crashes LÖVE. Use an empty clip region instead.
403 lines
14 KiB
Lua
403 lines
14 KiB
Lua
-- Immediate-mode widget kit for the save editor, drawn in the launcher's
|
|
-- visual language (see Theme.lua and SaveEditor.dc.html).
|
|
--
|
|
-- Call Kit.beginFrame(mx, my, clicked) once per love.draw() before any widget
|
|
-- and Kit.endFrame() after the last one; widgets read the frame's mouse state
|
|
-- to decide hover / click, and endFrame retires the text-input queue so a
|
|
-- keystroke is never applied twice.
|
|
--
|
|
-- Hit testing is a plain rect with no z-order, so panels must draw
|
|
-- overlapping controls in dispatch order and every target is >= 26px tall
|
|
-- (rule 6 of the design spec) -- that sizing is the whole accessibility story
|
|
-- here.
|
|
|
|
local Theme = require("Theme")
|
|
local PAL = Theme.PAL
|
|
|
|
local Kit = {}
|
|
Kit.mouseX, Kit.mouseY = 0, 0
|
|
Kit.mouseClicked = false -- left button pressed this frame
|
|
Kit.focus = nil -- id of the text field receiving keystrokes
|
|
Kit.time = 0
|
|
Kit.fonts = {}
|
|
Kit.scale = 1
|
|
|
|
local G = love and love.graphics or nil
|
|
local edits = {} -- queued textinput / backspace since the last frame
|
|
|
|
local function canPrintf()
|
|
return G and type(G.printf) == "function"
|
|
end
|
|
|
|
function Kit.beginFrame(mx, my, clicked)
|
|
Kit.mouseX, Kit.mouseY = mx, my
|
|
Kit.mouseClicked = clicked
|
|
if love and love.timer and love.timer.getTime then
|
|
Kit.time = love.timer.getTime()
|
|
end
|
|
end
|
|
|
|
-- Retire this frame's keystrokes. 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
|
|
end
|
|
|
|
-- Rebuild the font set when the window size changes. `s` matches the
|
|
-- launcher's height/768 scale so both windows step together.
|
|
function Kit.layout(width, height)
|
|
local s = Theme.clamp(height / 768, 0.7, 1.6)
|
|
local key = ("%dx%d"):format(width, height)
|
|
if Kit._fontKey ~= key then
|
|
Kit._fontKey = key
|
|
Kit.fonts = Theme.fonts(s)
|
|
Kit.scale = s
|
|
end
|
|
return s
|
|
end
|
|
|
|
-- ------------------------------------------------------------ input plumbing
|
|
-- App forwards love.textinput / love.keypressed here so Kit.textfield can be a
|
|
-- real editable field. Events arrive before draw, so they queue and the
|
|
-- focused field drains them while it renders.
|
|
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 by the focused field, so App can
|
|
-- leave its own shortcuts alone while the user is typing.
|
|
function Kit.keypressed(key)
|
|
if not Kit.focus then return false end
|
|
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
|
|
-- other keys (arrows, shortcuts) fall through to App while a field is hot
|
|
return false
|
|
end
|
|
|
|
function Kit.blur() Kit.focus = nil end
|
|
|
|
-- ------------------------------------------------------------- hit testing
|
|
function Kit.hit(x, y, w, h)
|
|
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)
|
|
return Kit.mouseClicked and Kit.hit(x, y, w, h)
|
|
end
|
|
|
|
-- ------------------------------------------------------------------- text
|
|
local function font(name)
|
|
return Kit.fonts[name] or Kit.fonts.small
|
|
end
|
|
|
|
function Kit.text(name, str, x, y, c, a)
|
|
if not G then return 0 end
|
|
local f = font(name)
|
|
if not f then return 0 end
|
|
G.setFont(f)
|
|
Theme.col(c or PAL.text, a or 1)
|
|
G.print(tostring(str), x, y)
|
|
return f:getWidth(tostring(str))
|
|
end
|
|
|
|
function Kit.textRight(name, str, x2, y, c, a)
|
|
local f = font(name)
|
|
if not f then return end
|
|
Kit.text(name, str, x2 - f:getWidth(tostring(str)), y, c, a)
|
|
end
|
|
|
|
function Kit.textCenter(name, str, x, y, w, c, a)
|
|
if not G then return end
|
|
local f = font(name)
|
|
if not f then return end
|
|
G.setFont(f)
|
|
Theme.col(c or PAL.text, a or 1)
|
|
if canPrintf() then
|
|
G.printf(tostring(str), x, y, w, "center")
|
|
else
|
|
G.print(tostring(str), x + (w - f:getWidth(tostring(str))) / 2, y)
|
|
end
|
|
end
|
|
|
|
function Kit.textHeight(name)
|
|
local f = font(name)
|
|
return f and f:getHeight() or 12
|
|
end
|
|
|
|
function Kit.textWidth(name, str)
|
|
local f = font(name)
|
|
return f and f:getWidth(tostring(str)) or 0
|
|
end
|
|
|
|
function Kit.ellipsize(name, str, maxW)
|
|
return Theme.ellipsize(font(name), str, maxW)
|
|
end
|
|
|
|
-- 12px / 2px-tracked uppercase section caption -- the design's one and only
|
|
-- section header. Returns the caption's 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, x, y, 2 * Kit.scale)
|
|
return f:getHeight()
|
|
end
|
|
|
|
function Kit.captionWidth(str)
|
|
return Theme.spacedWidth(font("caption"), str, 2 * Kit.scale)
|
|
end
|
|
|
|
-- --------------------------------------------------------------- surfaces
|
|
function Kit.card(x, y, w, h, r)
|
|
Theme.card(x, y, w, h, r or 16 * Kit.scale)
|
|
end
|
|
|
|
-- A list row. `selected` rings it in the accent colour (green for "this is
|
|
-- the thing you are editing", blue for "this is the thing you are browsing")
|
|
-- instead of filling it, so sprites and HP colours stay readable. Returns
|
|
-- true when the row was clicked this frame.
|
|
function Kit.row(x, y, w, h, selected, accent, r)
|
|
r = r or 12 * Kit.scale
|
|
if not G then return Kit.press(x, y, w, h) end
|
|
accent = accent or PAL.green
|
|
if selected then Theme.glow(x, y, w, h, r, accent, 0.45) end
|
|
Theme.row(x, y, w, h, r, 0.6)
|
|
if selected then
|
|
Theme.stroke(x, y, w, h, r, accent, 0.85, 1.5 * Kit.scale)
|
|
end
|
|
return Kit.press(x, y, w, h)
|
|
end
|
|
|
|
function Kit.meter(x, y, w, h, pct, c)
|
|
Theme.meter(x, y, w, h, pct, c)
|
|
end
|
|
|
|
-- Dashed empty-state box with a centred hint.
|
|
function Kit.emptyBox(x, y, w, h, message)
|
|
if not G then return end
|
|
Theme.col(PAL.cardBorder, 0.4)
|
|
if G.setLineWidth then G.setLineWidth(math.max(1, 1 * Kit.scale)) end
|
|
Theme.dashed(x, y, w, h, 12 * Kit.scale, 7 * Kit.scale, 5 * Kit.scale)
|
|
if G.setLineWidth then G.setLineWidth(1) end
|
|
local f = font("button")
|
|
if not f then return end
|
|
Kit.textCenter("button", message, x + 12 * Kit.scale,
|
|
y + h / 2 - f:getHeight() / 2, w - 24 * Kit.scale, PAL.muted)
|
|
end
|
|
|
|
-- --------------------------------------------------------------- buttons
|
|
-- Button kinds, straight out of the spec's colour semantics:
|
|
-- primary green gradient -- the single "commit this" control (Save)
|
|
-- ghost glassy white -- neutral verbs (Reload, Open, Add)
|
|
-- accent blue tint -- steppers, pagers, in-panel navigation
|
|
-- good green tint -- safe helpers (Full heal, max a DV)
|
|
-- danger red tint -- destructive verbs, always two-click
|
|
-- disabled steel -- never hidden, always explained in the status bar
|
|
local KINDS = {
|
|
primary = { fillTop = PAL.green, fillBot = PAL.greenDark, aTop = 1, aBot = 1,
|
|
ink = PAL.greenInk, border = nil, glow = PAL.green },
|
|
ghost = { fillTop = { 255, 255, 255 }, fillBot = { 255, 255, 255 },
|
|
aTop = 0.14, aBot = 0.03, ink = PAL.heading,
|
|
border = { 255, 255, 255 }, borderA = 0.18 },
|
|
accent = { flat = PAL.blue, flatA = 0.14, ink = PAL.blueInk,
|
|
border = PAL.cardBorder, borderA = 0.35 },
|
|
good = { flat = PAL.green, flatA = 0.1, ink = PAL.green,
|
|
border = PAL.green, borderA = 0.45 },
|
|
danger = { flat = PAL.red, flatA = 0.12, ink = PAL.redSoft,
|
|
border = PAL.red, borderA = 0.45 },
|
|
disabled = { flat = { 120, 132, 158 }, flatA = 0.22, ink = PAL.steel,
|
|
border = PAL.steel, borderA = 0.3 },
|
|
}
|
|
|
|
-- opts: { kind, font, enabled, align, radius, glow }
|
|
-- Returns true when clicked (never when disabled).
|
|
function Kit.button(x, y, w, h, label, opts)
|
|
opts = opts or {}
|
|
local enabled = opts.enabled ~= false
|
|
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
|
|
local r = opts.radius or 10 * Kit.scale
|
|
local hot = enabled and Kit.hover(x, y, w, h)
|
|
|
|
if G then
|
|
if opts.glow and enabled then
|
|
Theme.glow(x, y, w, h, r, kind.glow or PAL.green, opts.glow)
|
|
end
|
|
if kind.flat then
|
|
Theme.col(kind.flat, kind.flatA * (hot and 1.6 or 1))
|
|
G.rectangle("fill", x, y, w, h, r, r)
|
|
else
|
|
Theme.gradRounded(x, y, w, h, r, kind.fillTop, kind.fillBot,
|
|
kind.aTop * (hot and 1.4 or 1), kind.aBot * (hot and 1.6 or 1))
|
|
end
|
|
if kind.border then
|
|
Theme.stroke(x, y, w, h, r, kind.border, kind.borderA * (hot and 1.5 or 1), 1)
|
|
end
|
|
local f = font(opts.font or "button")
|
|
if f then
|
|
G.setFont(f)
|
|
Theme.col(kind.ink, 1)
|
|
local ty = y + (h - f:getHeight()) / 2
|
|
if opts.align == "left" then
|
|
G.print(label, x + 10 * Kit.scale, ty)
|
|
elseif canPrintf() then
|
|
G.printf(label, x, ty, w, "center")
|
|
else
|
|
G.print(label, x + (w - f:getWidth(label)) / 2, ty)
|
|
end
|
|
end
|
|
end
|
|
return enabled and Kit.press(x, y, w, h) or false
|
|
end
|
|
|
|
-- A small square control: the +/- steppers, the arrow cyclers, the row ✕.
|
|
function Kit.stepper(x, y, w, h, glyph, opts)
|
|
opts = opts or {}
|
|
opts.kind = opts.kind or "accent"
|
|
opts.font = opts.font or "small"
|
|
opts.radius = opts.radius or 6 * Kit.scale
|
|
return Kit.button(x, y, w, h, glyph, opts)
|
|
end
|
|
|
|
-- A pill toggle (badges, dex SEEN/OWN, event sub-tabs). `on` colours it;
|
|
-- returns true when clicked.
|
|
function Kit.chip(x, y, w, h, label, on, onColor, offColor)
|
|
local c = on and (onColor or PAL.green) or (offColor or PAL.steel)
|
|
if G then
|
|
local r = 6 * Kit.scale
|
|
Theme.col(c, on and 0.16 or 0.06)
|
|
G.rectangle("fill", x, y, w, h, r, r)
|
|
Theme.stroke(x, y, w, h, r, PAL.cardBorder, Kit.hover(x, y, w, h) and 0.5 or 0.28, 1)
|
|
Kit.textCenter("micro", label, x, y + (h - Kit.textHeight("micro")) / 2, w,
|
|
c, on and 1 or 0.75)
|
|
end
|
|
return Kit.press(x, y, w, h)
|
|
end
|
|
|
|
-- Checkbox row: a 20px box plus a mono label, the Events grid's unit.
|
|
-- Returns (newChecked, changed) so callers can write true/nil on a flip.
|
|
function Kit.checkbox(x, y, w, h, checked, label, labelColor)
|
|
local clicked = Kit.row(x, y, w, h, false, nil, 9 * Kit.scale)
|
|
local box = 20 * Kit.scale
|
|
local bx, by = x + 12 * Kit.scale, y + (h - box) / 2
|
|
if G then
|
|
Theme.col(checked and PAL.green or PAL.rowBg, checked and 1 or 0.9)
|
|
G.rectangle("fill", bx, by, box, box, 5 * Kit.scale, 5 * Kit.scale)
|
|
Theme.stroke(bx, by, box, box, 5 * Kit.scale, PAL.cardBorder, 0.4, 1)
|
|
if checked then
|
|
Kit.textCenter("small", "X", bx, by + (box - Kit.textHeight("small")) / 2,
|
|
box, PAL.greenInk)
|
|
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 (checked and PAL.text or PAL.muted))
|
|
end
|
|
if clicked then return not checked, true end
|
|
return checked, false
|
|
end
|
|
|
|
-- --------------------------------------------------------------- text field
|
|
-- A real editable field. The Events filter used to edge-detect love.keyboard
|
|
-- state because Kit had no input widget; this replaces that hack, and App
|
|
-- routes love.textinput / love.keypressed in through Kit.textinput /
|
|
-- Kit.keypressed. Returns the (possibly edited) value; the caller stores it.
|
|
function Kit.textfield(id, x, y, w, h, value, placeholder)
|
|
value = tostring(value or "")
|
|
if Kit.press(x, y, w, h) then Kit.focus = id end
|
|
local focused = (Kit.focus == id)
|
|
if focused then
|
|
for _, e in ipairs(edits) do
|
|
if e == "\b" then
|
|
value = value:sub(1, -2)
|
|
elseif e == "\r" then
|
|
Kit.focus = nil
|
|
focused = false
|
|
else
|
|
value = value .. e
|
|
end
|
|
end
|
|
end
|
|
if G then
|
|
local r = 8 * Kit.scale
|
|
Theme.col(PAL.rowBg, 0.7)
|
|
G.rectangle("fill", x, y, w, h, r, r)
|
|
Theme.stroke(x, y, w, h, r, focused and PAL.blue or PAL.cardBorder,
|
|
focused and 0.8 or 0.3, focused and 1.5 * Kit.scale 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 = Theme.ellipsizeLeft(font("mono"), value, w - 2 * pad)
|
|
local tw = Kit.text("mono", shown, x + pad, ty, PAL.heading)
|
|
-- caret: blinks only while focused, parked at the end of the text
|
|
if focused and (Kit.time % 1) < 0.55 then
|
|
Theme.col(PAL.blue, 1)
|
|
G.rectangle("fill", x + pad + tw + 2, ty, math.max(1, Kit.scale),
|
|
Kit.textHeight("mono"))
|
|
end
|
|
end
|
|
end
|
|
return value
|
|
end
|
|
|
|
-- ------------------------------------------------------------------ pager
|
|
-- Prev / Next / "1-12 of 151". Drawn even when there is a single page, so a
|
|
-- list is never silently truncated (rule 5 of the design spec). Returns the
|
|
-- new offset.
|
|
function Kit.pager(x, y, w, offset, total, perPage)
|
|
local h = 30 * Kit.scale
|
|
local bw = 74 * Kit.scale
|
|
local maxOffset = math.max(0, total - perPage)
|
|
offset = Theme.clamp(offset or 0, 0, maxOffset)
|
|
if Kit.button(x, y, bw, h, "Prev", { kind = "accent", font = "small",
|
|
enabled = offset > 0, radius = 8 * Kit.scale }) then
|
|
offset = math.max(0, offset - perPage)
|
|
end
|
|
if Kit.button(x + bw + 10 * Kit.scale, y, bw, h, "Next", { kind = "accent",
|
|
font = "small", enabled = offset < maxOffset, radius = 8 * Kit.scale }) then
|
|
offset = math.min(maxOffset, offset + perPage)
|
|
end
|
|
local shown = math.min(perPage, math.max(0, total - offset))
|
|
local label = ("%d-%d of %d"):format(total > 0 and offset + 1 or 0,
|
|
offset + shown, total)
|
|
Kit.text("mono", label, x + 2 * bw + 20 * Kit.scale,
|
|
y + (h - Kit.textHeight("mono")) / 2, PAL.caption)
|
|
return offset, h
|
|
end
|
|
|
|
-- Clip drawing to a rect (list bodies). No-ops under the headless stub.
|
|
function Kit.pushClip(x, y, w, h)
|
|
-- A compact mobile viewport can leave a panel with no room for a list.
|
|
-- LÖVE rejects negative scissor dimensions, so treat an exhausted clip
|
|
-- region as empty instead of passing invalid geometry through to it.
|
|
Kit._clipActive = G and G.setScissor ~= nil
|
|
if Kit._clipActive then
|
|
if w <= 0 or h <= 0 then
|
|
G.setScissor(0, 0, 0, 0)
|
|
else
|
|
G.setScissor(math.floor(x), math.floor(y), math.ceil(w), math.ceil(h))
|
|
end
|
|
end
|
|
end
|
|
|
|
function Kit.popClip()
|
|
if Kit._clipActive and G and G.setScissor then G.setScissor() end
|
|
Kit._clipActive = false
|
|
end
|
|
|
|
return Kit
|