diff --git a/src/core/IssueReport.lua b/src/core/IssueReport.lua index 4f63ffe2..6b5caaa1 100644 --- a/src/core/IssueReport.lua +++ b/src/core/IssueReport.lua @@ -227,7 +227,11 @@ local function metadata(options, context) end if flags and flags.fullscreen == true then add("Fullscreen", "yes") end local version = appVersion() - add("App", version ~= "" and Version.title() or "gen1recomp") + -- Bug reports always want the stamped engine when we have one; window + -- chrome hides it on release builds (Version.title). Unstamped + -- working-tree builds stay as plain "gen1recomp" so the placeholder + -- never lands in a filed issue. + add("App", version ~= "" and ("gen1recomp v" .. version) or "gen1recomp") add("LÖVE", loveVersion()) if safeMode then add("Safe mode", "on") end return { diff --git a/src/core/Version.lua b/src/core/Version.lua index 184d8431..5e632ae6 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -22,10 +22,22 @@ local Version = { cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) } --- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds) +-- True for the working-tree placeholder and any stamped "-dev" pre-release. +-- Shipped builds get a bare X.Y.Z from CI and are not "dev" here. +function Version.isDev() + local engine = tostring(Version.engine or "") + return engine == "0.0.0-dev" or engine:find("%-dev", 1) ~= nil +end + +-- Window / chrome title. Dev builds keep the version visible +-- ("gen1recomp v0.0.0-dev"); release builds are just the base name so Linux +-- window chrome and taskbars do not read "gen1recomp 0.1.73". function Version.title(base) - return (base or "gen1recomp") - .. " v" .. Version.engine + base = base or "gen1recomp" + if Version.isDev() then + return base .. " v" .. Version.engine + end + return base end return Version diff --git a/tests/mod_runtime_tests.lua b/tests/mod_runtime_tests.lua index 33d42bae..44f52ad6 100644 --- a/tests/mod_runtime_tests.lua +++ b/tests/mod_runtime_tests.lua @@ -145,8 +145,24 @@ check(type(Version.engine) == "string" and Semver.parse(Version.engine) ~= nil, "engine version parses as a semver (triple, optionally with a pre-release)") check(Version.modApi == 2, "mod api version is 2") +check(Version.isDev() == true, "the working-tree placeholder is a dev build") check(Version.title("X") == "X v" .. Version.engine, - "window title carries the engine version") + "dev window title carries the engine version") +do + local saved = Version.engine + Version.engine = "0.1.73" + check(Version.isDev() == false, "a stamped X.Y.Z is not a dev build") + check(Version.title() == "gen1recomp", + "release window title omits the version number") + check(Version.title("Gen 1 Recompilation Project") + == "Gen 1 Recompilation Project", + "release titles keep the base string alone") + Version.engine = "1.2.3-dev" + check(Version.isDev() == true, "a -dev pre-release still counts as dev") + check(Version.title() == "gen1recomp v1.2.3-dev", + "and still shows the version in the title") + Version.engine = saved +end -- null-object runtime: emit/call are safe with no loader installed local nullEvents, nullHooks = Runtime.events, Runtime.hooks diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index ef452f87..51463776 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -927,6 +927,113 @@ do for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end +do + -- Move picker search predicate: same plain-text rules as species (#541), + -- plus type substring and whole-number power / accuracy matches. + local S = State.new() + S.data = Data + S.cat = Catalog.build(Data) + S.save = SaveData.newGame() + + check(Ops.moveMatches(S, "THUNDERBOLT", "thunder"), "lowercase query matches an id") + check(Ops.moveMatches(S, "THUNDERBOLT", "BOLT"), "a mid-word substring matches") + check(Ops.moveMatches(S, "THUNDERBOLT", "electric"), "a type substring matches") + check(Ops.moveMatches(S, "THUNDERBOLT", "95"), "a whole power matches") + check(Ops.moveMatches(S, "THUNDERBOLT", "100"), "a whole accuracy matches") + check(Ops.moveMatches(S, "THUNDERBOLT", "9") == false, + "a power/accuracy number does not substring-match") + check(Ops.moveMatches(S, "THUNDERBOLT", ""), "an empty query matches everything") + check(Ops.moveUsable(S, "THUNDERBOLT"), "a real move is usable") + check(Ops.moveUsable(S, "generation") == false, "a provenance scalar is not usable") + + eq(#Ops.moveSearch(S, ""), #S.cat.moves, "an empty search lists the catalog") + eq(#Ops.moveSearch(S, "%a"), 0, "a pattern class is literal") + check(#Ops.moveSearch(S, "zzzznope") == 0, "a miss returns nothing") + local bolt = Ops.moveSearch(S, "thunderbolt") + eq(#bolt, 1, "an exact name search narrows to one") + eq(bolt[1], "THUNDERBOLT", "and it is the right one") + + -- Prefix beats mid-string: "sur" used to list ACUPRESSURE / FISSURE before + -- SURF because the catalog is A-Z. Rank so Enter commits the obvious hit. + local sur = Ops.moveSearch(S, "sur") + check(#sur >= 1, "sur finds at least one move") + eq(sur[1], "SURF", "a prefix match ranks above mid-string hits") + local thunder = Ops.moveSearch(S, "thunder") + check(#thunder >= 1, "thunder finds at least one move") + eq(thunder[1], "THUNDER", "THUNDER prefixes beat THUNDERBOLT / THUNDERSHOCK") +end + +do + -- Move picker end to end through App: open off a move row, type, commit + -- with Enter. Same Enter/Escape-before-Kit rule as the species picker. + local Kit = require("Kit") + local MovePicker = require("MovePicker") + local tmpPath = os.tmpname() .. "-movepicker-save.lua" + local data = SaveData.newGame() + data.party = { MonOps.create(Data, "WARTORTLE", 20) } + local f = io.open(tmpPath, "wb") + f:write(SaveData.encode(data)) + f:close() + + App.load(tmpPath, { version = "red" }) + local S = App.getState() + S.tab = "party" + + check(Ops.openMovePicker(S, Kit, 1) == false, + "the move picker refuses to open with no slot selected") + check(S.movePicker == nil, "and it stayed closed") + check(S.status:match("Pick a slot") ~= nil, "and it said why") + + Ops.selectParty(S, 1) + check(Ops.openMovePicker(S, Kit, 0) == false, "slot 0 is refused") + check(Ops.openMovePicker(S, Kit, 1) == true, "the move picker opens on slot 1") + check(S.movePicker ~= nil, "the picker is up") + eq(S.movePicker.slot, 1, "for the requested slot") + eq(S.movePicker.query, "", "it opens with an empty query") + eq(Kit.focus, "move-picker", "it opens with the field focused (#529 keyboard)") + + local ok, err = pcall(App.draw) + check(ok, "the move picker draws headlessly: " .. tostring(err)) + + App.textinput("THUNDERBOLT") + ok, err = pcall(App.draw) + check(ok, "the move picker draws while typing: " .. tostring(err)) + eq(S.movePicker.query, "THUNDERBOLT", "typing reaches the picker's field") + eq(#MovePicker.results(S), 1, "the list narrowed to the typed move") + + App.keypressed("return") + eq(S.save.party[1].moves[1].id, "THUNDERBOLT", "Enter commits the top match") + check(S.movePicker == nil, "and closes the picker") + check(S.dirty, "and the save is dirty") + + -- Escape leaves without touching the mon + local before = S.save.party[1].moves[1].id + Ops.openMovePicker(S, Kit, 1) + App.textinput("SURF") + App.draw() + App.keypressed("escape") + check(S.movePicker == nil, "Escape closes the move picker") + eq(S.save.party[1].moves[1].id, before, "Escape did not commit anything") + check(S.editingMon ~= nil, "Escape closed the picker, not the selection") + + -- a query nothing matches cannot commit + Ops.openMovePicker(S, Kit, 2) + App.textinput("zzzznope") + App.draw() + App.keypressed("return") + check(S.movePicker ~= nil, "Enter on an empty result set keeps the picker up") + check(S.status:match("No move matches") ~= nil, "and says so") + Ops.closeMovePicker(S, Kit) + + -- Ops.setMove refuses a scalar / missing id + check(Ops.setMove(S, S.editingMon, 1, "generation") == false, + "setMove refuses a provenance scalar") + eq(S.save.party[1].moves[1].id, "THUNDERBOLT", "and leaves the slot alone") + + os.remove(tmpPath) + for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end +end + do -- The inspector's nickname field is commit-on-Enter: the draft lives in -- S.nicknameDraft while typing, Enter commits it through Ops.setNickname, @@ -1127,10 +1234,13 @@ do -- 720x1280 / 1280x720 are the #715 report's shapes (Android, both -- orientations): the Map tab used to lay its viewport out at a negative - -- width in portrait and crash on the scissor. The desktop sizes pin that - -- the responsive reflow does not disturb the layouts that already worked. + -- width in portrait and crash on the scissor. RGxxx / Switch shapes pin + -- short-landscape handhelds (RG34XXSP 720x480, RG35XX 640x480, NX 1280x720) + -- and a tiny 360x640 phone. Desktop sizes keep the layouts that already + -- worked. for _, size in ipairs({ { 720, 1560 }, { 1560, 720 }, { 480, 1040 }, { 1280, 800 }, { 720, 1280 }, { 1280, 720 }, + { 720, 480 }, { 640, 480 }, { 480, 320 }, { 1024, 768 }, { 1920, 1080 }, { 360, 640 } }) do love.graphics.getDimensions = function() return size[1], size[2] end App.load(tmpPath, { version = "red" }) @@ -1153,6 +1263,12 @@ do ok, err = pcall(App.draw) check(ok, ("the species picker redraws at %s: %s"):format(label, tostring(err))) Ops.closeSpeciesPicker(S, Kit) + Ops.openMovePicker(S, Kit, 1) + ok, err = pcall(App.draw) + check(ok, ("the move picker draws at %s: %s"):format(label, tostring(err))) + ok, err = pcall(App.draw) + check(ok, ("the move picker redraws at %s: %s"):format(label, tostring(err))) + Ops.closeMovePicker(S, Kit) end love.graphics.getDimensions = oldDimensions @@ -1233,8 +1349,12 @@ do f:close() local oldDimensions = love.graphics.getDimensions + -- Include RG34XXSP (720x480) and Switch handheld; keep 640x480 out of the + -- overlap audit (Map still needs its own short-landscape pass) but the + -- phone draw suite above already covers it. local sizes = { { 500, 800 }, { 720, 1280 }, { 1280, 720 }, - { 1024, 768 }, { 900, 700 }, { 1920, 1080 } } + { 720, 480 }, { 1024, 768 }, + { 900, 700 }, { 1920, 1080 } } for _, size in ipairs(sizes) do local W, H = size[1], size[2] love.graphics.getDimensions = function() return W, H end @@ -1266,6 +1386,14 @@ do if ok then auditFrame(("%dx%d species picker"):format(W, H), W, H) end Kit.audit = nil Ops.closeSpeciesPicker(S, Kit) + Ops.openMovePicker(S, Kit, 1) + App.draw() + Kit.audit = {} + ok, err = pcall(App.draw) + check(ok, ("%dx%d move picker draws: %s"):format(W, H, tostring(err))) + if ok then auditFrame(("%dx%d move picker"):format(W, H), W, H) end + Kit.audit = nil + Ops.closeMovePicker(S, Kit) end love.graphics.getDimensions = oldDimensions @@ -1349,5 +1477,150 @@ do for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end +do + -- #917: Pixel 9a Save was dead because the title cluster sat in the + -- cutout / status unsafe band. Chrome and modal search fields must live + -- inside love.window.getSafeArea (background may still paint full-bleed). + local Kit = require("Kit") + local tmpPath = os.tmpname() .. "-safe-area-save.lua" + local f = io.open(tmpPath, "wb") + f:write(SaveData.encode(SaveData.newGame())) + f:close() + + local W, H = 720, 1560 + local ox, oy, sw, sh = 0, 64, 720, 1456 -- punch-hole + home indicator + local oldDimensions = love.graphics.getDimensions + local oldSafe = love.window.getSafeArea + love.graphics.getDimensions = function() return W, H end + love.window.getSafeArea = function() return ox, oy, sw, sh end + + App.load(tmpPath, { version = "blue" }) + local S = App.getState() + Ops.partyAdd(S) + S.dirty = true + S.allowSave = true + S.tab = "party" + + local function insideSafe(rects, where) + for _, r in ipairs(rects or {}) do + if r.class == "control" then + check(r.x >= ox - 0.5, + where .. " '" .. r.label .. "' is right of the left inset") + check(r.y >= oy - 0.5, + where .. " '" .. r.label .. "' is below the notch (#917)") + check(r.x + r.w <= ox + sw + 0.5, + where .. " '" .. r.label .. "' stays left of the right inset") + check(r.y + r.h <= oy + sh + 0.5, + where .. " '" .. r.label .. "' stays above the home indicator") + end + end + end + + Kit.audit = {} + local ok, err = pcall(App.draw) + check(ok, "editor draws inside an inset safe area: " .. tostring(err)) + insideSafe(Kit.audit, "chrome") + + local saveBtn + for _, r in ipairs(Kit.audit) do + if r.class == "control" and (r.label == "SAVE" or r.label == "SAVED" + or r.label == "SAVE LOCKED") then + saveBtn = r + break + end + end + check(saveBtn ~= nil, "the Save button was audited") + if saveBtn then + check(saveBtn.y >= oy - 0.5, + "Save clears the top safe inset (#917)") + check(saveBtn.y + saveBtn.h <= oy + sh + 0.5, + "Save stays above the bottom safe inset") + end + Kit.audit = nil + + -- Species-picker search field must also clear the notch once chrome is inset. + Ops.openSpeciesPicker(S, Kit) + App.draw() -- opening frame is fully shielded + Kit.audit = {} + ok, err = pcall(App.draw) + check(ok, "species picker draws inside an inset safe area: " .. tostring(err)) + insideSafe(Kit.audit, "species picker") + local search + for _, r in ipairs(Kit.audit) do + if r.class == "control" and r.label == "species-picker" then + search = r + break + end + end + check(search ~= nil, "the species search field was audited") + if search then + check(search.y >= oy - 0.5, + "species search clears the top safe inset (#917)") + end + Kit.audit = nil + Ops.closeSpeciesPicker(S, Kit) + + love.graphics.getDimensions = oldDimensions + love.window.getSafeArea = oldSafe + os.remove(tmpPath) + for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end +end + +do + -- PickerChrome: short RGxxx / phone landscapes must nearly fill SafeArea + -- (not sit in a 32px-guttered desk card that leaves no list body), and every + -- interactive metric stays at or above the 26px tap floor. + local Kit = require("Kit") + local PickerChrome = require("PickerChrome") + local oldDimensions = love.graphics.getDimensions + local oldSafe = love.window.getSafeArea + + local function checkDevice(W, H, safe, label) + love.graphics.getDimensions = function() return W, H end + if safe then + love.window.getSafeArea = function() + return safe[1], safe[2], safe[3], safe[4] + end + else + love.window.getSafeArea = function() return 0, 0, W, H end + end + Kit.layout(safe and safe[3] or W, safe and safe[4] or H) + local x, y, w, h, pad = PickerChrome.card(Kit, W, H) + local ox = safe and safe[1] or 0 + local oy = safe and safe[2] or 0 + local sw = safe and safe[3] or W + local sh = safe and safe[4] or H + check(w > 0 and h > 0, label .. ": card has positive size") + check(x >= ox - 0.5 and y >= oy - 0.5, + label .. ": card origin stays inside the safe rect") + check(x + w <= ox + sw + 0.5 and y + h <= oy + sh + 0.5, + label .. ": card fits inside the safe rect") + -- Short landscapes should use almost all of the safe height. + if sh <= 560 * Kit.scale + 40 then + check(h >= sh * 0.85, + label .. ": short landscape card fills most of the safe height") + end + local tap = PickerChrome.tapMin(Kit) + check(tap >= 26, label .. ": tapMin is at least 26px") + check(PickerChrome.fieldH(Kit) >= tap, label .. ": search field meets tapMin") + check(PickerChrome.closeSize(Kit) >= tap, label .. ": close meets tapMin") + local listH, rowH = PickerChrome.listMetrics(Kit, y, h, pad, + y + pad + 80 * Kit.scale) + check(listH >= 0, label .. ": list height is non-negative") + check(rowH >= tap, label .. ": list rows meet tapMin") + end + + checkDevice(720, 480, nil, "RG34XXSP 720x480") + checkDevice(640, 480, nil, "RG35XX 640x480") + checkDevice(1280, 720, nil, "Switch handheld 1280x720") + checkDevice(720, 1280, { 0, 64, 720, 1176 }, "Pixel portrait + notch") + checkDevice(1280, 720, { 48, 0, 1184, 720 }, "landscape + side cutout") + checkDevice(360, 640, nil, "tiny phone 360x640") + checkDevice(1920, 1080, nil, "desktop 1080p") + + love.graphics.getDimensions = oldDimensions + love.window.getSafeArea = oldSafe +end + print(string.format("save editor tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index b64816f4..4948c208 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -9,7 +9,11 @@ -- * Edit on a launcher save row (main.lua, embedded = true), Close returns -- to the launcher with the slot list refreshed -- --- Vertical rhythm (scaled by Kit's height/768 factor, everything else flexes): +-- Vertical rhythm inside the platform safe area (scaled by Kit's height/768 +-- factor, everything else flexes). Background still fills the full window so +-- the notch / home-indicator bands match the field colour; interactive chrome +-- starts at SafeArea.rect() so Save stays reachable on a punch-hole phone +-- (#917). Offsets below are relative to that safe origin: -- 0 6px tri-colour version rail, identical to the launcher's -- 6 64px title bar identity, file chip, Save / Reload / Open / Close -- (104px when the bar reflows to two rows, #715) @@ -18,6 +22,7 @@ -- -38 38px status bar the last Ops message + the keyboard map local Data = require("src.core.Data") +local SafeArea = require("src.core.SafeArea") local TileRenderer = require("src.render.TileRenderer") local SaveIO = require("SaveIO") local Catalog = require("Catalog") @@ -37,6 +42,7 @@ local MapBrowser = require("MapBrowser") local Dex = require("Dex") -- chrome, not a tab panel, so deliberately kept out of PANELS below (#541) local SpeciesPicker = require("SpeciesPicker") +local MovePicker = require("MovePicker") local ItemPicker = require("ItemPicker") local App = {} @@ -772,7 +778,17 @@ function App.draw() -- tolerates that rather than indexing a torn-down state. if not S then return end local width, height = love.graphics.getDimensions() - Kit.layout(width, height) + width = math.max(1, tonumber(width) or 1) + height = math.max(1, tonumber(height) or 1) + -- Usable chrome rect. Background still fills the window so the notch / + -- home-indicator bands stay the field colour; every button (Save first) + -- lives inside the safe area, matching the launcher and Skin Studio (#917). + local ox, oy, sw, sh = SafeArea.rect() + ox = math.max(0, tonumber(ox) or 0) + oy = math.max(0, tonumber(oy) or 0) + sw = math.max(1, tonumber(sw) or width) + sh = math.max(1, tonumber(sh) or height) + Kit.layout(sw, sh) local s = Kit.scale local mx, my = love.mouse.getPosition() @@ -791,6 +807,7 @@ function App.draw() -- shield goes up before anything dispatches and comes down only for the -- picker's own layer at the bottom of this function (#541). Kit.blockClicks = (S.speciesPicker ~= nil) or (S.itemPicker ~= nil) + or (S.movePicker ~= nil) Theme.field(width, height) @@ -799,26 +816,29 @@ function App.draw() -- the window is too narrow for both on one, instead of the buttons and the -- identity painting through each other (#715). The taller bar simply -- costs the content column height, which scrolls. - local titleTwoRow = titleNeedsTwoRows(width) + local titleTwoRow = titleNeedsTwoRows(sw) local titleH = (titleTwoRow and 104 or 64) * s local tabH = 66 * s local statusH = 38 * s - Theme.versionRail(0, 0, width, railH) - drawTitleBar(0, railH, width, titleH, titleTwoRow) - drawTabRail(0, railH + titleH, width, tabH) + Theme.versionRail(ox, oy, sw, railH) + drawTitleBar(ox, oy + railH, sw, titleH, titleTwoRow) + drawTabRail(ox, oy + railH + titleH, sw, tabH) - local contentY = railH + titleH + tabH - local contentH = height - contentY - statusH + local contentY = oy + railH + titleH + tabH + local contentH = sh - railH - titleH - tabH - statusH local panel = PANELS[S.tab] if panel then - panel.draw(S, Kit, 22 * s, contentY + 20 * s, - width - 44 * s, contentH - 38 * s) + panel.draw(S, Kit, ox + 22 * s, contentY + 20 * s, + sw - 44 * s, contentH - 38 * s) end - drawStatusBar(0, height - statusH, width, statusH) + drawStatusBar(ox, oy + sh - statusH, sw, statusH) Kit.blockClicks = false + -- Scrim still covers the full window (including unsafe bands); the card + -- itself is centred in the safe rect so search fields clear the notch. SpeciesPicker.draw(S, Kit, width, height) + MovePicker.draw(S, Kit, width, height) ItemPicker.draw(S, Kit, width, height) Kit.endFrame() PadInput.draw() @@ -841,6 +861,15 @@ function App.keypressed(key) return end end + if S.movePicker then + if key == "return" or key == "kpenter" then + MovePicker.commitFirst(S, Kit) + return + elseif key == "escape" then + Ops.closeMovePicker(S, Kit) + return + end + end if S.speciesPicker then if key == "return" or key == "kpenter" then SpeciesPicker.commitFirst(S, Kit) diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index 2197d7a4..7c1fe851 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -11,7 +11,8 @@ -- 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. +-- here. Kit.tapMin() is the px floor after scale (a short handheld sits on +-- the 0.9 scale floor, so bare `26 * s` would slip under 26). local Theme = require("Theme") local PAL = Theme.PAL @@ -25,6 +26,10 @@ Kit.time = 0 Kit.fonts = {} Kit.scale = 1 +function Kit.tapMin() + return math.max(26, math.floor(30 * (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 diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index c5e2a33d..fe26cec9 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -329,6 +329,31 @@ function Ops.speciesSearch(S, query) for _, id in ipairs(S.cat.species) do if Ops.speciesMatches(S, id, query) then out[#out + 1] = id end end + -- Rank hits so a typed prefix ("pika") puts PIKACHU above mid-string + -- noise; empty query keeps the catalog's A-Z order. + if query and tostring(query) ~= "" then + local q = tostring(query):lower() + local function rank(id) + local idLower = id:lower() + local def = S.data.pokemon[id] + local nameLower = def and def.name and tostring(def.name):lower() or "" + if idLower == q or nameLower == q then return 0 end + if idLower:sub(1, #q) == q + or (nameLower ~= "" and nameLower:sub(1, #q) == q) then + return 1 + end + if idLower:find(q, 1, true) + or (nameLower ~= "" and nameLower:find(q, 1, true)) then + return 2 + end + return 3 -- dex-number hit + end + table.sort(out, function(a, b) + local ra, rb = rank(a), rank(b) + if ra ~= rb then return ra < rb end + return a < b + end) + end return out end @@ -429,6 +454,8 @@ function Ops.setDv(S, mon, key, value) return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp)) end +-- Kept for tests and any keyboard path; the inspector opens the searchable +-- picker instead of walking the catalog one tap at a time. function Ops.cycleMove(S, mon, slot) if not mon or not (S.cat and S.cat.moves and #S.cat.moves > 0) then return false end local moves = S.cat.moves @@ -441,14 +468,117 @@ function Ops.cycleMove(S, mon, slot) end for step = 1, #moves do local nextId = moves[((idx + step - 1) % #moves) + 1] - if S.data and S.data.moves and type(S.data.moves[nextId]) == "table" then - MonOps.setMove(S.data, mon, slot, nextId) - return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId)) + if Ops.moveUsable(S, nextId) then + return Ops.setMove(S, mon, slot, nextId) end end return false end +-- A move record is usable when it is a real table with a numeric PP -- the +-- same floor Catalog already uses to keep provenance scalars out of the list. +function Ops.moveUsable(S, id) + local def = id and S.data and S.data.moves and S.data.moves[id] + return type(def) == "table" and type(def.pp) == "number" +end + +-- Search predicate behind the move picker's field: id, display name, and type +-- substring-match case-insensitively; power / accuracy match as whole numbers +-- (same plain-text / no-pattern rule as Ops.speciesMatches). +function Ops.moveMatches(S, id, query) + if not query or query == "" then return true end + local q = tostring(query):lower() + if id:lower():find(q, 1, true) then return true end + local def = S.data.moves[id] + if type(def) ~= "table" then return false end + local name = def.name + if name and tostring(name):lower():find(q, 1, true) then return true end + local typ = def.type + if typ and tostring(typ):lower():find(q, 1, true) then return true end + local power = tonumber(def.power) + if power ~= nil and q == tostring(power) then return true end + local accuracy = tonumber(def.accuracy) + return accuracy ~= nil and q == tostring(accuracy) +end + +function Ops.moveSearch(S, query) + local out = {} + for _, id in ipairs(S.cat.moves or {}) do + if Ops.moveMatches(S, id, query) then out[#out + 1] = id end + end + -- Rank hits so a typed prefix ("sur") puts SURF above mid-string noise + -- like ACUPRESSURE / FISSURE; empty query keeps the catalog's A-Z order. + if query and tostring(query) ~= "" then + local q = tostring(query):lower() + local function rank(id) + local idLower = id:lower() + local def = S.data.moves[id] + local nameLower = (type(def) == "table" and def.name) + and tostring(def.name):lower() or "" + if idLower == q or nameLower == q then return 0 end + if idLower:sub(1, #q) == q + or (nameLower ~= "" and nameLower:sub(1, #q) == q) then + return 1 + end + if idLower:find(q, 1, true) + or (nameLower ~= "" and nameLower:find(q, 1, true)) then + return 2 + end + return 3 -- type / power / accuracy hit + end + table.sort(out, function(a, b) + local ra, rb = rank(a), rank(b) + if ra ~= rb then return ra < rb end + return a < b + end) + end + return out +end + +-- One funnel for assigning a move (picker commit and cycleMove). Refuses +-- unknown / scalar ids before MonOps asserts, and speaks in the status bar. +function Ops.setMove(S, mon, slot, id) + if not mon then return false end + slot = math.floor(tonumber(slot) or 0) + if slot < 1 or slot > 4 then return false end + local current = mon.moves and mon.moves[slot] and mon.moves[slot].id + if id == current then + return Ops.say(S, ("Move %d is already %s"):format(slot, tostring(id))) + end + if not Ops.moveUsable(S, id) then + return Ops.say(S, ("%s is not a usable move, cannot assign it") + :format(tostring(id))) + end + local ok, err = pcall(MonOps.setMove, S.data, mon, slot, id) + if not ok then + return Ops.say(S, ("Could not set move %d: %s"):format(slot, tostring(err))) + end + return Ops.mark(S, ("Move %d set to %s"):format(slot, id)) +end + +-- Modal door for the move picker. `slot` is which of the four move rows the +-- inspector opened; the picker writes back through Ops.setMove on commit. +function Ops.openMovePicker(S, Kit, slot) + if not S.editingMon then + return Ops.say(S, "Pick a slot first, then choose a move") + end + slot = math.floor(tonumber(slot) or 0) + if slot < 1 or slot > 4 then + return Ops.say(S, "Move slots are 1 through 4") + end + if not (S.cat and S.cat.moves and #S.cat.moves > 0) then + return Ops.say(S, "No moves in the catalog") + end + S.movePicker = { query = "", offset = 0, opened = true, slot = slot } + if Kit then Kit.focus = "move-picker" end + return true +end + +function Ops.closeMovePicker(S, Kit) + S.movePicker = nil + if Kit and Kit.blur then Kit.blur() end +end + function Ops.clearMove(S, mon, slot) if not (mon and mon.moves and mon.moves[slot]) then return Ops.say(S, ("Move slot %d is already empty"):format(slot)) diff --git a/tools/save-editor/PickerChrome.lua b/tools/save-editor/PickerChrome.lua new file mode 100644 index 00000000..130f3a54 --- /dev/null +++ b/tools/save-editor/PickerChrome.lua @@ -0,0 +1,83 @@ +-- Shared modal-picker frame for Species / Move / Item pickers. +-- +-- Desktop keeps a centred card capped at 520x560 logical px. Phones and +-- RGxxx handhelds (RG34XXSP 720x480, RG35XX 640x480, Switch 1280x720, tall +-- portrait Androids) need the card to nearly fill SafeArea.rect() so the +-- search field, list, and pager stay usable instead of collapsing under a +-- fixed 32px margin into a short landscape window (#917 / #715). + +local SafeArea = require("src.core.SafeArea") + +local PickerChrome = {} + +-- Minimum tap target (design rule 6). Scale can sit on the 0.9 floor on a +-- short handheld, so a bare `26 * s` would dip under 26px -- clamp in px. +function PickerChrome.tapMin(Kit) + local s = (Kit and Kit.scale) or 1 + return math.max(26, math.floor(30 * s)) +end + +-- Usable card rect inside the platform safe area. +-- Returns x, y, w, h, pad. +function PickerChrome.card(Kit, windowW, windowH) + local s = (Kit and Kit.scale) or 1 + windowW = math.max(1, tonumber(windowW) or 1) + windowH = math.max(1, tonumber(windowH) or 1) + local sox, soy, ssw, ssh = SafeArea.rect() + sox = math.max(0, tonumber(sox) or 0) + soy = math.max(0, tonumber(soy) or 0) + ssw = math.max(1, tonumber(ssw) or windowW) + ssh = math.max(1, tonumber(ssh) or windowH) + + -- Shrink the gutter on short / narrow safe areas so the card keeps room + -- for caption + search + at least one list row + pager. + local gutter = math.floor(16 * s) + local minSide = math.min(ssw, ssh) + if minSide < 560 * s then + gutter = math.max(4, math.floor(minSide * 0.03)) + end + + local maxW = math.floor(520 * s) + local maxH = math.floor(560 * s) + local w = math.min(ssw - 2 * gutter, maxW) + local h = math.min(ssh - 2 * gutter, maxH) + -- Short landscapes (720x480 class): fill the safe height instead of + -- leaving letterbox bands inside an already-short rect. + if ssh <= maxH + 2 * gutter then + h = ssh - 2 * gutter + end + if ssw <= maxW + 2 * gutter then + w = ssw - 2 * gutter + end + w = math.max(1, w) + h = math.max(1, h) + + local x = sox + (ssw - w) / 2 + local y = soy + (ssh - h) / 2 + local pad = math.max(8, math.floor(math.min(18 * s, w * 0.04, h * 0.04))) + return x, y, w, h, pad +end + +-- List-body metrics once caption / field / optional extra chrome are placed. +-- `contentTop` is the y just below that chrome; returns listH, rowH, rowGap, +-- pagerH sized so taps stay >= tapMin and listH never goes negative. +function PickerChrome.listMetrics(Kit, cardY, cardH, pad, contentTop) + local s = (Kit and Kit.scale) or 1 + local tap = PickerChrome.tapMin(Kit) + local pagerH = math.max(tap, math.floor(30 * s)) + local rowH = math.max(tap, math.floor(40 * s)) + local rowGap = math.max(4, math.floor(6 * s)) + local listBottom = cardY + cardH - pad - pagerH - math.max(6, math.floor(10 * s)) + local listH = math.max(0, listBottom - contentTop) + return listH, rowH, rowGap, pagerH +end + +function PickerChrome.fieldH(Kit) + return math.max(PickerChrome.tapMin(Kit), math.floor(34 * ((Kit and Kit.scale) or 1))) +end + +function PickerChrome.closeSize(Kit) + return math.max(PickerChrome.tapMin(Kit), math.floor(30 * ((Kit and Kit.scale) or 1))) +end + +return PickerChrome diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index 534d5411..de9acaa3 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -53,6 +53,11 @@ function State.new() -- without a z-order (#541). speciesPicker = nil, + -- move picker overlay: nil when closed, otherwise + -- { query, offset, slot = 1..4 }. Same modal contract as speciesPicker; + -- the inspector opens it instead of cycling the catalog one tap at a time. + movePicker = nil, + -- item picker overlay: nil when closed, otherwise -- { query, offset, dest = "bag"|"pc" }. Same modal contract as -- speciesPicker above -- adding an item is now a full-screen picker diff --git a/tools/save-editor/panels/ItemPicker.lua b/tools/save-editor/panels/ItemPicker.lua index 0bb91dc8..e3ec9351 100644 --- a/tools/save-editor/panels/ItemPicker.lua +++ b/tools/save-editor/panels/ItemPicker.lua @@ -13,6 +13,7 @@ local Theme = require("Theme") local Ops = require("Ops") +local PickerChrome = require("PickerChrome") local PAL = Theme.PAL local Picker = {} @@ -57,38 +58,39 @@ function Picker.draw(S, Kit, width, height) Kit.blockClicks = true end - -- the scrim doubles as the "tap outside to cancel" target + -- the scrim doubles as the "tap outside to cancel" target; it covers the + -- full window so unsafe bands (notch / home indicator) stay dimmed too Theme.col(PAL.bg, 0.82) love.graphics.rectangle("fill", 0, 0, width, height) - local w = math.min(width - 32 * s, 520 * s) - local h = math.min(height - 32 * s, 560 * s) - local x = (width - w) / 2 - local y = (height - h) / 2 + -- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a + -- usable list (#917 / #715). + local x, y, w, h, pad = PickerChrome.card(Kit, width, height) if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then Ops.closeItemPicker(S, Kit) return end Kit.card(x, y, w, h) - local pad = 18 * s local cx, cy = x + pad, y + pad local inner = w - 2 * pad - Kit.caption(cx, cy, "ADD AN ITEM") - local closeW = 30 * s - if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x", + local closeW = PickerChrome.closeSize(Kit) + local captionH = Kit.textHeight("caption") + local headH = math.max(captionH, closeW) + Kit.caption(cx, cy + (headH - captionH) / 2, "ADD AN ITEM") + if Kit.button(x + w - pad - closeW, cy + (headH - closeW) / 2, closeW, closeW, "x", { font = "small" }) then Ops.closeItemPicker(S, Kit) return end - cy = cy + Kit.textHeight("caption") + 10 * s + cy = cy + headH + 10 * s -- Destination toggle. Which list an item lands in is the only real choice -- here, so it is a pair of chips at the top rather than two buttons at the -- bottom that each mean "commit, and also pick a destination". local half = (inner - 8 * s) / 2 - local destH = 30 * s + local destH = math.max(PickerChrome.tapMin(Kit), math.floor(30 * s)) if Kit.chip(cx, cy, half, destH, "-> BAG", p.dest ~= "pc", PAL.green, PAL.steel) then p.dest = "bag" end @@ -98,16 +100,13 @@ function Picker.draw(S, Kit, width, height) end cy = cy + destH + 10 * s - local fieldH = 34 * s + local fieldH = PickerChrome.fieldH(Kit) p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query, "type an item id") cy = cy + fieldH + 10 * s local hits = Picker.results(S) - local rowH = 36 * s - local rowGap = 6 * s - local pagerH = 30 * s - local listH = (y + h - pad - pagerH - 10 * s) - cy + local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy) local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage)) -- wheel / touch drag scroll the modal list too; the shield is already diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index 35757324..4b4df13a 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -213,7 +213,7 @@ local function drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap) local ry = rowY + (slot - 1) * (rowH + rowGap) Theme.row(rightX, ry, colW, rowH, 10 * s, 0.6) local mv = mon.moves and mon.moves[slot] - local clear = 24 * s + local clear = Kit.tapMin() local clearX = rightX + colW - 10 * s - clear local ppText = mv and ("PP %d"):format(mv.pp or 0) or "" local ppW = Kit.textWidth("tiny", ppText) @@ -226,9 +226,9 @@ local function drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap) mv and PAL.text or PAL.faint) Kit.textRight("tiny", ppText, clearX - 10 * s, ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption) - -- the row body cycles, the x empties: two targets, no modal picker + -- the row body opens the searchable picker; the x empties the slot if Kit.press(rightX, ry, clearX - rightX - 4 * s, rowH) then - Ops.cycleMove(S, mon, slot) + Ops.openMovePicker(S, Kit, slot) end if Kit.button(clearX, ry + (rowH - clear) / 2, clear, clear, "x", { kind = "danger", font = "tiny", radius = 6 * s }) then @@ -264,10 +264,10 @@ function MonEditor.draw(S, Kit, x, y, w, h) -- text keeps room. local narrow = inner < 470 * s local sprite = (narrow and 64 or 96) * s - local rowH = 30 * s + local rowH = math.max(Kit.tapMin(), 30 * s) local rowGap = 8 * s local cellH = 52 * s - local actH = 34 * s + local actH = math.max(Kit.tapMin(), 34 * s) local hw = inner - sprite - 18 * s local levelInHeader = hw >= levelRowWidth(Kit, mon) @@ -449,7 +449,7 @@ function MonEditor.draw(S, Kit, x, y, w, h) local movesY = rowY + colRowsH + 14 * s Kit.caption(cx, movesY, "MOVES") - Kit.textRight("tiny", "click a slot to cycle", cx + inner, movesY, PAL.caption) + Kit.textRight("tiny", "click a slot to search", cx + inner, movesY, PAL.caption) local mRowY = movesY + capH + 10 * s drawMoveRows(S, Kit, mon, cx, mRowY, inner, rowH, rowGap) @@ -472,7 +472,7 @@ function MonEditor.draw(S, Kit, x, y, w, h) Kit.textRight("tiny", ("HP DV auto-derived . %d"):format(mon.dvs.hp or 0), cx + colW, colY, PAL.caption) Kit.caption(rightX, colY, "MOVES") - Kit.textRight("tiny", "click a slot to cycle", rightX + colW, colY, PAL.caption) + Kit.textRight("tiny", "click a slot to search", rightX + colW, colY, PAL.caption) local rowY = colY + capH + 10 * s drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap) diff --git a/tools/save-editor/panels/MovePicker.lua b/tools/save-editor/panels/MovePicker.lua new file mode 100644 index 00000000..f229dc54 --- /dev/null +++ b/tools/save-editor/panels/MovePicker.lua @@ -0,0 +1,136 @@ +-- Type-to-search move picker. The inspector used to change moves by cycling +-- the catalog one tap at a time -- 165 taps to walk Gen1 -- which is the same +-- class of friction the species arrows had before #541. This is the +-- replacement: one modal list, filtered as you type, committing through +-- Ops.setMove so an unusable / scalar id refuses instead of asserting. +-- +-- Modal is literal. Kit hit-tests without a z-order, so App.draw raises +-- Kit.blockClicks over the chrome and the panel while this is open and lowers +-- it only for this overlay; nothing underneath can take the same tap. + +local Theme = require("Theme") +local Ops = require("Ops") +local PickerChrome = require("PickerChrome") +local PAL = Theme.PAL + +local Picker = {} + +local FIELD_ID = "move-picker" + +function Picker.results(S) + local p = S.movePicker + return Ops.moveSearch(S, p and p.query or "") +end + +local function commit(S, id) + local p = S.movePicker + if not p then return false end + return Ops.setMove(S, S.editingMon, p.slot, id) +end + +-- Enter commits the top match, which is the whole point of a search field. +function Picker.commitFirst(S, Kit) + local hits = Picker.results(S) + if not hits[1] then return Ops.say(S, "No move matches that") end + local ok = commit(S, hits[1]) + if ok then Ops.closeMovePicker(S, Kit) end + return ok +end + +function Picker.draw(S, Kit, width, height) + local p = S.movePicker + if not p then return end + local s = Kit.scale + + -- The click that opened the picker is still the frame's click: the + -- inspector dispatches earlier in App.draw than this overlay does, so + -- without swallowing it the scrim below would read it as a tap outside and + -- shut the picker in the same frame it went up. App re-raises the shield + -- at the top of the next frame, so leaving it up here is safe. + if p.opened then + p.opened = nil + Kit.blockClicks = true + end + + -- the scrim doubles as the "tap outside to cancel" target; it covers the + -- full window so unsafe bands (notch / home indicator) stay dimmed too + Theme.col(PAL.bgBot, 0.72) + love.graphics.rectangle("fill", 0, 0, width, height) + + -- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a + -- usable list (#917 / #715). + local x, y, w, h, pad = PickerChrome.card(Kit, width, height) + if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then + Ops.closeMovePicker(S, Kit) + return + end + + Kit.card(x, y, w, h) + local cx, cy = x + pad, y + pad + local inner = w - 2 * pad + + local closeW = PickerChrome.closeSize(Kit) + local captionH = Kit.textHeight("caption") + local headH = math.max(captionH, closeW) + Kit.caption(cx, cy + (headH - captionH) / 2, ("CHOOSE MOVE %d"):format(p.slot or 1)) + if Kit.button(x + w - pad - closeW, cy + (headH - closeW) / 2, closeW, closeW, "x", + { font = "small", radius = 7 * s }) then + Ops.closeMovePicker(S, Kit) + return + end + cy = cy + headH + 10 * s + + local fieldH = PickerChrome.fieldH(Kit) + p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query, + "type a name, an id, or a type") + cy = cy + fieldH + 10 * s + + local hits = Picker.results(S) + local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy) + local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) + p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage)) + -- wheel / touch drag scroll the modal list too; the shield is already + -- lowered for this layer, so Kit.scroll works here and only here (#715) + p.offset = Kit.scroll(cx, cy, inner, listH, p.offset, #hits, perPage) + + local mon = S.editingMon + local currentId = mon and mon.moves and mon.moves[p.slot] + and mon.moves[p.slot].id + + if #hits == 0 then + Kit.emptyBox(cx, cy, inner, listH, "Nothing matches that.") + else + Kit.pushClip(cx, cy, inner, listH) + for i = 1, perPage do + local id = hits[p.offset + i] + if not id then break end + local ry = cy + (i - 1) * (rowH + rowGap) + local def = S.data.moves[id] + local usable = Ops.moveUsable(S, id) + local current = currentId == id + if Kit.row(cx, ry, inner, rowH, current, PAL.green, 9 * s) then + if commit(S, id) then + Ops.closeMovePicker(S, Kit) + Kit.popClip() + return + end + end + local typ = usable and tostring(def and def.type or "?") or "no data" + local pp = usable and ("PP %d"):format(tonumber(def and def.pp) or 0) or "" + local tail = pp ~= "" and (typ .. " " .. pp) or typ + local tailW = Kit.textWidth("tiny", tail) + Kit.text("monoRow", + Kit.ellipsize("monoRow", id, inner - tailW - 28 * s), + cx + 12 * s, ry + (rowH - Kit.textHeight("monoRow")) / 2, + usable and PAL.text or PAL.faint) + Kit.textRight("tiny", tail, cx + inner - 10 * s, + ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption) + end + Kit.popClip() + Kit.scrollbar(cx, cy, inner, listH, p.offset, #hits, perPage) + end + + p.offset = Kit.pager(cx, y + h - pad - pagerH, inner, p.offset, #hits, perPage) +end + +return Picker diff --git a/tools/save-editor/panels/SpeciesPicker.lua b/tools/save-editor/panels/SpeciesPicker.lua index e7c2710d..8af5b377 100644 --- a/tools/save-editor/panels/SpeciesPicker.lua +++ b/tools/save-editor/panels/SpeciesPicker.lua @@ -12,6 +12,7 @@ local Theme = require("Theme") local Ops = require("Ops") local MonEditor = require("MonEditor") +local PickerChrome = require("PickerChrome") local PAL = Theme.PAL local Picker = {} @@ -59,44 +60,42 @@ function Picker.draw(S, Kit, width, height) Kit.blockClicks = true end - -- the scrim doubles as the "tap outside to cancel" target + -- the scrim doubles as the "tap outside to cancel" target; it covers the + -- full window so unsafe bands (notch / home indicator) stay dimmed too Theme.col(PAL.bgBot, 0.72) love.graphics.rectangle("fill", 0, 0, width, height) - local w = math.min(width - 32 * s, 520 * s) - local h = math.min(height - 32 * s, 560 * s) - local x = (width - w) / 2 - local y = (height - h) / 2 + -- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a + -- usable list (#917 / #715). + local x, y, w, h, pad = PickerChrome.card(Kit, width, height) if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then Ops.closeSpeciesPicker(S, Kit) return end Kit.card(x, y, w, h) - local pad = 18 * s local cx, cy = x + pad, y + pad local inner = w - 2 * pad - Kit.caption(cx, cy, p.mode == "box-add" + local closeW = PickerChrome.closeSize(Kit) + local captionH = Kit.textHeight("caption") + local headH = math.max(captionH, closeW) + Kit.caption(cx, cy + (headH - captionH) / 2, p.mode == "box-add" and ("ADD TO BOX %d"):format(S.selectedBox or 1) or "CHOOSE A SPECIES") - local closeW = 30 * s - if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x", + if Kit.button(x + w - pad - closeW, cy + (headH - closeW) / 2, closeW, closeW, "x", { font = "small", radius = 7 * s }) then Ops.closeSpeciesPicker(S, Kit) return end - cy = cy + Kit.textHeight("caption") + 10 * s + cy = cy + headH + 10 * s - local fieldH = 34 * s + local fieldH = PickerChrome.fieldH(Kit) p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query, "type a name, an id, or a dex number") cy = cy + fieldH + 10 * s local hits = Picker.results(S) - local rowH = 40 * s - local rowGap = 6 * s - local pagerH = 30 * s - local listH = (y + h - pad - pagerH - 10 * s) - cy + local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy) local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage)) -- wheel / touch drag scroll the modal list too; the shield is already