mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user