mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-03 16:16:38 -04:00
953 lines
29 KiB
Lua
953 lines
29 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, wheel) 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. The wheel notches accumulated since the
|
|
-- last frame arrive the same way and are retired the same way: an unclaimed
|
|
-- notch dies with the frame rather than scrolling something later (#595).
|
|
--
|
|
-- Clip-aware hit testing fences scrolled content. Every action uses a
|
|
-- 44px minimum target, with layouts reflowing at narrow widths.
|
|
|
|
local Theme = require("Theme")
|
|
local PAL = Theme.PAL
|
|
local Button = require("src.ui.kit.Button")
|
|
local Icons = require("src.ui.kit.Icons")
|
|
|
|
local Kit = {}
|
|
Kit.mouseX, Kit.mouseY = 0, 0
|
|
Kit.mouseClicked = false -- left button pressed this frame
|
|
Kit.wheelY = 0 -- wheel notches queued since the last frame (#595)
|
|
Kit.focus = nil -- id of the text field receiving keystrokes
|
|
Kit.time = 0
|
|
Kit.fonts = {}
|
|
Kit.scale = 1
|
|
|
|
function Kit.tapMin()
|
|
return math.max(44, math.floor(38 * (Kit.scale or 1)))
|
|
end
|
|
|
|
local G = love and love.graphics or nil
|
|
local edits = {} -- queued textinput / backspace since the last frame
|
|
local kbField = nil -- id of the field the OS soft keyboard is raised for
|
|
|
|
-- Mobile LOVE only delivers love.textinput while setTextInput(true) is
|
|
-- active, and that call is what raises the Android/iOS soft keyboard; the
|
|
-- rect keeps the focused field visible above it. Desktop has text input on
|
|
-- by default and the launcher hosting this editor depends on that -- the
|
|
-- launcher's own fields (slot rename #205, mod index prompt, find search)
|
|
-- follow the same rule since #578: arm on open, lower only on mobile -- so
|
|
-- neither side ever turns desktop text input off, since setTextInput is
|
|
-- global SDL state, not per-widget (#529).
|
|
local function mobile()
|
|
local osName = love and love.system and love.system.getOS and love.system.getOS()
|
|
return osName == "Android" or osName == "iOS"
|
|
end
|
|
|
|
local function syncSoftKeyboard(id, x, y, w, h)
|
|
if not (love and love.keyboard and love.keyboard.setTextInput) then
|
|
return
|
|
end
|
|
if id then
|
|
if kbField ~= id then
|
|
kbField = id
|
|
love.keyboard.setTextInput(true, math.floor(x), math.floor(y), math.ceil(w), math.ceil(h))
|
|
end
|
|
elseif kbField then
|
|
kbField = nil
|
|
if mobile() then
|
|
love.keyboard.setTextInput(false)
|
|
end
|
|
end
|
|
end
|
|
|
|
local function canPrintf()
|
|
return G and type(G.printf) == "function"
|
|
end
|
|
|
|
function Kit.beginFrame(mx, my, clicked, wheel)
|
|
Kit.mouseX, Kit.mouseY = mx, my
|
|
Kit.mouseClicked = clicked
|
|
Kit._fieldHit = false
|
|
Kit._focusDrawn = false
|
|
Kit.wheelY = wheel or 0
|
|
-- Held-button state is polled, not evented: the editor is hosted both
|
|
-- standalone and inside the launcher, and neither routes mousereleased
|
|
-- here. Touch drag scrolling (#715) rides this poll, so it works in both
|
|
-- hosts without new plumbing. The stub has no love.mouse.isDown; a frame
|
|
-- without it simply has no drags.
|
|
local down = false
|
|
if love and love.mouse and love.mouse.isDown then
|
|
down = love.mouse.isDown(1) and true or false
|
|
end
|
|
Kit.mouseDown = down
|
|
if Kit.touchDown == true then
|
|
Kit.mouseDown = true
|
|
end
|
|
if not Kit.mouseDown then
|
|
Kit._drag = nil
|
|
end
|
|
Kit.resetClip()
|
|
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.
|
|
-- A wheel notch no list claimed retires with them, for the same reason.
|
|
function Kit.endFrame()
|
|
if (Kit.mouseClicked and not Kit._fieldHit) or (Kit.focus and not Kit._focusDrawn) then
|
|
Kit.blur()
|
|
end
|
|
for i = #edits, 1, -1 do
|
|
edits[i] = nil
|
|
end
|
|
Kit.wheelY = 0
|
|
end
|
|
|
|
-- Use the launcher's font scale, with reflow and scrolling on small screens.
|
|
function Kit.layout(width, height)
|
|
local s = Theme.clamp(math.min(width / 640, height / 768), 0.9, 1.6) * 1.3
|
|
local key = ("%dx%d"):format(width, height)
|
|
if Kit._fontKey ~= key then
|
|
Kit._fontKey = key
|
|
Kit.fonts = Theme.fonts(s)
|
|
end
|
|
Kit.scale = s
|
|
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] = { id = Kit.focus, text = 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] = { id = Kit.focus, text = "\b" }
|
|
return true
|
|
elseif key == "return" or key == "kpenter" or key == "escape" then
|
|
edits[#edits + 1] = { id = Kit.focus, text = key == "escape" and "\27" or "\r" }
|
|
return true
|
|
end
|
|
-- other keys (arrows, shortcuts) fall through to App while a field is hot
|
|
return false
|
|
end
|
|
|
|
-- Used by immediate Enter commits so typing delivered before the next draw
|
|
-- is included exactly once.
|
|
function Kit.flushText(id, value, sanitize)
|
|
value = tostring(value or "")
|
|
for _, edit in ipairs(edits) do
|
|
if edit.id == id then
|
|
local e = edit.text
|
|
if e == "\b" then
|
|
local at = #value
|
|
while at > 1 and value:byte(at) >= 128 and value:byte(at) < 192 do
|
|
at = at - 1
|
|
end
|
|
value = value:sub(1, at - 1)
|
|
elseif e ~= "\r" and e ~= "\27" then
|
|
value = value .. e
|
|
end
|
|
end
|
|
end
|
|
for i = #edits, 1, -1 do
|
|
if edits[i].id == id then
|
|
table.remove(edits, i)
|
|
end
|
|
end
|
|
return sanitize and sanitize(value) or value
|
|
end
|
|
|
|
function Kit.blur()
|
|
Kit.focus = nil
|
|
syncSoftKeyboard(nil) -- the soft keyboard follows focus down too (#529)
|
|
end
|
|
|
|
-- ------------------------------------------------------------- hit testing
|
|
-- A widget inside a scrolled clip region can sit at coordinates outside the
|
|
-- visible rect (#715: stacked panels scroll in pixels), so the active clip
|
|
-- bounds the hit: what the user cannot see cannot take the tap.
|
|
function Kit.hit(x, y, w, h)
|
|
local c = Kit._clipRect
|
|
if
|
|
c
|
|
and not (
|
|
Kit.mouseX >= c.x
|
|
and Kit.mouseX <= c.x + c.w
|
|
and Kit.mouseY >= c.y
|
|
and Kit.mouseY <= c.y + c.h
|
|
)
|
|
then
|
|
return false
|
|
end
|
|
return Kit.mouseX >= x and Kit.mouseX <= x + w and Kit.mouseY >= y and Kit.mouseY <= y + h
|
|
end
|
|
|
|
-- ------------------------------------------------------------ layout audit
|
|
-- #715 reflow tests: when a test sets Kit.audit to a table, every control
|
|
-- that could take a click this frame appends its rect (plus the clip that
|
|
-- bounds it), so a window-size sweep can assert that no two controls
|
|
-- overlap and none escapes the window. Shielded widgets are skipped: under
|
|
-- a modal they cannot take the tap, and the modal legitimately covers them.
|
|
Kit.audit = nil
|
|
|
|
local function audit(class, x, y, w, h, label)
|
|
local a = Kit.audit
|
|
if not a or Kit.blockClicks then
|
|
return
|
|
end
|
|
local c = Kit._clipRect
|
|
a[#a + 1] = {
|
|
class = class,
|
|
x = x,
|
|
y = y,
|
|
w = w,
|
|
h = h,
|
|
label = tostring(label or ""),
|
|
clip = c and { x = c.x, y = c.y, w = c.w, h = c.h } or nil,
|
|
}
|
|
end
|
|
|
|
function Kit.hover(x, y, w, h)
|
|
return Kit.hit(x, y, w, h)
|
|
end
|
|
|
|
-- Kit hit-tests without a z-order, so an overlay cannot just be drawn last:
|
|
-- every widget underneath it would still take the same click. A modal raises
|
|
-- this shield over the layers it covers (App.draw does it around the chrome
|
|
-- and the panel while the species picker is up) and lowers it for its own
|
|
-- layer (#541).
|
|
Kit.blockClicks = false
|
|
|
|
function Kit.press(x, y, w, h)
|
|
if Kit.blockClicks then
|
|
return false
|
|
end
|
|
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.textBold(name, str, x, y, c, a)
|
|
Kit.text(name, str, x, y, c, a)
|
|
return Kit.text(name, str, x + (Theme.BOLD_OFFSET or 1), y, c, a)
|
|
end
|
|
|
|
function Kit.textCenterBold(name, str, x, y, w, c, a)
|
|
Kit.textCenter(name, str, x, y, w, c, a)
|
|
Kit.textCenter(name, str, x + (Theme.BOLD_OFFSET or 1), y, w, c, a)
|
|
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
|
|
audit("row", x, y, w, h, "row")
|
|
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
|
|
-- The launcher and editor share one painter; input ownership stays local.
|
|
Kit.KINDS = Button.KINDS
|
|
local ACTION_ICONS = {
|
|
Save = "save",
|
|
Saved = "save",
|
|
Undo = "undo-2",
|
|
Redo = "redo-2",
|
|
More = "ellipsis",
|
|
Less = "x",
|
|
Reload = "rotate-ccw",
|
|
Open = "folder",
|
|
["Open..."] = "folder",
|
|
Close = "x",
|
|
["Discard?"] = "trash",
|
|
Set = "check",
|
|
Add = "plus",
|
|
["Add item"] = "plus",
|
|
["Add mon"] = "plus",
|
|
Remove = "trash",
|
|
Release = "trash",
|
|
["Confirm?"] = "check",
|
|
Drop = "trash",
|
|
Inspect = "pencil",
|
|
Edit = "pencil",
|
|
Withdraw = "download",
|
|
Take = "download",
|
|
Free = "trash",
|
|
["Sure?"] = "check",
|
|
["Deposit selected"] = "upload",
|
|
Deposit = "upload",
|
|
Tools = "sliders-horizontal",
|
|
["Change species"] = "pencil",
|
|
["Clear nickname"] = "x",
|
|
["Full heal"] = "heart",
|
|
["Clone to a box"] = "copy",
|
|
["Reset to learnset"] = "rotate-ccw",
|
|
["Max all PP"] = "chevrons-up",
|
|
["Max all (31)"] = "chevrons-up",
|
|
["Clear EVs"] = "x",
|
|
Clear = "x",
|
|
["Max all"] = "chevrons-up",
|
|
["Sort A-Z"] = "arrow-up-down",
|
|
["Sort count"] = "arrow-up-down",
|
|
["Sort #"] = "list-filter",
|
|
["Clear all"] = "trash",
|
|
["Wipe dex"] = "trash",
|
|
["See all"] = "eye",
|
|
["Own all"] = "check",
|
|
["Own party + boxes"] = "check",
|
|
Player = "map-pin",
|
|
["Set here"] = "map-pin",
|
|
Prev = "chevron-left",
|
|
Next = "chevron-right",
|
|
}
|
|
function Kit.buttonWidth(label, opts, h)
|
|
opts = opts or {}
|
|
h = h or Kit.controlH()
|
|
if opts.iconOnly then
|
|
return h
|
|
end
|
|
local name = opts.font or "button"
|
|
local width = Kit.textWidth(name, label) + 20 * Kit.scale + (Theme.BOLD_OFFSET or 1)
|
|
if (opts.icon or ACTION_ICONS[label]) and not opts.iconStack then
|
|
width = width + math.floor(h * 0.42) + math.floor(7 * Kit.scale)
|
|
end
|
|
if opts.trailingIcon then
|
|
width = width + h * 0.65
|
|
end
|
|
return math.max(Kit.tapMin(), math.ceil(width))
|
|
end
|
|
|
|
function Kit.buttonHeight(label, w, opts)
|
|
local h = Kit.controlH()
|
|
local height = Button.labelLayout(Kit, w, h, label, opts or {}).height + 4 * Kit.scale
|
|
return math.max(h, math.ceil(height))
|
|
end
|
|
|
|
function Kit.button(x, y, w, h, label, opts)
|
|
-- Retired panels still pass scaled radii and raised-key options. Keep the
|
|
-- editor's controls flat, with a visibly rounded touch-control shape, even
|
|
-- when a selected state or an old call site supplies those options.
|
|
local faceOpts = {}
|
|
for key, value in pairs(opts or {}) do
|
|
faceOpts[key] = value
|
|
end
|
|
opts = faceOpts
|
|
opts.radius = Theme.radius()
|
|
opts.segments = Theme.CONTROL.segments
|
|
opts.emboss = false
|
|
opts.fullLabel = true
|
|
if opts.face == "tab" then
|
|
opts.face = "selection"
|
|
end
|
|
opts.icon = opts.icon or ACTION_ICONS[label]
|
|
audit("control", x, y, w, h, label)
|
|
local hot = opts.enabled ~= false and Kit.hover(x, y, w, h)
|
|
local shown = label
|
|
if opts.iconOnly then
|
|
shown = ""
|
|
elseif label and label ~= "" then
|
|
opts.labelLayout = Button.labelLayout(Kit, w, h, label, opts)
|
|
end
|
|
Button.draw(Kit, x, y, w, h, shown, opts, hot, false)
|
|
return opts.enabled ~= false and Kit.press(x, y, w, h) or false
|
|
end
|
|
|
|
function Kit.iconButton(x, y, w, h, icon, label, opts)
|
|
local faceOpts = {}
|
|
for key, value in pairs(opts or {}) do
|
|
faceOpts[key] = value
|
|
end
|
|
faceOpts.icon, faceOpts.iconOnly = icon, true
|
|
return Kit.button(x, y, w, h, label, faceOpts)
|
|
end
|
|
|
|
function Kit.icon(name, x, y, size, color, alpha)
|
|
Icons.draw(name, x, y, size, color or PAL.text, alpha)
|
|
end
|
|
|
|
local STEPPER_ICONS = {
|
|
minus = "minus",
|
|
plus = "plus",
|
|
previous = "chevron-left",
|
|
next = "chevron-right",
|
|
up = "arrow-up",
|
|
down = "arrow-down",
|
|
}
|
|
|
|
function Kit.stepper(x, y, w, h, action, opts)
|
|
opts = opts or {}
|
|
opts.kind = opts.kind or "ghost"
|
|
opts.font = opts.font or "small"
|
|
return Kit.iconButton(x, y, w, h, STEPPER_ICONS[action] or action, action, opts)
|
|
end
|
|
|
|
local TAB_ICONS = {
|
|
Party = "users",
|
|
Boxes = "grid-2x2",
|
|
Items = "backpack",
|
|
Events = "flag",
|
|
Map = "map-pin",
|
|
Dex = "book-open",
|
|
["Pokédex"] = "book-open",
|
|
Trainer = "user-round",
|
|
Checks = "shield-check",
|
|
Main = "pencil",
|
|
Stats = "sliders-horizontal",
|
|
Moves = "list-filter",
|
|
Origin = "map-pin",
|
|
Extras = "award",
|
|
Bag = "backpack",
|
|
PC = "package",
|
|
Wallet = "wallet",
|
|
Badges = "award",
|
|
Flags = "flag",
|
|
["Story flags"] = "flag",
|
|
Trainers = "users",
|
|
["Items taken"] = "package",
|
|
["Object toggles"] = "eye",
|
|
["System & Badges"] = "award",
|
|
Variables = "sliders-horizontal",
|
|
Deposit = "upload",
|
|
Maps = "map-pin",
|
|
View = "eye",
|
|
Spawn = "map-pin",
|
|
}
|
|
function Kit.navigationIcon(label)
|
|
return TAB_ICONS[label]
|
|
end
|
|
|
|
function Kit.chip(x, y, w, h, label, on, _onColor, _offColor)
|
|
return Kit.button(x, y, w, h, label, {
|
|
face = "tab",
|
|
font = "small",
|
|
active = on,
|
|
icon = TAB_ICONS[label],
|
|
iconStack = TAB_ICONS[label] ~= nil,
|
|
})
|
|
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
|
|
local radius = math.min(Theme.radius(), box / 3)
|
|
Theme.fillRounded(bx, by, box, box, PAL.surface, 1, radius)
|
|
Theme.strokeRounded(
|
|
bx,
|
|
by,
|
|
box,
|
|
box,
|
|
checked and PAL.blue or PAL.line,
|
|
checked and 1 or 0.65,
|
|
checked and 2 or 1,
|
|
radius
|
|
)
|
|
if checked then
|
|
local size = box * 0.75
|
|
Icons.draw("check", bx + (box - size) / 2, by + (box - size) / 2, size, PAL.blue)
|
|
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.
|
|
--
|
|
-- `opts.sanitize(value)` (optional) is a post-filter run on the merged text
|
|
-- right after this frame's edits and BEFORE it draws, so a keystroke or paste
|
|
-- the filter rejects never even flashes on screen. It gets the whole value
|
|
-- because a paste arrives as one textinput chunk alongside existing text.
|
|
function Kit.textfield(id, x, y, w, h, value, placeholder, opts)
|
|
audit("control", x, y, w, h, id)
|
|
value = tostring(value or "")
|
|
if Kit.press(x, y, w, h) then
|
|
Kit.focus = id
|
|
Kit._fieldHit = true
|
|
end
|
|
local focused = (Kit.focus == id)
|
|
local clip = Kit._clipRect
|
|
if
|
|
focused
|
|
and clip
|
|
and (y + h <= clip.y or y >= clip.y + clip.h or x + w <= clip.x or x >= clip.x + clip.w)
|
|
then
|
|
Kit.blur()
|
|
focused = false
|
|
end
|
|
if focused then
|
|
Kit._focusDrawn = true
|
|
-- raise (or hand off) the soft keyboard while this field owns focus (#529)
|
|
syncSoftKeyboard(id, x, y, w, h)
|
|
for _, edit in ipairs(edits) do
|
|
if edit.id == id then
|
|
local e = edit.text
|
|
if e == "\b" then
|
|
local i = #value
|
|
while i > 1 and value:byte(i) >= 128 and value:byte(i) < 192 do
|
|
i = i - 1
|
|
end
|
|
value = value:sub(1, i - 1)
|
|
elseif e == "\r" then
|
|
if opts and opts.onSubmit then
|
|
opts.onSubmit(value)
|
|
end
|
|
Kit.blur() -- commit/cancel also lowers the soft keyboard (#529)
|
|
focused = false
|
|
elseif e == "\27" then
|
|
Kit.blur()
|
|
focused = false
|
|
else
|
|
value = value .. e
|
|
end
|
|
end
|
|
end
|
|
if opts and opts.sanitize then
|
|
value = opts.sanitize(value)
|
|
end
|
|
end
|
|
if G then
|
|
local r = Theme.radius()
|
|
Theme.fillRounded(x, y, w, h, PAL.rowBg, 0.7, 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
|
|
|
|
-- Forms reserve height before drawing; all editor panels use the same floor.
|
|
function Kit.controlH(base)
|
|
return math.max(Kit.tapMin(), (base or 38) * Kit.scale)
|
|
end
|
|
|
|
function Kit.textWrapped(name, str, x, y, w, c)
|
|
local f = font(name)
|
|
if not f or w <= 0 then
|
|
return 0
|
|
end
|
|
local lines
|
|
if f.getWrap then
|
|
local _
|
|
_, lines = f:getWrap(tostring(str), w)
|
|
else
|
|
lines = {}
|
|
local line = ""
|
|
for word in tostring(str):gmatch("%S+") do
|
|
local nextLine = line == "" and word or (line .. " " .. word)
|
|
if line ~= "" and f:getWidth(nextLine) > w then
|
|
lines[#lines + 1] = line
|
|
line = word
|
|
else
|
|
line = nextLine
|
|
end
|
|
end
|
|
if line ~= "" then
|
|
lines[#lines + 1] = line
|
|
end
|
|
end
|
|
for i, line in ipairs(lines) do
|
|
Kit.text(name, line, x, y + (i - 1) * f:getHeight(), c)
|
|
end
|
|
return #lines * f:getHeight()
|
|
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 = Kit.controlH()
|
|
local bw = math.max(Kit.tapMin(), math.min(74 * Kit.scale, (w - 10 * Kit.scale) / 2))
|
|
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)
|
|
-- the counter clips to the width the caller granted: a panel parking a
|
|
-- button on the pager line passes a reduced w and the text yields instead
|
|
-- of running underneath it (#715)
|
|
local labelX = x + 2 * bw + 20 * Kit.scale
|
|
Kit.text(
|
|
"mono",
|
|
Kit.ellipsize("mono", label, math.max(0, x + w - labelX)),
|
|
labelX,
|
|
y + (h - Kit.textHeight("mono")) / 2,
|
|
PAL.caption
|
|
)
|
|
return offset, h
|
|
end
|
|
|
|
-- ----------------------------------------------------------------- scroll
|
|
-- Mouse wheel over a list body (#595): same offset contract as Kit.pager, so
|
|
-- a list can carry both and stay on one page counter. Three rules, all of
|
|
-- them consequences of Kit having no z-order:
|
|
-- * only the list the pointer is inside takes the notch,
|
|
-- * the notch is consumed, so two stacked lists cannot both eat it,
|
|
-- * Kit.blockClicks shields it exactly as it shields Kit.press, or the
|
|
-- panel under an open species picker would scroll through the modal.
|
|
local SCROLL_ROWS = 3
|
|
|
|
-- `step` is optional and exists for grids: a 4-column dex page must move in
|
|
-- multiples of 4 or the columns shear. Lists leave it nil and keep the old
|
|
-- behaviour bit for bit (wheel notch = 3 rows, drag = 1 row per row height).
|
|
function Kit.scroll(x, y, w, h, offset, total, perPage, step)
|
|
local maxOffset = math.max(0, (total or 0) - (perPage or 0))
|
|
offset = Theme.clamp(offset or 0, 0, maxOffset)
|
|
if Kit.blockClicks then
|
|
return offset
|
|
end
|
|
|
|
-- Touch drag (#715): a phone has no wheel and the pagers are small
|
|
-- targets, so a held pointer dragging vertically over the list body
|
|
-- scrolls it. The drag is keyed to the rect it started in and follows the
|
|
-- pointer even once it leaves, like every native scroll view; the press
|
|
-- frame itself still dispatches as a click, which is the pre-existing
|
|
-- press-on-down contract, so a tap keeps selecting rows.
|
|
local dragStep = math.max(1, step or 1)
|
|
if Kit.mouseDown and maxOffset > 0 and h > 0 and (perPage or 0) > 0 then
|
|
local key = math.floor(x) .. ":" .. math.floor(y)
|
|
local d = Kit._drag
|
|
if not d and Kit.hit(x, y, w, h) then
|
|
Kit._drag = { key = key, startY = Kit.mouseY, base = offset }
|
|
elseif d and d.key == key then
|
|
local visRows = math.max(1, math.floor(perPage / dragStep))
|
|
local rowPx = math.max(1, h / visRows)
|
|
local moved = math.floor((d.startY - Kit.mouseY) / rowPx + 0.5) * dragStep
|
|
offset = Theme.clamp(d.base + moved, 0, maxOffset)
|
|
end
|
|
end
|
|
|
|
if (Kit.wheelY or 0) == 0 then
|
|
return offset
|
|
end
|
|
if not Kit.hit(x, y, w, h) then
|
|
return offset
|
|
end
|
|
-- LOVE reports wheel-up as positive y; up moves the window toward the top
|
|
-- of the list, which is a smaller offset.
|
|
local rows = step or math.max(1, math.min(SCROLL_ROWS, perPage or SCROLL_ROWS))
|
|
local notch = (Kit.wheelY > 0) and -rows or rows
|
|
Kit.wheelY = 0
|
|
return Theme.clamp(offset + notch, 0, maxOffset)
|
|
end
|
|
|
|
-- Pixel-unit sibling of Kit.scroll for a whole stacked card column (#715
|
|
-- reflow): `offset` is a pixel offset into `contentH` pixels of laid-out
|
|
-- content shown through an `h`-pixel viewport. Same three rules as
|
|
-- Kit.scroll (pointer-inside only, notch consumed, shielded by
|
|
-- Kit.blockClicks), same drag contract (a tap still dispatches as a click).
|
|
-- Call it AFTER the content so any inner Kit.scroll list gets first claim on
|
|
-- a wheel notch or drag that lands over it.
|
|
function Kit.scrollPixels(x, y, w, h, offset, contentH)
|
|
local maxOffset = math.max(0, (contentH or 0) - math.max(0, h))
|
|
offset = Theme.clamp(offset or 0, 0, maxOffset)
|
|
if Kit.blockClicks then
|
|
return offset
|
|
end
|
|
|
|
if Kit.mouseDown and maxOffset > 0 and h > 0 then
|
|
local key = "px:" .. math.floor(x) .. ":" .. math.floor(y)
|
|
local d = Kit._drag
|
|
if not d and Kit.hit(x, y, w, h) then
|
|
Kit._drag = { key = key, startY = Kit.mouseY, base = offset }
|
|
elseif d and d.key == key then
|
|
offset = Theme.clamp(d.base + (d.startY - Kit.mouseY), 0, maxOffset)
|
|
end
|
|
end
|
|
|
|
if (Kit.wheelY or 0) == 0 then
|
|
return offset
|
|
end
|
|
if not Kit.hit(x, y, w, h) then
|
|
return offset
|
|
end
|
|
local notch = 48 * Kit.scale
|
|
local delta = (Kit.wheelY > 0) and -notch or notch
|
|
Kit.wheelY = 0
|
|
return Theme.clamp(offset + delta, 0, maxOffset)
|
|
end
|
|
|
|
-- Thin scrollbar in the card's right padding, clear of control faces and
|
|
-- their corner arcs. Pure indicator (the drag above and the
|
|
-- pager are the controls): on a phone the old layout looked "stuck" because
|
|
-- nothing said the list continued past the fold (#715).
|
|
function Kit.scrollbar(x, y, w, h, offset, total, perPage)
|
|
if not G then
|
|
return
|
|
end
|
|
total, perPage = total or 0, perPage or 0
|
|
if total <= perPage or h <= 0 or perPage <= 0 then
|
|
return
|
|
end
|
|
local bw = 3 * Kit.scale
|
|
local bx = x + w + 4 * Kit.scale
|
|
audit("scrollbar", bx, y, bw, h, "scrollbar")
|
|
Theme.col(PAL.cardBorder, 0.22)
|
|
G.rectangle("fill", bx, y, bw, h, bw / 2, bw / 2)
|
|
local maxOffset = total - perPage
|
|
local th = math.max(18 * Kit.scale, h * perPage / total)
|
|
local ty = y + (h - th) * (Theme.clamp(offset or 0, 0, maxOffset) / maxOffset)
|
|
Theme.col(PAL.blue, 0.55)
|
|
G.rectangle("fill", bx, ty, bw, th, bw / 2, bw / 2)
|
|
end
|
|
|
|
-- Clip drawing to a rect (list bodies, scrolled cards). A stack since #715:
|
|
-- a stacked panel scrolls its whole column inside one clip and the lists
|
|
-- inside it push their own, so pushes nest by intersecting with the rect
|
|
-- above and a pop restores that rect rather than clearing the scissor. The
|
|
-- tracked rect also bounds Kit.hit, so a widget scrolled out of view is
|
|
-- inert instead of taking taps aimed at whatever is drawn where it left.
|
|
-- Under the headless stub the scissor is a no-op but the rect tracking (and
|
|
-- so the hit fencing) still runs.
|
|
local clipStack = {}
|
|
|
|
local function applyClip(rect)
|
|
Kit._clipRect = rect
|
|
if not (G and G.setScissor) then
|
|
return
|
|
end
|
|
if not rect then
|
|
G.setScissor()
|
|
elseif rect.w <= 0 or rect.h <= 0 then
|
|
-- 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.
|
|
G.setScissor(0, 0, 0, 0)
|
|
else
|
|
G.setScissor(math.floor(rect.x), math.floor(rect.y), math.ceil(rect.w), math.ceil(rect.h))
|
|
end
|
|
end
|
|
|
|
function Kit.pushClip(x, y, w, h)
|
|
local prev = clipStack[#clipStack]
|
|
local x2, y2 = x + math.max(0, w), y + math.max(0, h)
|
|
if prev then
|
|
x, y = math.max(x, prev.x), math.max(y, prev.y)
|
|
x2 = math.min(x2, prev.x + prev.w)
|
|
y2 = math.min(y2, prev.y + prev.h)
|
|
end
|
|
local rect = { x = x, y = y, w = math.max(0, x2 - x), h = math.max(0, y2 - y) }
|
|
clipStack[#clipStack + 1] = rect
|
|
applyClip(rect)
|
|
end
|
|
|
|
function Kit.popClip()
|
|
clipStack[#clipStack] = nil
|
|
applyClip(clipStack[#clipStack])
|
|
end
|
|
|
|
-- A pcall-ed draw that raised mid-clip must not leak the stack into later
|
|
-- frames (every hit test would stay fenced to the dead rect), so the frame
|
|
-- boundary clears it.
|
|
function Kit.resetClip()
|
|
for i = #clipStack, 1, -1 do
|
|
clipStack[i] = nil
|
|
end
|
|
applyClip(nil)
|
|
end
|
|
|
|
return Kit
|