mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
394 lines
14 KiB
Lua
394 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)
|
|
if G and G.setScissor then
|
|
G.setScissor(math.floor(x), math.floor(y), math.ceil(w), math.ceil(h))
|
|
end
|
|
end
|
|
|
|
function Kit.popClip()
|
|
if G and G.setScissor then G.setScissor() end
|
|
end
|
|
|
|
return Kit
|