mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
af47e19e1a
The launcher spent ~9ms per frame building and drawing, and the Find Mods tab could hang the window for minutes. Both had the same root cause: a retained UI tree rebuilt every frame, and blocking curl calls made from the draw path. Replace the vendored FlexLove engine (28.5k lines) with src/ui/kit/ (Kit, Theme, Layout, Loader). The kit caches Text objects and all measurement, allocates nothing in the steady state, and draws flat. Build+draw is now under 1ms at every window size and on every tab (POKEPORT_LAUNCHER_PROF). Move every network call off the render thread onto a love.thread pool (src/net/Fetch.lua): mod index fetches, per-mod release checks, find-tab stats, thumbnails and mod installs. Mod indexes prewarm at boot so the Find Mods tab is populated before it is opened. Paginate every list -- mods, find, save slots, settings, release notes, versions -- with the page size derived from the real viewport height, so a 500-mod index costs what a 10-mod one does. Scrolling is gone. Anything that waits now raises a non-dismissable loader; per-row background work shows an inline spinner instead. The in-app updater moves to the top right beside the settings gear and pulses when an update is waiting. Theme is black with white outlines, no gradients or glows, and solid colour-coded embossed buttons with bold labels. The game tabs keep their cartridge colours. Everything is 1.3x larger. The save editor shares the theme, and adding an item there is now a searchable pop-up like adding a Pokemon. Also: - Reset rebinds, in Settings and under Touch Controls. Rebinds are additive (Input:applyBindings layers them over the defaults), so there was no in-game way to undo one. - Launch options: --game red [--slot N] / POKEPORT_GAME boots straight into a game for shortcuts and frontends, falling back to that game's tab when its ROM is not imported. Fixes found while porting: - Ellipsis and letterspacing truncated bytes, not codepoints, so a multi-byte mod name crashed the first frame on a Japanese index. Measurement no longer throws on malformed input either. - The new font set missed UiFont's kana fallback, rendering translated builds as tofu. - Fetch workers idle in Channel:demand() and LOVE waits for live threads at exit, so the process outlived the window; quitting mid-download also waited on curl's 300s ceiling. Shut the pool down in love.quit and bound its transfer timeouts. - In one column the save-slot card drew below the fold, over the footer, with no scrollbar left to reach it. The two FlexLove engine tests guarded a scroll manager and an auto-height propagation bug that no longer exist; replace them with a kit suite covering page bounds, viewport sizing and UTF-8 truncation, and retarget the NX test to assert the dependency is gone rather than that its perf guards are set.
651 lines
26 KiB
Lua
651 lines
26 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).
|
|
--
|
|
-- 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.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
|
|
|
|
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.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 not down 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()
|
|
for i = #edits, 1, -1 do edits[i] = nil end
|
|
Kit.wheelY = 0
|
|
end
|
|
|
|
-- Rebuild the font set when the window size changes. `s` matched the
|
|
-- launcher's height/768 scale alone until #497: a phone in portrait
|
|
-- (720x1560) is TALLER than the desktop reference and barely half as wide, so
|
|
-- a height-only scale drew a 1.6x desktop layout into a 720px window and every
|
|
-- right-aligned cluster in the chrome landed on top of the block to its left.
|
|
--
|
|
-- The #497 answer was to shrink the whole layout down to fit the width
|
|
-- (floor 0.62), which #715 showed is its own failure: a small window got a
|
|
-- complete but unreadably tiny desktop layout, and the panels still assumed
|
|
-- their columns fit. Shrink-to-fit is gone. The scale now never dips below
|
|
-- 0.9, so text and the 26px tap targets stay readable everywhere, and a
|
|
-- narrow window is answered by REFLOW instead: every panel compares its real
|
|
-- pixel width against what its columns need (Party/Boxes/Items stack their
|
|
-- cards, Dex/Events drop grid columns, the chrome wraps its button row) and
|
|
-- whatever no longer fits vertically scrolls through Kit.scroll /
|
|
-- Kit.scrollPixels. The width term survives only to keep a portrait phone
|
|
-- from inflating to the 1.6 cap its height alone would buy: 640 real px is
|
|
-- the narrowest the single-row chrome fits at scale 1. Desktop sizes
|
|
-- (width >= 640 * height / 768) still land on the height term, so they stay
|
|
-- pixel-identical to before.
|
|
function Kit.layout(width, height)
|
|
local s = Theme.clamp(math.min(width / 640, height / 768), 0.9, 1.6)
|
|
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] = 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
|
|
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.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
|
|
-- 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
|
|
-- Colour-coded solid keys, matching the launcher exactly (src/ui/kit/Kit.lua):
|
|
-- the button IS its colour, with black ink and a two-rect emboss, and hover
|
|
-- rings it in white. The editor opens straight off a launcher save row, so a
|
|
-- control that behaves the same has to look the same.
|
|
local KINDS = {
|
|
primary = { fill = PAL.green, ink = PAL.inverse },
|
|
good = { fill = PAL.green, ink = PAL.inverse },
|
|
accent = { fill = PAL.blue, ink = PAL.inverse },
|
|
warn = { fill = PAL.yellow, ink = PAL.inverse },
|
|
danger = { fill = PAL.red, ink = PAL.inverse },
|
|
ghost = { fill = PAL.ink, ink = PAL.inverse },
|
|
disabled = { fill = PAL.steel, ink = PAL.inverse },
|
|
}
|
|
|
|
-- 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
|
|
-- disabled buttons audit too: rule 3 keeps them visible, so they still
|
|
-- must not paint over a neighbour (#715)
|
|
audit("control", x, y, w, h, label)
|
|
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
|
|
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 then
|
|
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, 1, 2,
|
|
Theme.radius() + 2)
|
|
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
|
|
-- Faux bold, the same trick the launcher uses: one weight of face, so
|
|
-- an emphasised run is the same text drawn a pixel across.
|
|
local function put(dx)
|
|
if opts.align == "left" then
|
|
G.print(label, x + 10 * Kit.scale + dx, ty)
|
|
elseif canPrintf() then
|
|
G.printf(label, x + dx, ty, w, "center")
|
|
else
|
|
G.print(label, x + (w - f:getWidth(label)) / 2 + dx, ty)
|
|
end
|
|
end
|
|
put(0)
|
|
put(Theme.BOLD_OFFSET or 1)
|
|
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)
|
|
audit("control", x, y, w, h, label)
|
|
local c = on and (onColor or PAL.green) or (offColor or PAL.steel)
|
|
if G then
|
|
-- Same rule as the launcher's chips: an ON chip is FILLED with its colour
|
|
-- and prints black; an OFF chip is an outline. The old alpha-tinted fill
|
|
-- read as "slightly different dark rectangle" on a black field.
|
|
local hot = 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.textCenter("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 1 or 0.5, 1)
|
|
Kit.textCenter("micro", label, x,
|
|
y + (h - Kit.textHeight("micro")) / 2, w, c)
|
|
end
|
|
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)
|
|
audit("control", x, y, w, h, id)
|
|
value = tostring(value or "")
|
|
if Kit.press(x, y, w, h) then Kit.focus = id end
|
|
local focused = (Kit.focus == id)
|
|
if focused then
|
|
-- raise (or hand off) the soft keyboard while this field owns focus (#529)
|
|
syncSoftKeyboard(id, x, y, w, h)
|
|
for _, e in ipairs(edits) do
|
|
if e == "\b" then
|
|
value = value:sub(1, -2)
|
|
elseif e == "\r" then
|
|
Kit.blur() -- commit/cancel also lowers the soft keyboard (#529)
|
|
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)
|
|
-- 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 overlay scrollbar along the right edge of a list body, drawn after
|
|
-- the rows so it stays visible. 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 - bw
|
|
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
|