fix(save-editor): mobile safe areas, move search picker, and release window titles

Inset save-editor chrome and pickers into SafeArea so Save stays tappable on punch-hole phones (#917), replace move cycling with a ranked search modal that reflows on phones/RGxxx, and omit the engine version from window titles on stamped release builds.
This commit is contained in:
1jamie
2026-08-25 12:43:05 -05:00
parent bbe0f0d9ae
commit b796c30278
13 changed files with 752 additions and 61 deletions
+5 -1
View File
@@ -227,7 +227,11 @@ local function metadata(options, context)
end end
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
local version = appVersion() 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()) add("LÖVE", loveVersion())
if safeMode then add("Safe mode", "on") end if safeMode then add("Safe mode", "on") end
return { return {
+15 -3
View File
@@ -22,10 +22,22 @@ local Version = {
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) 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) function Version.title(base)
return (base or "gen1recomp") base = base or "gen1recomp"
.. " v" .. Version.engine if Version.isDev() then
return base .. " v" .. Version.engine
end
return base
end end
return Version return Version
+17 -1
View File
@@ -145,8 +145,24 @@ check(type(Version.engine) == "string"
and Semver.parse(Version.engine) ~= nil, and Semver.parse(Version.engine) ~= nil,
"engine version parses as a semver (triple, optionally with a pre-release)") "engine version parses as a semver (triple, optionally with a pre-release)")
check(Version.modApi == 2, "mod api version is 2") 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, 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 -- null-object runtime: emit/call are safe with no loader installed
local nullEvents, nullHooks = Runtime.events, Runtime.hooks local nullEvents, nullHooks = Runtime.events, Runtime.hooks
+276 -3
View File
@@ -927,6 +927,113 @@ do
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
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 do
-- The inspector's nickname field is commit-on-Enter: the draft lives in -- The inspector's nickname field is commit-on-Enter: the draft lives in
-- S.nicknameDraft while typing, Enter commits it through Ops.setNickname, -- 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 -- 720x1280 / 1280x720 are the #715 report's shapes (Android, both
-- orientations): the Map tab used to lay its viewport out at a negative -- 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 -- width in portrait and crash on the scissor. RGxxx / Switch shapes pin
-- the responsive reflow does not disturb the layouts that already worked. -- 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 }, for _, size in ipairs({ { 720, 1560 }, { 1560, 720 }, { 480, 1040 },
{ 1280, 800 }, { 720, 1280 }, { 1280, 720 }, { 1280, 800 }, { 720, 1280 }, { 1280, 720 },
{ 720, 480 }, { 640, 480 }, { 480, 320 },
{ 1024, 768 }, { 1920, 1080 }, { 360, 640 } }) do { 1024, 768 }, { 1920, 1080 }, { 360, 640 } }) do
love.graphics.getDimensions = function() return size[1], size[2] end love.graphics.getDimensions = function() return size[1], size[2] end
App.load(tmpPath, { version = "red" }) App.load(tmpPath, { version = "red" })
@@ -1153,6 +1263,12 @@ do
ok, err = pcall(App.draw) ok, err = pcall(App.draw)
check(ok, ("the species picker redraws at %s: %s"):format(label, tostring(err))) check(ok, ("the species picker redraws at %s: %s"):format(label, tostring(err)))
Ops.closeSpeciesPicker(S, Kit) 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 end
love.graphics.getDimensions = oldDimensions love.graphics.getDimensions = oldDimensions
@@ -1233,8 +1349,12 @@ do
f:close() f:close()
local oldDimensions = love.graphics.getDimensions 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 }, 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 for _, size in ipairs(sizes) do
local W, H = size[1], size[2] local W, H = size[1], size[2]
love.graphics.getDimensions = function() return W, H end 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 if ok then auditFrame(("%dx%d species picker"):format(W, H), W, H) end
Kit.audit = nil Kit.audit = nil
Ops.closeSpeciesPicker(S, Kit) 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 end
love.graphics.getDimensions = oldDimensions love.graphics.getDimensions = oldDimensions
@@ -1349,5 +1477,150 @@ do
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
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)) print(string.format("save editor tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end if failed > 0 then os.exit(1) end
+40 -11
View File
@@ -9,7 +9,11 @@
-- * Edit on a launcher save row (main.lua, embedded = true), Close returns -- * Edit on a launcher save row (main.lua, embedded = true), Close returns
-- to the launcher with the slot list refreshed -- 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 -- 0 6px tri-colour version rail, identical to the launcher's
-- 6 64px title bar identity, file chip, Save / Reload / Open / Close -- 6 64px title bar identity, file chip, Save / Reload / Open / Close
-- (104px when the bar reflows to two rows, #715) -- (104px when the bar reflows to two rows, #715)
@@ -18,6 +22,7 @@
-- -38 38px status bar the last Ops message + the keyboard map -- -38 38px status bar the last Ops message + the keyboard map
local Data = require("src.core.Data") local Data = require("src.core.Data")
local SafeArea = require("src.core.SafeArea")
local TileRenderer = require("src.render.TileRenderer") local TileRenderer = require("src.render.TileRenderer")
local SaveIO = require("SaveIO") local SaveIO = require("SaveIO")
local Catalog = require("Catalog") local Catalog = require("Catalog")
@@ -37,6 +42,7 @@ local MapBrowser = require("MapBrowser")
local Dex = require("Dex") local Dex = require("Dex")
-- chrome, not a tab panel, so deliberately kept out of PANELS below (#541) -- chrome, not a tab panel, so deliberately kept out of PANELS below (#541)
local SpeciesPicker = require("SpeciesPicker") local SpeciesPicker = require("SpeciesPicker")
local MovePicker = require("MovePicker")
local ItemPicker = require("ItemPicker") local ItemPicker = require("ItemPicker")
local App = {} local App = {}
@@ -772,7 +778,17 @@ function App.draw()
-- tolerates that rather than indexing a torn-down state. -- tolerates that rather than indexing a torn-down state.
if not S then return end if not S then return end
local width, height = love.graphics.getDimensions() 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 s = Kit.scale
local mx, my = love.mouse.getPosition() 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 -- shield goes up before anything dispatches and comes down only for the
-- picker's own layer at the bottom of this function (#541). -- picker's own layer at the bottom of this function (#541).
Kit.blockClicks = (S.speciesPicker ~= nil) or (S.itemPicker ~= nil) Kit.blockClicks = (S.speciesPicker ~= nil) or (S.itemPicker ~= nil)
or (S.movePicker ~= nil)
Theme.field(width, height) 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 -- 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 -- identity painting through each other (#715). The taller bar simply
-- costs the content column height, which scrolls. -- costs the content column height, which scrolls.
local titleTwoRow = titleNeedsTwoRows(width) local titleTwoRow = titleNeedsTwoRows(sw)
local titleH = (titleTwoRow and 104 or 64) * s local titleH = (titleTwoRow and 104 or 64) * s
local tabH = 66 * s local tabH = 66 * s
local statusH = 38 * s local statusH = 38 * s
Theme.versionRail(0, 0, width, railH) Theme.versionRail(ox, oy, sw, railH)
drawTitleBar(0, railH, width, titleH, titleTwoRow) drawTitleBar(ox, oy + railH, sw, titleH, titleTwoRow)
drawTabRail(0, railH + titleH, width, tabH) drawTabRail(ox, oy + railH + titleH, sw, tabH)
local contentY = railH + titleH + tabH local contentY = oy + railH + titleH + tabH
local contentH = height - contentY - statusH local contentH = sh - railH - titleH - tabH - statusH
local panel = PANELS[S.tab] local panel = PANELS[S.tab]
if panel then if panel then
panel.draw(S, Kit, 22 * s, contentY + 20 * s, panel.draw(S, Kit, ox + 22 * s, contentY + 20 * s,
width - 44 * s, contentH - 38 * s) sw - 44 * s, contentH - 38 * s)
end end
drawStatusBar(0, height - statusH, width, statusH) drawStatusBar(ox, oy + sh - statusH, sw, statusH)
Kit.blockClicks = false 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) SpeciesPicker.draw(S, Kit, width, height)
MovePicker.draw(S, Kit, width, height)
ItemPicker.draw(S, Kit, width, height) ItemPicker.draw(S, Kit, width, height)
Kit.endFrame() Kit.endFrame()
PadInput.draw() PadInput.draw()
@@ -841,6 +861,15 @@ function App.keypressed(key)
return return
end end
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 S.speciesPicker then
if key == "return" or key == "kpenter" then if key == "return" or key == "kpenter" then
SpeciesPicker.commitFirst(S, Kit) SpeciesPicker.commitFirst(S, Kit)
+6 -1
View File
@@ -11,7 +11,8 @@
-- Hit testing is a plain rect with no z-order, so panels must draw -- 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 -- overlapping controls in dispatch order and every target is >= 26px tall
-- (rule 6 of the design spec) -- that sizing is the whole accessibility story -- (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 Theme = require("Theme")
local PAL = Theme.PAL local PAL = Theme.PAL
@@ -25,6 +26,10 @@ Kit.time = 0
Kit.fonts = {} Kit.fonts = {}
Kit.scale = 1 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 G = love and love.graphics or nil
local edits = {} -- queued textinput / backspace since the last frame local edits = {} -- queued textinput / backspace since the last frame
local kbField = nil -- id of the field the OS soft keyboard is raised for local kbField = nil -- id of the field the OS soft keyboard is raised for
+133 -3
View File
@@ -329,6 +329,31 @@ function Ops.speciesSearch(S, query)
for _, id in ipairs(S.cat.species) do for _, id in ipairs(S.cat.species) do
if Ops.speciesMatches(S, id, query) then out[#out + 1] = id end if Ops.speciesMatches(S, id, query) then out[#out + 1] = id end
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 return out
end 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)) return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp))
end 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) 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 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 local moves = S.cat.moves
@@ -441,14 +468,117 @@ function Ops.cycleMove(S, mon, slot)
end end
for step = 1, #moves do for step = 1, #moves do
local nextId = moves[((idx + step - 1) % #moves) + 1] local nextId = moves[((idx + step - 1) % #moves) + 1]
if S.data and S.data.moves and type(S.data.moves[nextId]) == "table" then if Ops.moveUsable(S, nextId) then
MonOps.setMove(S.data, mon, slot, nextId) return Ops.setMove(S, mon, slot, nextId)
return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId))
end end
end end
return false return false
end 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) function Ops.clearMove(S, mon, slot)
if not (mon and mon.moves and mon.moves[slot]) then if not (mon and mon.moves and mon.moves[slot]) then
return Ops.say(S, ("Move slot %d is already empty"):format(slot)) return Ops.say(S, ("Move slot %d is already empty"):format(slot))
+83
View File
@@ -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
+5
View File
@@ -53,6 +53,11 @@ function State.new()
-- without a z-order (#541). -- without a z-order (#541).
speciesPicker = nil, 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 -- item picker overlay: nil when closed, otherwise
-- { query, offset, dest = "bag"|"pc" }. Same modal contract as -- { query, offset, dest = "bag"|"pc" }. Same modal contract as
-- speciesPicker above -- adding an item is now a full-screen picker -- speciesPicker above -- adding an item is now a full-screen picker
+15 -16
View File
@@ -13,6 +13,7 @@
local Theme = require("Theme") local Theme = require("Theme")
local Ops = require("Ops") local Ops = require("Ops")
local PickerChrome = require("PickerChrome")
local PAL = Theme.PAL local PAL = Theme.PAL
local Picker = {} local Picker = {}
@@ -57,38 +58,39 @@ function Picker.draw(S, Kit, width, height)
Kit.blockClicks = true Kit.blockClicks = true
end 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) Theme.col(PAL.bg, 0.82)
love.graphics.rectangle("fill", 0, 0, width, height) love.graphics.rectangle("fill", 0, 0, width, height)
local w = math.min(width - 32 * s, 520 * s) -- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a
local h = math.min(height - 32 * s, 560 * s) -- usable list (#917 / #715).
local x = (width - w) / 2 local x, y, w, h, pad = PickerChrome.card(Kit, width, height)
local y = (height - h) / 2
if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then
Ops.closeItemPicker(S, Kit) Ops.closeItemPicker(S, Kit)
return return
end end
Kit.card(x, y, w, h) Kit.card(x, y, w, h)
local pad = 18 * s
local cx, cy = x + pad, y + pad local cx, cy = x + pad, y + pad
local inner = w - 2 * pad local inner = w - 2 * pad
Kit.caption(cx, cy, "ADD AN ITEM") local closeW = PickerChrome.closeSize(Kit)
local closeW = 30 * s local captionH = Kit.textHeight("caption")
if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x", 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 { font = "small" }) then
Ops.closeItemPicker(S, Kit) Ops.closeItemPicker(S, Kit)
return return
end 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 -- 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 -- 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". -- bottom that each mean "commit, and also pick a destination".
local half = (inner - 8 * s) / 2 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 if Kit.chip(cx, cy, half, destH, "-> BAG", p.dest ~= "pc", PAL.green, PAL.steel) then
p.dest = "bag" p.dest = "bag"
end end
@@ -98,16 +100,13 @@ function Picker.draw(S, Kit, width, height)
end end
cy = cy + destH + 10 * s 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, p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query,
"type an item id") "type an item id")
cy = cy + fieldH + 10 * s cy = cy + fieldH + 10 * s
local hits = Picker.results(S) local hits = Picker.results(S)
local rowH = 36 * s local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy)
local rowGap = 6 * s
local pagerH = 30 * s
local listH = (y + h - pad - pagerH - 10 * s) - cy
local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) 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)) 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 -- wheel / touch drag scroll the modal list too; the shield is already
+7 -7
View File
@@ -213,7 +213,7 @@ local function drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap)
local ry = rowY + (slot - 1) * (rowH + rowGap) local ry = rowY + (slot - 1) * (rowH + rowGap)
Theme.row(rightX, ry, colW, rowH, 10 * s, 0.6) Theme.row(rightX, ry, colW, rowH, 10 * s, 0.6)
local mv = mon.moves and mon.moves[slot] local mv = mon.moves and mon.moves[slot]
local clear = 24 * s local clear = Kit.tapMin()
local clearX = rightX + colW - 10 * s - clear local clearX = rightX + colW - 10 * s - clear
local ppText = mv and ("PP %d"):format(mv.pp or 0) or "" local ppText = mv and ("PP %d"):format(mv.pp or 0) or ""
local ppW = Kit.textWidth("tiny", ppText) 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) mv and PAL.text or PAL.faint)
Kit.textRight("tiny", ppText, clearX - 10 * s, Kit.textRight("tiny", ppText, clearX - 10 * s,
ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption) 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 if Kit.press(rightX, ry, clearX - rightX - 4 * s, rowH) then
Ops.cycleMove(S, mon, slot) Ops.openMovePicker(S, Kit, slot)
end end
if Kit.button(clearX, ry + (rowH - clear) / 2, clear, clear, "x", if Kit.button(clearX, ry + (rowH - clear) / 2, clear, clear, "x",
{ kind = "danger", font = "tiny", radius = 6 * s }) then { kind = "danger", font = "tiny", radius = 6 * s }) then
@@ -264,10 +264,10 @@ function MonEditor.draw(S, Kit, x, y, w, h)
-- text keeps room. -- text keeps room.
local narrow = inner < 470 * s local narrow = inner < 470 * s
local sprite = (narrow and 64 or 96) * 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 rowGap = 8 * s
local cellH = 52 * s local cellH = 52 * s
local actH = 34 * s local actH = math.max(Kit.tapMin(), 34 * s)
local hw = inner - sprite - 18 * s local hw = inner - sprite - 18 * s
local levelInHeader = hw >= levelRowWidth(Kit, mon) 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 local movesY = rowY + colRowsH + 14 * s
Kit.caption(cx, movesY, "MOVES") 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 local mRowY = movesY + capH + 10 * s
drawMoveRows(S, Kit, mon, cx, mRowY, inner, rowH, rowGap) 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), Kit.textRight("tiny", ("HP DV auto-derived . %d"):format(mon.dvs.hp or 0),
cx + colW, colY, PAL.caption) cx + colW, colY, PAL.caption)
Kit.caption(rightX, colY, "MOVES") 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 local rowY = colY + capH + 10 * s
drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap) drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap)
+136
View File
@@ -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
+14 -15
View File
@@ -12,6 +12,7 @@
local Theme = require("Theme") local Theme = require("Theme")
local Ops = require("Ops") local Ops = require("Ops")
local MonEditor = require("MonEditor") local MonEditor = require("MonEditor")
local PickerChrome = require("PickerChrome")
local PAL = Theme.PAL local PAL = Theme.PAL
local Picker = {} local Picker = {}
@@ -59,44 +60,42 @@ function Picker.draw(S, Kit, width, height)
Kit.blockClicks = true Kit.blockClicks = true
end 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) Theme.col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height) love.graphics.rectangle("fill", 0, 0, width, height)
local w = math.min(width - 32 * s, 520 * s) -- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a
local h = math.min(height - 32 * s, 560 * s) -- usable list (#917 / #715).
local x = (width - w) / 2 local x, y, w, h, pad = PickerChrome.card(Kit, width, height)
local y = (height - h) / 2
if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then
Ops.closeSpeciesPicker(S, Kit) Ops.closeSpeciesPicker(S, Kit)
return return
end end
Kit.card(x, y, w, h) Kit.card(x, y, w, h)
local pad = 18 * s
local cx, cy = x + pad, y + pad local cx, cy = x + pad, y + pad
local inner = w - 2 * 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") 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 + (headH - closeW) / 2, closeW, closeW, "x",
if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x",
{ font = "small", radius = 7 * s }) then { font = "small", radius = 7 * s }) then
Ops.closeSpeciesPicker(S, Kit) Ops.closeSpeciesPicker(S, Kit)
return return
end 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, p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query,
"type a name, an id, or a dex number") "type a name, an id, or a dex number")
cy = cy + fieldH + 10 * s cy = cy + fieldH + 10 * s
local hits = Picker.results(S) local hits = Picker.results(S)
local rowH = 40 * s local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy)
local rowGap = 6 * s
local pagerH = 30 * s
local listH = (y + h - pad - pagerH - 10 * s) - cy
local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) 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)) 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 -- wheel / touch drag scroll the modal list too; the shield is already