-- 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 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 = 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 = nil 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.navPopup then Chooser.close(S) else App.close() end elseif action == "tab_prev" then if S.navPopup then Chooser.keypressed(S, "up") else cycleTab(-1) end elseif action == "tab_next" then if 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 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 PadInput.yieldToPointer() end function App.touchmoved(id, x, y) if not touch or touch.id ~= id then return end touch.x, touch.y = x, y if math.abs(x - touch.startX) + math.abs(y - touch.startY) > 10 then touch.moved = true 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 App.mousepressed(x, y, 1) end touch = nil Kit.touchDown = false 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 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.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 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 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