Merge pull request #1812 from 1Jamie/save-editor-safe-areas-move-search

fix(save-editor): mobile safe areas, move search picker, and release window titles
This commit is contained in:
bryanthaboi
2026-08-25 15:54:19 -04:00
committed by GitHub
13 changed files with 752 additions and 61 deletions
+5 -1
View File
@@ -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 {
+15 -3
View File
@@ -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
+17 -1
View File
@@ -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
+276 -3
View File
@@ -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
+40 -11
View File
@@ -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)
+6 -1
View File
@@ -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
+133 -3
View File
@@ -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))
+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).
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
+15 -16
View File
@@ -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
+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)
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)
+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 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