Files
gen1recomp/tools/save-editor/App.lua
T
2026-10-02 06:21:06 -04:00

1092 lines
33 KiB
Lua

-- Save editor app shell. Boots the game's generated Data plus a save file
-- and draws the chrome the design spec fixes (SaveEditor.dc.html): a version
-- rail, a title bar, a tab rail and a status bar, with one panel filling the
-- space between. Panels own their tab's content; this module owns everything
-- around it.
--
-- The editor is reachable two ways and behaves the same in both:
-- * `love . --editor` standalone window, Close quits
-- * Edit on a launcher save row (main.lua, embedded = true), Close returns
-- to the launcher with the slot list refreshed
--
-- Chrome reflows inside the platform safe area. Phones use a compact action
-- menu and popup page choosers; wide windows keep actions on one row. Each page
-- owns its scrolling viewport and slides with the launcher's navigation.
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")
local State = require("State")
local Kit = require("Kit")
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PadInput = require("PadInput")
local Motion = require("Motion")
local Chooser = require("Chooser")
local PAL = Theme.PAL
local Party = require("Party")
local Boxes = require("Boxes")
local Items = require("Items")
local Events = require("Events")
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 = {}
local S
-- one loader per process: registries collide if a second load re-registers
-- vanilla records over an already-merged Data
local mods
local mouseClicked = false
-- Click position from the press event. Kit samples the pointer in draw, so a
-- touch / mouse / pad-A click must use the event coords -- not love.mouse
-- (often stale on NX) and not the virtual cursor when a finger taps elsewhere.
local clickX, clickY
-- Wheel notches queued by App.wheelmoved since the last draw, handed to Kit
-- there like mouseClicked is: LOVE delivers events before love.draw, so a
-- notch is always spent by the frame that follows it (#595).
local TouchEditor = require("TouchEditor")
local wheelY = 0
local touch
-- Which game's cache Data was loaded from. main.lua checks this before
-- opening the editor on a save from the other version, because the two
-- caches cannot both be mounted in one process (see CacheFs.mountVersion).
App.dataVersion = nil
local TABS = {
{ id = "party", glyph = "PT", label = "Party" },
{ id = "boxes", glyph = "BX", label = "Boxes" },
{ id = "items", glyph = "IT", label = "Items" },
{ id = "events", glyph = "EV", label = "Events" },
{ id = "map", glyph = "MP", label = "Map" },
{ id = "dex", glyph = "DX", label = "Pokédex" },
{ id = "trainer", glyph = "TR", label = "Trainer" },
{ id = "legality", glyph = "CK", label = "Checks" },
}
local PANELS = {
party = Party,
boxes = Boxes,
items = Items,
events = Events,
map = MapBrowser,
dex = Dex,
trainer = require("Trainer"),
legality = require("Checks"),
}
local function fileExists(path)
local f = io.open(path, "rb")
if f then
f:close()
return true
end
return false
end
-- Apply a load attempt for `path` into the current State (S must exist).
local function applyLoaded(path, statusVerb)
statusVerb = statusVerb or "Loaded"
Motion.reset()
Kit.blur()
S.path = path
local existed = fileExists(path)
local save, err = SaveIO.load(path)
if save then
S.save = save
S.status = statusVerb .. " " .. path
S.loadError = false
S.allowSave = true
elseif existed then
S.save = Gen.newGame(S.version)
S.status = "Corrupt save at "
.. path
.. " ("
.. tostring(err)
.. "), Save disabled, use Reload after fixing the file"
S.loadError = true
S.allowSave = false
else
S.save = Gen.newGame(S.version)
S.status = "No save at " .. path .. " (" .. tostring(err) .. "), editing new game stub"
S.loadError = false
S.allowSave = true
end
if Gen.of(S.save, S.version) == 3 then
S.events = Catalog.game3EventList(S.modRoots)
S.game3Events = Catalog.game3Categories(S.modRoots)
elseif Gen.of(S.save, S.version) == 2 then
S.events = Catalog.gen2EventList(Gen.engineOf(S.save, S.version), S.modRoots)
end
local mapId = Gen.playerMap(S.save)
S.mapId = mapId
S.dirty = false
S.undoStack, S.redoStack = {}, {}
S.historyToken, S.historySavedToken = 0, 0
S.revision = (S.revision or 0) + 1
S.speciesPicker, S.movePicker, S.itemPicker = nil, nil, nil
S.formMon, S.nicknameMon = nil, nil
S.monDrafts, S.trainerDrafts, S.walletDrafts = {}, {}, {}
S.propertyChoice, S.itemMenu = nil, nil
S.navPopup, S.editPopup = nil, nil
S._listState = nil
S.mobileInspector = nil
S._quitArmed = false
S._openArmed = false
S.editingMon = nil
Ops.disarm(S)
local prepared, prepareError = pcall(function()
Gen.ensureBoxes(S.save)
Gen.hydrateSave(Data, S.save)
end)
if not prepared then
S.loadError, S.allowSave = true, false
S.status = "Save disabled: " .. tostring(prepareError)
return
end
local probe = require("src.mods.Merge").deepCopy(S.save)
S.validation = Gen.validate(probe, Data)
if not Gen.emptyReport(S.save, S.validation) then
if Gen.of(S.save, S.version) == 2 then
S.status = S.status
.. string.format(
", game would quarantine: %d script bytes, %d mail, %d events",
#(S.validation.lostScriptMem or {}),
#(S.validation.lostMail or {}),
#(S.validation.lostEvents or {})
)
elseif Gen.of(S.save, S.version) ~= 3 then
S.status = S.status
.. string.format(
", game would quarantine: %d mons, %d items, %d maps",
#S.validation.lostMons,
#S.validation.lostItems,
#S.validation.remappedMaps
)
end
end
end
-- pathOverride lets tests point App.load at a scratch file instead of the
-- real default save path (used to exercise the corrupt-save branch below).
-- opts carries what only the launcher knows: which game the save belongs to,
-- its slot id, and where Close should go back to.
function App.load(pathOverride, opts)
opts = opts or {}
Motion.reset()
local transition = require("src.ui.kit.Transition")
local okMotion, motionOptions = pcall(function()
return require("src.core.SaveData").loadOptions()
end)
transition.reduceMotion = opts.reduceMotion == true
or os.getenv("POKEPORT_REDUCE_MOTION") == "1"
or (okMotion and type(motionOptions) == "table" and motionOptions.reduceMotion == true)
or transition.reduceMotion
S = State.new()
S.data = Data
S.version = opts.version
S.slotId = opts.slotId
S.embedded = opts.embedded or false
S.onClose = opts.onClose
if opts.version then
require("src.core.GameVersion").set(opts.version)
end
if
(Gen.of(nil, opts.version) == 3 or require("src.core.GameVersion").generation() == 3)
and not Gen.game3CacheReady()
then
S.path = pathOverride or SaveIO.defaultPath()
S.missingCache = Gen.missingCacheMessage(opts.version)
S.status = S.missingCache
S.loadError, S.allowSave = true, false
return
end
-- the same mod set the game loads, merged into Data before the catalogs
-- build, so modded species/items/moves are editable and MonOps stops
-- asserting on them
if not mods or App.dataVersion ~= opts.version then
-- One loader per editor session. A previous session leaves Data holding
-- that session's merged registries (and possibly the other game's cache),
-- and a second builtin registration over them collides -- "statuses
-- already registered: FRZ". _pristineKeys only exists once Data has been
-- loaded at least once, so it doubles as the "needs evicting" marker.
if Data._pristineKeys then
Data:unloadGenerated()
end
Data:load()
if Gen.of(nil, opts.version) == 3 or require("src.core.GameVersion").generation() == 3 then
Gen.bindGame3Data(Data)
elseif Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then
Gen.bindGoldData(Data)
end
local ModLoader = require("src.mods.Loader")
mods = ModLoader.new()
mods:load(Data)
App.dataVersion = opts.version
end
S.mods = mods
S.cat = Catalog.build(Data)
local modRoots = {}
for _, mod in ipairs(S.mods:status().loaded) do
modRoots[#modRoots + 1] = mod.path
end
S.modRoots = modRoots
if Gen.of(nil, opts.version) == 3 or require("src.core.GameVersion").generation() == 3 then
S.events = Catalog.game3EventList(modRoots)
elseif Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then
S.events = Catalog.gen2EventList(Gen.engineOf(nil, opts.version), modRoots)
else
S.events =
Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua", nil, modRoots)
end
applyLoaded(pathOverride or SaveIO.defaultPath(), "Loaded")
end
-- Switch to another save file (Open button, drag-drop, or --save arg).
-- If there are unsaved edits, the first call arms a confirm; call again
-- (or pass force=true) to discard and open.
function App.openPath(path, force)
if not path or path == "" then
return false
end
if not S or S.missingCache then
return false
end
if S.dirty and not force and not S._openArmed then
S._openArmed = true
S.status = "Unsaved changes, open again to discard and load " .. path
return false
end
applyLoaded(path, "Opened")
return true
end
function App.chooseAndOpen()
local path = SaveIO.choosePath()
if path then
App.openPath(path)
else
local osName = love and love.system and love.system.getOS and love.system.getOS()
if osName ~= "OS X" and osName ~= "Windows" and osName ~= "Linux" then
S.status = "File picker unavailable, drop a save.lua onto the window"
end
end
end
function App.filedropped(file)
if not (file and S) then
return
end
local path = file.getFilename and file:getFilename() or nil
if not path or path == "" then
S.status = "Could not read dropped file path"
return
end
App.openPath(path)
end
-- Test hook: App.load keeps its state in a module-local so headless tests
-- can drive App.load/App.draw against a scratch path and then inspect the
-- resulting flags/status without loving a real save file.
function App.getState()
return S
end
-- Tear the editor down far enough that a later App.load rebuilds from
-- scratch. main.lua calls this after Close so the next Edit -- possibly on
-- the other game's save -- re-runs Data:load against whatever cache is
-- mounted by then, instead of reusing this session's merged registries.
function App.unload()
Motion.reset()
touch = nil
Kit.touchDown, Kit.ignoreMouseDown = nil, nil
Kit._touchDrag, Kit._pointerDrag, Kit._tapPending, Kit._dragDelta = nil, nil, nil, 0
S = nil
mods = nil
App.dataVersion = nil
-- Kit is never evicted from package.loaded, so a Close taken while a text
-- field still owns focus would leak Kit.focus and a raised soft keyboard
-- (against a rect that is gone) into the launcher and the next session
-- (#529). A Close taken on the frame the species picker went up would
-- likewise leave its modal shield raised, and the next session would open
-- deaf to every click (#541).
Kit.blur()
Kit.blockClicks = false
PadInput.reset()
end
local function cycleTab(delta)
if not S then
return
end
local idx = 1
for i, t in ipairs(TABS) do
if t.id == S.tab then
idx = i
break
end
end
idx = ((idx - 1 + delta) % #TABS) + 1
Motion.change(S, "tab", TABS[idx].id, delta)
Ops.say(S, "Tab: " .. TABS[idx].label)
end
-- Pad / Joy-Con actions from PadInput.gamepadpressed (A/B via GamepadMap so
-- NX physical A confirms and B closes).
local function handlePadAction(action)
if not action or not S then
return
end
if action == "a" then
local mx, my = PadInput.pointer()
App.mousepressed(mx, my, 1)
elseif action == "b" then
if S.editPopup then
TouchEditor.close(S, Kit)
elseif S.navPopup then
Chooser.close(S)
else
App.close()
end
elseif action == "tab_prev" then
if S.editPopup then
TouchEditor.keypressed(S, Kit, S.editPopup.mode == "number" and "left" or "up")
elseif S.navPopup then
Chooser.keypressed(S, "up")
else
cycleTab(-1)
end
elseif action == "tab_next" then
if S.editPopup then
TouchEditor.keypressed(S, Kit, S.editPopup.mode == "number" and "right" or "down")
elseif S.navPopup then
Chooser.keypressed(S, "down")
else
cycleTab(1)
end
end
end
function App.save()
if S.missingCache then
return Ops.say(S, S.missingCache)
end
if not S.allowSave then
return Ops.say(S, "Save disabled, corrupt save loaded; fix the file and Reload first")
end
local output = S.save
if Gen.ofState(S) == 3 then
local prepared, result = pcall(require("Game3Adapter").export, S.save)
if not prepared then
S.status = "Save failed: " .. tostring(result)
return false
end
output = result
end
local ok, err = SaveIO.save(S.path, output)
if ok then
S.dirty = false
S.historySavedToken = S.historyToken or 0
S._quitArmed = false
Ops.disarm(S)
S.status = "Saved " .. S.path
return true
end
S.status = "Save failed: " .. tostring(err)
return false
end
function App.reload()
if S.missingCache then
return Ops.say(S, S.missingCache)
end
local save, err = SaveIO.load(S.path)
if save then
applyLoaded(S.path, "Reloaded")
return not S.loadError
end
S.status = "Reload failed: " .. tostring(err)
return false
end
-- Close: back to the launcher when hosted there, otherwise quit. Unsaved
-- edits arm a confirm exactly like Open does, so leaving can't lose work.
--
-- The teardown itself is DEFERRED to the end of the frame (App.draw calls
-- finishClose below). Close is dispatched from inside drawTitleBar, and the
-- host's onClose runs App.unload, which drops S -- doing that inline left the
-- rest of the frame drawing against a nil state.
function App.close()
if not S then
return false
end
if S.dirty and not S._quitArmed then
S._quitArmed = true
S.status = "Unsaved changes, Save first or click Close again to discard"
return false
end
S._closeRequested = true
return true
end
local function finishClose()
local embedded, onClose = S.embedded, S.onClose
S._closeRequested = false
if embedded and onClose then
onClose()
elseif love and love.event then
love.event.quit()
end
end
function App.update(dt)
-- Immediate-mode UI: nothing to simulate per-frame; input is sampled
-- directly in App.draw() via Kit.beginFrame. Tile animation (water,
-- flowers) still needs ticking so the Map tab isn't static.
TileRenderer.tick()
PadInput.update(dt)
local notches = PadInput.takeWheel()
if notches ~= 0 then
App.wheelmoved(0, notches)
end
-- Dev harness, the launcher's POKEPORT_LAUNCHER_SHOT for this window:
-- POKEPORT_EDITOR_SHOT=/path.png with POKEPORT_WIN=WxH resizes, lets the
-- view settle, captures one frame and quits, so a scripted run can see the
-- real editor at any window shape. POKEPORT_EDITOR_TAB picks the tab and
-- POKEPORT_EDITOR_ITEMPICK=1 opens the add-item modal.
local shot = os.getenv("POKEPORT_EDITOR_SHOT")
if shot and not App._shotDone then
if not App._shotSized then
App._shotSized = true
local w, h = (os.getenv("POKEPORT_WIN") or ""):match("^(%d+)x(%d+)$")
if w and love.window and love.window.setMode then
pcall(love.window.setMode, tonumber(w), tonumber(h), { resizable = true })
end
local tab = os.getenv("POKEPORT_EDITOR_TAB")
if tab and tab ~= "" and S then
S.tab = tab
end
local monSlot = tonumber(os.getenv("POKEPORT_EDITOR_MON") or "")
if monSlot and S and S.save and S.save.party then
S.editingMon = S.save.party[monSlot]
end
if os.getenv("POKEPORT_EDITOR_ITEMPICK") == "1" and S then
Ops.openItemPicker(S, Kit, "bag")
end
end
App._shotTimer = (App._shotTimer or 0) + dt
if App._shotTimer > 1.0 then
App._shotDone = true
love.graphics.captureScreenshot(function(imagedata)
local fd = imagedata:encode("png")
local f = io.open(shot, "wb")
if f then
f:write(fd:getString())
f:close()
end
love.event.quit()
end)
end
end
end
function App.mousepressed(x, y, button)
if touch then
return
end
if button == 1 then
mouseClicked = true
clickX, clickY = x, y
-- A finger / mouse tap yields the virtual cursor so the click lands where
-- the event said, not under the Joy-Con pointer (NX touch soft-miss).
PadInput.yieldToPointer()
end
end
function App.touchpressed(id, x, y)
if touch then
return
end
touch = { id = id, x = x, y = y, startX = x, startY = y, moved = false }
Kit.touchDown = true
Kit.ignoreMouseDown = true
Kit._pointerDrag, Kit._tapPending = nil, nil
Kit._touchDrag = { x = x, startY = y }
Kit._dragDelta = 0
PadInput.yieldToPointer()
end
function App.touchmoved(id, x, y)
if not touch or touch.id ~= id then
return
end
local lastY = touch.y
touch.x, touch.y = x, y
if math.abs(x - touch.startX) + math.abs(y - touch.startY) > 10 then
touch.moved = true
end
if touch.moved then
Kit.dragAdd(lastY - y)
end
end
function App.touchreleased(id, x, y)
if not touch or touch.id ~= id then
return
end
App.touchmoved(id, x, y)
if not touch.moved then
mouseClicked, clickX, clickY = true, x, y
end
touch = nil
Kit.touchDown = false
Kit.ignoreMouseDown = true
end
function App.textinput(text)
Kit.textinput(text)
end
function App.gamepadpressed(joystick, button)
handlePadAction(PadInput.gamepadpressed(joystick, button))
end
function App.gamepadreleased(joystick, button)
PadInput.gamepadreleased(joystick, button)
end
function App.gamepadaxis(joystick, axis, value)
PadInput.gamepadaxis(joystick, axis, value)
end
function App.joystickpressed(joystick, button)
handlePadAction(PadInput.joystickpressed(joystick, button))
end
function App.joystickreleased(joystick, button)
PadInput.joystickreleased(joystick, button)
end
function App.joystickaxis(joystick, axis, value)
PadInput.joystickaxis(joystick, axis, value)
end
function App.joystickhat(joystick, hat, direction)
PadInput.joystickhat(joystick, hat, direction)
end
-- ------------------------------------------------------------------ chrome
-- Compact action row, with file identity above it when height allows.
local function drawTitleBar(x, y, w, h)
local pad, gap, row = 12 * Kit.scale, 8 * Kit.scale, Kit.controlH()
local inner = w - 2 * pad
if not S.compactChrome then
Kit.text(
"tab",
"SAVE EDITOR" .. (S.version and (" / " .. S.version:upper()) or ""),
x + pad,
y + 8 * Kit.scale,
PAL.heading
)
Kit.text(
"tiny",
Kit.ellipsize("tiny", (S.dirty and "UNSAVED " or "SAVED ") .. (S.path or "New save"), inner),
x + pad,
y + Kit.textHeight("tab") + 12 * Kit.scale,
S.dirty and PAL.yellow or PAL.caption
)
end
local narrow = w < 600 * Kit.scale and not S.compactChrome
local cols = narrow and 4 or 6
local bw = (inner - (cols - 1) * gap) / cols
local by = S.compactChrome and (y + 8 * Kit.scale)
or (y + Kit.textHeight("tab") + Kit.textHeight("tiny") + 20 * Kit.scale)
local saveInk = S.allowSave and S.dirty and PAL.green or PAL.muted
local actions = {
{
S.allowSave and (S.dirty and "Save" or "Saved") or "Save locked",
"ghost",
function()
App.save()
end,
S.dirty or not S.allowSave,
{
face = "invert",
ink = saveInk,
stroke = saveInk,
icon = S.allowSave and "save" or "lock",
},
},
{
"Undo",
"ghost",
function()
require("History").undo(S)
end,
S.undoStack and #S.undoStack > 0,
},
{
"Redo",
"ghost",
function()
require("History").redo(S)
end,
S.redoStack and #S.redoStack > 0,
},
{
"Reload",
"ghost",
function()
App.reload()
end,
true,
},
{
"Open",
"accent",
function()
App.chooseAndOpen()
end,
true,
},
{
S._quitArmed and "Discard?" or "Close",
S._quitArmed and "danger" or "ghost",
function()
App.close()
end,
true,
},
}
local function actionOptions(action)
local opts = action[5] or {}
opts.kind, opts.enabled, opts.font = action[2], action[4], "small"
return opts
end
if narrow then
local more = {
S.chromeMenu and "Less" or "More",
"ghost",
function()
S.chromeMenu = not S.chromeMenu
Kit.blur()
end,
true,
}
local primary = { actions[1], actions[2], actions[3], more }
for i, a in ipairs(primary) do
if Kit.button(x + pad + (i - 1) * (bw + gap), by, bw, row, a[1], actionOptions(a)) then
a[3]()
end
end
if S.chromeMenu then
local menuW = (inner - 2 * gap) / 3
for i = 4, 6 do
local a = actions[i]
if
Kit.button(
x + pad + (i - 4) * (menuW + gap),
by + row + gap,
menuW,
row,
a[1],
actionOptions(a)
)
then
a[3]()
end
end
end
return
end
for i, a in ipairs(actions) do
if
Kit.button(
x + pad + (i - 1) % cols * (bw + gap),
by + math.floor((i - 1) / cols) * (row + gap),
bw,
row,
a[1],
actionOptions(a)
)
then
a[3]()
end
end
end
local function drawTabRail(x, y, w, h)
local pad, row = 12 * Kit.scale, Kit.controlH()
Chooser.navigation(
S,
Kit,
"tab",
"Save editor page",
TABS,
x + pad,
y,
math.min(w - 2 * pad, 360 * Kit.scale),
row,
function()
S.mobileInspector = false
S.chromeMenu, S.itemMenu = false, nil
end
)
end
local function drawStatusBar(x, y, w, h)
local s = Kit.scale
local pad = 22 * s
Theme.col(PAL.bgBot, 0.6)
love.graphics.rectangle("fill", x, y, w, h)
Theme.col(PAL.cardBorder, 0.22)
love.graphics.rectangle("fill", x, y, w, 1)
local ctrl = (love.system and love.system.getOS and love.system.getOS() == "OS X") and "Cmd"
or "Ctrl"
local hint = S.embedded
and (ctrl .. "+S save . " .. ctrl .. "+R reload . Esc clear selection . Close returns to the launcher")
or (
ctrl
.. "+S save . "
.. ctrl
.. "+R reload . Esc clear selection . arrows pan map . wheel scrolls lists"
)
-- The status message is the load-bearing half of this bar (every Ops verb
-- narrates through it); the keyboard map is decoration. On a phone the
-- two used to overlap because the hint was drawn unconditionally and the
-- status ellipsized against a negative budget (#715), so now the hint only
-- draws when the status still keeps a readable share of the bar.
local hintW = Kit.textWidth("tiny", hint)
local avail = w - 2 * pad - hintW - 14 * s
if avail >= 120 * s then
Kit.textRight("tiny", hint, x + w - pad, y + (h - Kit.textHeight("tiny")) / 2, PAL.faint)
else
avail = w - 2 * pad
end
Kit.text(
"mono",
Kit.ellipsize("mono", S.status or "", avail),
x + pad,
y + (h - Kit.textHeight("mono")) / 2,
PAL.detail
)
end
function App.draw()
-- Closing the editor unloads it, and the host may still deliver one more
-- frame or a queued event before it re-routes; every entry point below
-- tolerates that rather than indexing a torn-down state.
if not S then
return
end
local width, height = love.graphics.getDimensions()
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()
local padX, padY, padOn = PadInput.pointer()
if mouseClicked and clickX ~= nil then
mx, my = clickX, clickY
elseif touch then
mx, my = touch.x, touch.y
elseif padOn then
mx, my = padX, padY
end
Motion.update()
Kit.beginFrame(mx, my, mouseClicked, wheelY)
mouseClicked = false
clickX, clickY = nil, nil
wheelY = 0
-- Modal shield. Kit has no z-order, so the picker cannot simply be drawn
-- last: the chrome and the panel underneath would take the same tap. The
-- 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)
or (S.navPopup ~= nil)
or (S.editPopup ~= nil)
or Motion.active()
Theme.field(width, height)
if S.missingCache then
Theme.versionRail(ox, oy, sw, 6 * s)
local pad = 22 * s
local ty = oy + sh / 2 - 60 * s
for line in (S.missingCache .. " "):gmatch("(.-%.)%s+") do
Kit.textCenter("button", line, ox + pad, ty, sw - 2 * pad, PAL.heading)
ty = ty + Kit.textHeight("button") + 8 * s
end
local label = "Close"
local bw = 22 * s + Kit.textWidth("button", label)
if Kit.button(ox + (sw - bw) / 2, ty + 40 * s, bw, 38 * s, label, { kind = "ghost" }) then
App.close()
end
Kit.endFrame()
PadInput.draw()
if S._closeRequested then
finishClose()
end
return
end
local railH = 6 * s
-- The title bar reflows to two rows (identity above, buttons below) when
-- 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.
S.compactChrome = sh < 500 * s and sw > sh
local titleTwoRow = sw < 600 * s and not S.compactChrome and S.chromeMenu
local titleH = Kit.textHeight("tab")
+ Kit.textHeight("tiny")
+ 28 * s
+ (titleTwoRow and 2 or 1) * (Kit.controlH() + 8 * s)
if S.compactChrome then
titleH = Kit.controlH() + 16 * s
end
local tabH = Kit.controlH() + 6 * s
local statusH = 38 * s
-- A short landscape map needs room for actual cells. Its focus control
-- hides the editor chrome while keeping the map navigation and status.
local focusMap = S.tab == "map" and S.mapSection == "view" and S.mapFocused and sw > sh
if focusMap then
titleH, tabH = 0, 0
end
Theme.versionRail(ox, oy, sw, railH)
if not focusMap then
drawTitleBar(ox, oy + railH, sw, titleH)
drawTabRail(ox, oy + railH + titleH, sw, tabH)
end
local contentY = oy + railH + titleH + tabH
local contentH = sh - railH - titleH - tabH - statusH
local px, py = ox + 10 * s, contentY + 8 * s
local pw, ph = sw - 20 * s, math.max(1, contentH - 16 * s)
local ok, err = xpcall(function()
Motion.pages(S, Kit, "tab", px, py, pw, ph, function(state, kit, dx, dy, dw, dh)
local panel = PANELS[state.tab]
if not panel then
return
end
local minH = (state.tab == "events" and 360 or state.tab == "dex" and 580 or 0) * s
state._scrollingPage = minH > dh
kit.pushClip(dx, dy, dw, dh)
if minH > dh then
state.pageScroll = state.pageScroll or {}
local off = state.pageScroll[state.tab] or 0
panel.draw(state, kit, dx, dy - off, dw, minH)
state.pageScroll[state.tab] = kit.scrollPixels(dx, dy, dw, dh, off, minH)
else
panel.draw(state, kit, dx, dy, dw, dh)
end
kit.popClip()
end)
end, debug.traceback)
if not ok then
Kit.resetClip()
print(string.format("[SAVE-EDITOR ERROR in %s panel]\n%s", tostring(S.tab), tostring(err)))
Kit.text(
"mono",
"Error rendering " .. tostring(S.tab) .. " panel",
px + 12 * s,
py + 12 * s,
PAL.red
)
end
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.
if S.editPopup then
TouchEditor.draw(S, Kit, width, height)
elseif S.navPopup then
Chooser.draw(S, Kit, width, height)
else
SpeciesPicker.draw(S, Kit, width, height)
MovePicker.draw(S, Kit, width, height)
ItemPicker.draw(S, Kit, width, height)
end
Kit.endFrame()
PadInput.draw()
-- Only now, with the whole frame painted, is it safe to drop the editor.
if S._closeRequested then
finishClose()
end
end
function App.keypressed(key)
if not S or S.missingCache then
return
end
if TouchEditor.keypressed(S, Kit, key) then
return
end
if Chooser.keypressed(S, key) then
return
end
-- The picker takes Enter and Escape before the focused field does: Kit maps
-- fields handle their own commit/cancel instead of submitting a form.
if S.itemPicker then
if key == "return" or key == "kpenter" then
S.itemPicker.query = Kit.flushText("item-picker", S.itemPicker.query)
ItemPicker.commitFirst(S, Kit)
return
elseif key == "escape" then
Ops.closeItemPicker(S, Kit)
return
end
end
if S.movePicker then
if key == "return" or key == "kpenter" then
S.movePicker.query = Kit.flushText("move-picker", S.movePicker.query)
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
S.speciesPicker.query = Kit.flushText("species-picker", S.speciesPicker.query)
SpeciesPicker.commitFirst(S, Kit)
return
elseif key == "escape" then
Ops.closeSpeciesPicker(S, Kit)
return
end
end
-- The inspector's nickname field is a commit-on-Enter field, unlike the
-- search fields, which are live view state. Enter commits the draft through
-- Ops and blurs; Escape discards it and blurs. Both must run before
-- Kit.keypressed. Drain queued typing before committing the draft.
if Kit.focus == "mon-nickname" then
if key == "return" or key == "kpenter" then
if
S.editingMon
and Ops.setNickname(
S,
S.editingMon,
Kit.flushText("mon-nickname", S.nicknameDraft, function(v)
return Ops.nicknameSanitize(S, v)
end)
)
then
S.nicknameDraft = S.editingMon.nickname or ""
end
Kit.blur()
return
elseif key == "escape" then
Kit.blur()
if S.editingMon then
S.nicknameDraft = S.editingMon.nickname or ""
end
return
end
end
-- A focused text field eats the keys it cares about (typing "s" into the
-- map filter must not trigger Save).
if Kit.keypressed(key) then
return
end
if Kit.focus then
if key == "escape" then
Kit.blur()
end
return
end
-- Save and Reload both touch the file on disk (Reload discards unsaved
-- edits), so they need a modifier. A bare letter must remain safe to type.
local mod = love.keyboard
and love.keyboard.isDown
and (love.keyboard.isDown("lgui", "rgui") or love.keyboard.isDown("lctrl", "rctrl"))
if key == "escape" and (S.itemMenu or S.chromeMenu) then
S.itemMenu, S.chromeMenu = nil, false
Ops.say(S, "Menu closed")
return
end
if key == "escape" then
S.editingMon = nil
Ops.disarm(S)
Ops.say(S, "Selection cleared")
elseif key == "z" and mod then
if love.keyboard.isDown("lshift", "rshift") then
require("History").redo(S)
else
require("History").undo(S)
end
elseif key == "y" and mod then
require("History").redo(S)
elseif key == "s" and mod then
App.save()
elseif key == "r" and mod then
App.reload()
end
if S.tab == "map" and MapBrowser.keypressed then
MapBrowser.keypressed(S, key)
end
end
function App.wheelmoved(x, y)
if not S or S.missingCache then
return
end
if S.navPopup or S.editPopup or S.speciesPicker or S.movePicker or S.itemPicker then
wheelY = wheelY + (y or 0)
return
end
-- The map tab spends the wheel on zoom; every other tab routes it through
-- Kit so whichever list the pointer is over takes it next draw (#595).
if
S.tab == "map"
and MapBrowser.wheelmoved
and not S._scrollingPage
and (not S.mapSection or S.mapSection == "view")
then
MapBrowser.wheelmoved(S, y)
return
end
wheelY = wheelY + (y or 0)
end
function App.quit()
if not S then
return false
end
if S.dirty then
-- simple: block quit once and set status; user saves or force-quits again
if not S._quitArmed then
S._quitArmed = true
S.status = "Unsaved changes, save or press quit again"
return true
end
end
return false
end
return App