-- Mod manager v2 (18-mod-manager-ux.md): one stack state routing a fixed -- set of screens -- list (MODS/PROF/ERRS tabs), detail, options, -- permissions, errors, apply -- over mapped input, so gamepad and touch -- drive it like any other menu. Toggles resolve their dependency closure -- before they land, edits stage until one apply/restart, and safe mode is -- read from Runtime.safeMode (19 owns the detection). local Font = require("src.render.Font") local Runtime = require("src.mods.Runtime") local Semver = require("src.mods.Semver") local Version = require("src.core.Version") local Theme = require("src.ui.Theme") local OptionRows = require("src.ui.OptionRows") local Strings = require("src.core.Strings") local ModProfile = require("src.mods.ModProfile") local ManagerState = {} ManagerState.__index = ManagerState ManagerState.isOpaque = true -- stamped here as well as by Screens.push so the F10 toggle in -- Game:keypressed recognizes a directly-pushed instance ManagerState.screenId = "ManagerState" -- Same as OptionsMenu: without this, title LOGO zones leak through when -- MODS is opened from the title-screen options path. function ManagerState:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") end -- the charmap has no * ~ + < > glyphs, so the status gutter uses what it -- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" } local TABS = { "MODS", "PROFILES", "ERRORS" } local TAB_LINE = { "[MODS] PROF ERRS", "MODS [PROF] ERRS", "MODS PROF [ERRS]" } local LIST_TOP = 3 -- first content row (tile y) local LIST_ROWS = 11 -- single-line rows in the scroll region -- what the mod declared it does, shown before the player enables it local PERMISSION_ROWS = { engine_internals = { glyph = "!", text = "PATCHES ENGINE CODE" }, network = { glyph = "!", text = "USES THE NETWORK" }, filesystem = { glyph = "!", text = "READS/WRITES FILES" }, } local OPTION_TYPES = { toggle = true, choice = true, number = true, text = true } local function wrap(text, width) local lines = {} for paragraph in tostring(text or ""):gmatch("[^\n]+") do local line = "" for word in paragraph:gmatch("%S+") do while #word > width do if line ~= "" then lines[#lines + 1] = line line = "" end lines[#lines + 1] = word:sub(1, width) word = word:sub(width + 1) end if word ~= "" then local candidate = line == "" and word or line .. " " .. word if #candidate > width and line ~= "" then lines[#lines + 1] = line line = word else line = candidate end end end if line ~= "" then lines[#lines + 1] = line end end if #lines == 0 then lines[1] = "" end return lines end local function clampIndex(i, n) if i < 1 then return n end if i > n then return 1 end return i end -- enabled at boot is the loader's verdict, not the user's flag: setEnabled -- flips manifest.enabled but never state, so the difference is exactly the -- staged-since-boot set and survives closing the manager local function bootEnabled(m) return m.state ~= "disabled" end -- ------- pure toggle resolution -- The closure a requested flip drags along (18 "enable/disable flow"). -- mods is id -> status manifest; enabledSet is the current desired set. -- Module-level so tests table-drive it without a game. function ManagerState.resolveToggle(mods, id, want, enabledSet) local r = { apply = {}, alsoEnable = {}, alsoDisable = {}, conflicts = {}, missing = {}, badVersion = {} } local function enabledAfter(mid) if r.apply[mid] ~= nil then return r.apply[mid] end return enabledSet[mid] and true or false end local function enableWalk(mid, root) if r.apply[mid] then return end r.apply[mid] = true if not root then r.alsoEnable[#r.alsoEnable + 1] = mid end local m = mods[mid] if not m then return end if m.game_version and not Semver.satisfies(Version.engine, m.game_version) then r.badVersion[#r.badVersion + 1] = { id = mid, need = m.game_version, got = Version.engine, engine = true } end for _, spec in ipairs(m.dependencySpecs or {}) do local dep = mods[spec.id] if not dep then r.missing[#r.missing + 1] = spec.id elseif spec.range and not Semver.satisfies(dep.version, spec.range) then r.badVersion[#r.badVersion + 1] = { id = spec.id, need = spec.range, got = dep.version } elseif not enabledAfter(spec.id) then enableWalk(spec.id, false) end end end local function disableWalk(mid, root) if r.apply[mid] == false then return end r.apply[mid] = false if not root then r.alsoDisable[#r.alsoDisable + 1] = mid end -- reverse hard deps: whoever needs mid has to switch off with it for otherId, other in pairs(mods) do if enabledAfter(otherId) then for _, spec in ipairs(other.dependencySpecs or {}) do if spec.id == mid then disableWalk(otherId, false) break end end end end end if want then enableWalk(id, true) else disableWalk(id, true) end if want then -- a conflict blocks whichever side declared it for mid in pairs(r.apply) do local m = mods[mid] for _, spec in ipairs((m and m.conflictSpecs) or {}) do local other = mods[spec.id] if other and spec.id ~= mid and enabledAfter(spec.id) and (not spec.range or Semver.satisfies(other.version, spec.range)) then r.conflicts[#r.conflicts + 1] = spec.id end end for otherId, other in pairs(mods) do if otherId ~= mid and enabledAfter(otherId) then for _, spec in ipairs(other.conflictSpecs or {}) do if spec.id == mid and (not spec.range or not m or Semver.satisfies(m.version, spec.range)) then r.conflicts[#r.conflicts + 1] = otherId end end end end end end return r end -- ------- lifecycle function ManagerState.new(game) return setmetatable({ game = game, screen = "list", tab = 1, cursor = 1, scroll = 1, backStack = {}, descScroll = 1, restartPending = false, }, ManagerState) end function ManagerState:enter() self:refresh() -- #593: the setup that existed before profiles shipped becomes PROFILE 1 -- the first time the manager opens, so switching away from it is always a -- round trip. Seeded here rather than at boot because profiles are only -- ever read on this screen. local seeded = ModProfile.ensureFirst(self:optionsTable(), self.status.available, self:modOptionsTable()) if seeded then self:persistOptions() self:refresh() end if Runtime.safeMode then self.banner = "SAFE MODE - ALL MODS OFF" end self:snapCursor() end function ManagerState:refresh() local loader = self.game.mods self.status = (loader and loader.status and loader:status()) or self.game.modStatus or { available = {}, errors = {} } self.byId = {} for _, m in ipairs(self.status.available or {}) do self.byId[m.id] = m end if self.currentMod then self.currentMod = self.byId[self.currentMod.id] end self.restartPending = #self:stagedList() > 0 -- a live set that drifted off the named profile reverts to ad-hoc local opts = self:optionsTable() if opts.activeProfile then local p = self:findProfile(opts.activeProfile) if not p or not self:matchesProfile(p) then opts.activeProfile = nil end end end function ManagerState:optionsTable() local save = self.game.save return (save and save.options) or {} end -- Per-mod option values as persisted (options.modOptions; setOption below is -- the only writer). A profile captures this alongside the enable set. function ManagerState:modOptionsTable() return self:optionsTable().modOptions or {} end function ManagerState:manifestMap() return self.byId or {} end function ManagerState:enabledSet() local set = {} for _, m in ipairs(self.status.available or {}) do if m.enabled then set[m.id] = true end end return set end function ManagerState:isStaged(m) return (m.enabled and true or false) ~= bootEnabled(m) end function ManagerState:stagedList() local out = {} for _, m in ipairs(self.status.available or {}) do if self:isStaged(m) then out[#out + 1] = m end end table.sort(out, function(a, b) return a.id < b.id end) return out end function ManagerState:glyphFor(m) if self:isStaged(m) then return GLYPH.staged end if not m.enabled then return GLYPH.disabled end if m.state == "blocked_dependency" then return GLYPH.blocked end if m.error then return GLYPH.errored end return " " end -- ------- row models -- Every screen is one flat row list the shared cursor walks; headers are -- skipped by the cursor and drawn dim. function ManagerState:modRows() local rows = {} local byCategory, categories = {}, {} for _, m in ipairs(self.status.available or {}) do local cat = m.category or "OTHER" if not byCategory[cat] then byCategory[cat] = {} categories[#categories + 1] = cat end table.insert(byCategory[cat], m) end table.sort(categories) for _, cat in ipairs(categories) do rows[#rows + 1] = { header = true, label = cat } for _, m in ipairs(byCategory[cat]) do rows[#rows + 1] = { mod = m, label = m.name or m.id, glyph = self:glyphFor(m) } end end if #rows == 0 then rows[1] = { header = true, label = Strings("NO MODS INSTALLED") } end return rows end function ManagerState:profileRows() local rows = {} local opts = self:optionsTable() for _, p in ipairs(opts.modProfiles or {}) do rows[#rows + 1] = { profile = p, label = p.name, glyph = opts.activeProfile == p.name and GLYPH.errored or " " } end rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") } -- #593 sharing: EXPORT writes the focused profile to profiles/*.g1rmodlist -- in the save dir, IMPORT reads back every file dropped in that folder. rows[#rows + 1] = { exportProfile = true, label = Strings("EXPORT..") } rows[#rows + 1] = { importProfile = true, label = Strings("IMPORT..") } rows[#rows + 1] = { adhoc = true, label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" } return rows end function ManagerState:errorLines(mod) local lines = {} if mod and mod.error then for _, line in ipairs(wrap("FAILED: " .. mod.error, 16)) do lines[#lines + 1] = line end end for _, err in ipairs(self.status.errors or {}) do for _, line in ipairs(wrap(err, 16)) do lines[#lines + 1] = line end end if #lines == 0 then lines[1] = "NO ERRORS" end return lines end function ManagerState:errorRows(mod) local rows = {} for _, line in ipairs(self:errorLines(mod)) do rows[#rows + 1] = { label = line, inert = true } end return rows end function ManagerState:detailRows(m) local rows = {} rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", action = function() self:beginToggle(m) end } if self:schemaFor(m) then rows[#rows + 1] = { label = Strings("OPTIONS.."), action = function() self:openOptions(m) end } end if m.permissions and #m.permissions > 0 then rows[#rows + 1] = { label = Strings("PERMISSIONS.."), action = function() self:goTo("permissions") end } end if m.github then rows[#rows + 1] = { inert = true, label = "GH " .. m.github } end if m.experimental then rows[#rows + 1] = { inert = true, label = "EXPERIMENTAL" } end if m.error then rows[#rows + 1] = { label = Strings("VIEW ERROR.."), action = function() self:goTo("errors") end } end rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end } return rows end function ManagerState:applyRows() local rows = {} rows[#rows + 1] = { label = Strings("APPLY & RESTART"), action = function() self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end) end } rows[#rows + 1] = { label = Strings("DISCARD CHANGES"), action = function() self:discardChanges() end } rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end } return rows end function ManagerState:permissionRows(m) local rows = {} for _, name in ipairs(m.permissions or {}) do local info = PERMISSION_ROWS[name] rows[#rows + 1] = { inert = true, glyph = info and info.glyph or "?", label = info and info.text or name } end if #rows == 0 then rows[1] = { inert = true, label = Strings("DATA & API ONLY") } end return rows end function ManagerState:rowsForScreen() if self.screen == "list" then if self.tab == 1 then return self:modRows() end if self.tab == 2 then return self:profileRows() end return self:errorRows(nil) elseif self.screen == "detail" then return self.currentMod and self:detailRows(self.currentMod) or {} elseif self.screen == "errors" then return self:errorRows(self.currentMod) elseif self.screen == "permissions" then return self.currentMod and self:permissionRows(self.currentMod) or {} elseif self.screen == "apply" then return self:applyRows() end return {} end -- ------- navigation function ManagerState:goTo(screen) self.backStack[#self.backStack + 1] = { screen = self.screen, cursor = self.cursor, scroll = self.scroll } self.screen = screen self.cursor = 1 self.scroll = screen == "options" and 0 or 1 self.descScroll = 1 self:snapCursor() end function ManagerState:goBack() local prev = table.remove(self.backStack) if prev then self.screen = prev.screen self.cursor = prev.cursor self.scroll = prev.scroll self:refresh() else self.game.stack:pop() end end function ManagerState:snapCursor() local rows = self:rowsForScreen() local row = rows[self.cursor] if row and not row.header then return end for i, candidate in ipairs(rows) do if not candidate.header then self.cursor = i return end end self.cursor = 1 end function ManagerState:moveCursor(dir) local rows = self:rowsForScreen() local n = #rows if n == 0 then return end local i = self.cursor for _ = 1, n do i = clampIndex(i + dir, n) if not rows[i].header then self.cursor = i break end end -- keep the cursor inside the scroll window if self.cursor < self.scroll then self.scroll = self.cursor elseif self.cursor > self.scroll + LIST_ROWS - 1 then self.scroll = self.cursor - LIST_ROWS + 1 end end function ManagerState:adjustOrTab(dir) if self.screen == "list" then self.tab = clampIndex(self.tab + dir, #TABS) self.cursor, self.scroll = 1, 1 self:snapCursor() elseif self.screen == "detail" then self.descScroll = math.max(1, self.descScroll + dir) else for _ = 1, LIST_ROWS do self:moveCursor(dir) end end end function ManagerState:focusedRow() return self:rowsForScreen()[self.cursor] end function ManagerState:confirmSound() if self.game.data then require("src.core.Sound").play(self.game.data, "Press_AB") end end function ManagerState:notify(text) self.notice = text self.noticeTimer = 90 end function ManagerState:activate() local row = self:focusedRow() if not row or row.header or row.inert then return end self:confirmSound() if row.action then row.action() elseif row.mod then self.currentMod = row.mod self:goTo("detail") elseif row.profile then self:applyProfile(row.profile) elseif row.saveAs then self:saveCurrentAs() elseif row.exportProfile then self:exportActiveProfile() elseif row.importProfile then self:importProfiles() elseif row.adhoc then self:optionsTable().activeProfile = nil self:notify("AD-HOC SET ACTIVE") end end function ManagerState:pressStart() if self.screen == "list" and self.tab == 2 then local row = self:focusedRow() if row and row.profile then self:openConfirm({ "DELETE " .. row.profile.name .. "?" }, function() self:deleteProfile(row.profile) end) return end end if self.screen == "apply" then return end if self.restartPending or Runtime.safeMode then self:goTo("apply") else self:notify("NO CHANGES") end end function ManagerState:quickToggle() if self.screen == "list" and self.tab == 1 then local row = self:focusedRow() if row and row.mod then self:beginToggle(row.mod) end elseif self.screen == "list" and self.tab == 2 then local row = self:focusedRow() if row and row.profile then self:renameProfile(row.profile) end elseif self.screen == "detail" and self.currentMod then self:beginToggle(self.currentMod) end end function ManagerState:update() if self.notice then self.noticeTimer = (self.noticeTimer or 0) - 1 if self.noticeTimer <= 0 then self.notice = nil end end local input = self.game.input if self.overlay then return self:updateOverlay(input) end if self.screen == "options" then return self:updateOptions(input) end if input:wasPressed("up") then self:moveCursor(-1) elseif input:wasPressed("down") then self:moveCursor(1) elseif input:wasPressed("left") then self:adjustOrTab(-1) elseif input:wasPressed("right") then self:adjustOrTab(1) elseif input:wasPressed("a") then self:activate() elseif input:wasPressed("b") then self:goBack() elseif input:wasPressed("start") then self:pressStart() elseif input:wasPressed("select") then self:quickToggle() end end -- ------- overlays function ManagerState:openBlocked(r) local lines = {} for _, depId in ipairs(r.missing) do lines[#lines + 1] = "NEEDS " .. depId lines[#lines + 1] = "NOT INSTALLED" end for _, otherId in ipairs(r.conflicts) do local other = self.byId[otherId] lines[#lines + 1] = "CONFLICTS WITH" lines[#lines + 1] = (other and other.name or otherId) lines[#lines + 1] = "DISABLE IT FIRST" end for _, bad in ipairs(r.badVersion) do if bad.engine then lines[#lines + 1] = "NEEDS ENGINE " .. bad.need lines[#lines + 1] = "HAVE " .. bad.got else lines[#lines + 1] = "NEEDS " .. bad.id .. " " .. bad.need end end self.overlay = { kind = "ok", lines = lines } end function ManagerState:openCascade(r, m, want) local lines = {} if want then local names = {} for _, depId in ipairs(r.alsoEnable) do local dep = self.byId[depId] names[#names + 1] = dep and dep.name or depId end lines[#lines + 1] = "ALSO ENABLE" lines[#lines + 1] = table.concat(names, ", ") .. "?" else local dep = self.byId[r.alsoDisable[1]] lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS." lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or Strings("DISABLE BOTH?") end self.overlay = { kind = "confirm", lines = lines, index = 1, onYes = function() self:commitToggle(r.apply) end } end function ManagerState:openConfirm(lines, onYes) self.overlay = { kind = "confirm", lines = lines, index = 1, onYes = onYes } end function ManagerState:updateOverlay(input) local overlay = self.overlay if overlay.kind == "ok" then if input:wasPressed("a") or input:wasPressed("b") then self.overlay = nil end return end if input:wasPressed("up") or input:wasPressed("down") then overlay.index = overlay.index == 1 and 2 or 1 elseif input:wasPressed("a") then self:confirmSound() self.overlay = nil if overlay.index == 1 and overlay.onYes then overlay.onYes() end elseif input:wasPressed("b") then self.overlay = nil end end -- ------- the enable/disable flow function ManagerState:beginToggle(m) if not m then return end local want = not m.enabled local loader = self.game.mods local r if loader and loader.resolveToggle then r = loader:resolveToggle(m.id, want, self:enabledSet()) else r = ManagerState.resolveToggle(self:manifestMap(), m.id, want, self:enabledSet()) end local function proceed() if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then self:openBlocked(r) elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then self:openCascade(r, m, want) else self:commitToggle(r.apply) end end -- Experimental mods ask once on enable; disable is silent. if want and m.experimental then self:openConfirm({ "EXPERIMENTAL MOD", "THIS MOD IS MARKED", "EXPERIMENTAL.", "ENABLE ANYWAY?", }, proceed) return end proceed() end function ManagerState:commitToggle(apply) local loader = self.game.mods local opts = self:optionsTable() for id, en in pairs(apply) do if loader and loader.setEnabled then loader:setEnabled(id, en) end -- mirror into the live options so a later writeOptions cannot revert -- what setEnabled just persisted opts.mods = opts.mods or {} opts.mods[id] = en end if loader and loader.status then self.game.modStatus = loader:status() end self:refresh() end function ManagerState:discardChanges() local loader = self.game.mods local opts = self:optionsTable() for _, m in ipairs(self:stagedList()) do local en = bootEnabled(m) if loader and loader.setEnabled then loader:setEnabled(m.id, en) end opts.mods = opts.mods or {} opts.mods[m.id] = en end if loader and loader.status then self.game.modStatus = loader:status() end self:refresh() self:notify("CHANGES DISCARDED") end function ManagerState:restartGame() if self.game.restartWithMods then self.game:restartWithMods() elseif love.event and love.event.quit then love.event.quit("restart") end end -- ------- profiles (named enable-sets, not the manifest profile field) function ManagerState:findProfile(name) for _, p in ipairs(self:optionsTable().modProfiles or {}) do if p.name == name then return p end end return nil end function ManagerState:matchesProfile(p) for _, m in ipairs(self.status.available or {}) do local want = p.enabled[m.id] ~= false if (m.enabled and true or false) ~= want then return false end end return true end function ManagerState:persistOptions() if self.game.writeOptions then self.game:writeOptions() end end function ManagerState:applyProfile(p) local mods = self:manifestMap() local set = self:enabledSet() local combined = {} for _, m in ipairs(self.status.available or {}) do local want = p.enabled[m.id] ~= false local cur = set[m.id] and true or false if cur ~= want then local r = ManagerState.resolveToggle(mods, m.id, want, set) if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then self:openBlocked(r) return end for id, en in pairs(r.apply) do combined[id] = en set[id] = en or nil end end end self:commitToggle(combined) -- #593: a profile is the whole setup, not just the enable set. Options go -- through setOption so loader.modOptions and mod.options_changed stay in -- step; save slots move only to slots that version already registered -- (SaveData.setActiveSlot would otherwise conjure one). for modId, bucket in pairs(p.options or {}) do if self.byId[modId] then for key, value in pairs(bucket) do self:setOption(modId, key, value) end end end for _, move in ipairs(ModProfile.slotMoves(p)) do require("src.core.SaveData").setActiveSlot(move[1], move[2]) end self:optionsTable().activeProfile = p.name self:persistOptions() local missing = ModProfile.missingIds(p, self.byId) if #missing > 0 then self:notify(#missing .. " MODS MISSING") else self:notify("PROFILE STAGED") end end function ManagerState:saveCurrentAs() local NamingScreen = require("src.ui.NamingScreen") self.game.stack:push(NamingScreen.new(self.game, { title = Strings("PROFILE NAME?"), maxLen = 10, onDone = function(name) local opts = self:optionsTable() opts.modProfiles = opts.modProfiles or {} local snap = ModProfile.capture(self.status.available, self:modOptionsTable()) local existing = self:findProfile(name) if existing then existing.enabled, existing.options, existing.slots = snap.enabled, snap.options, snap.slots else snap.name = name opts.modProfiles[#opts.modProfiles + 1] = snap end opts.activeProfile = name self:persistOptions() self:refresh() end, })) end -- #593 sharing. Export drops the active profile (or the focused one) in the -- save directory's profiles/ folder as .g1rmodlist; import reads every -- file already there. Deliberately folder-based rather than a native file -- picker: the same shape SaveFileIO uses for exports/, and it works on the -- mobile builds where no picker exists. function ManagerState:exportActiveProfile() local row = self:focusedRow() local p = (row and row.profile) or self:findProfile(self:optionsTable().activeProfile) if not p then return self:notify("NO PROFILE") end local ok = ModProfile.export(p) self:notify(ok and ("SAVED " .. p.name) or "EXPORT FAILED") end function ManagerState:importProfiles() local opts = self:optionsTable() opts.modProfiles = opts.modProfiles or {} local n = 0 for _, path in ipairs(ModProfile.files()) do if ModProfile.import(path, opts.modProfiles) then n = n + 1 end end if n == 0 then return self:notify("NO MODLISTS") end self:persistOptions() self:snapCursor() self:notify(n .. " IMPORTED") end function ManagerState:renameProfile(p) local NamingScreen = require("src.ui.NamingScreen") self.game.stack:push(NamingScreen.new(self.game, { title = Strings("RENAME?"), maxLen = 10, default = p.name, onDone = function(name) local opts = self:optionsTable() if opts.activeProfile == p.name then opts.activeProfile = name end p.name = name self:persistOptions() end, })) end function ManagerState:deleteProfile(p) local opts = self:optionsTable() local profiles = opts.modProfiles or {} for i, candidate in ipairs(profiles) do if candidate == p then table.remove(profiles, i) break end end if opts.activeProfile == p.name then opts.activeProfile = nil end self:persistOptions() self:snapCursor() end -- ------- per-mod options (auto-UI from options_schema) -- the loader captured schemas when mod.options:define ran; a mod that only -- shipped the manifest options_schema file gets it loaded here on demand function ManagerState:schemaFor(m) local loader = self.game.mods if not loader then return nil end local schema = loader.optionSchemas and loader.optionSchemas[m.id] if schema == nil and m.options_schema and m.path and loader.fs and loader.fs.load then local chunk = loader.fs.load(m.path .. "/" .. m.options_schema) if chunk then local ok, rows = pcall(chunk) if ok and type(rows) == "table" then schema = rows if loader.optionSchemas then loader.optionSchemas[m.id] = schema end end end end return schema end function ManagerState:optionValue(modId, row) local loader = self.game.mods local stored = loader and loader.modOptions and loader.modOptions[modId] local v = stored and stored[row.key] if v == nil then v = row.default end return v end function ManagerState:setOption(modId, key, value) local save = self.game.save if save and save.options then save.options.modOptions = save.options.modOptions or {} local t = save.options.modOptions t[modId] = t[modId] or {} t[modId][key] = value end local loader = self.game.mods if loader then loader.modOptions = loader.modOptions or {} loader.modOptions[modId] = loader.modOptions[modId] or {} loader.modOptions[modId][key] = value end self:persistOptions() if loader and loader.events then loader.events:emit("mod.options_changed", { mod = modId, key = key, value = value }) end end function ManagerState:buildOptionRows(m, schema) local rows = {} local modId = m.id for _, row in ipairs(schema) do if type(row) ~= "table" or type(row.key) ~= "string" or row.key == "" or not OPTION_TYPES[row.type] then -- malformed rows are skipped, reported where the errors screen reads Runtime.reportError(modId, "options row skipped: " .. tostring(type(row) == "table" and (row.key or row.type) or row)) elseif row.type == "toggle" then rows[#rows + 1] = { id = row.key, label = row.label or row.key, value = function() return self:optionValue(modId, row) and "ON" or "OFF" end, step = function() self:setOption(modId, row.key, not self:optionValue(modId, row)) return true end } elseif row.type == "choice" then rows[#rows + 1] = { id = row.key, label = row.label or row.key, value = function() local cur = self:optionValue(modId, row) for _, choice in ipairs(row.choices or {}) do if choice[2] == cur then return choice[1] end end local first = (row.choices or {})[1] return first and first[1] or "----" end, step = function(_, dir) local choices = row.choices or {} if #choices == 0 then return false end local cur = self:optionValue(modId, row) local index = 1 for i, choice in ipairs(choices) do if choice[2] == cur then index = i break end end index = clampIndex(index + dir, #choices) self:setOption(modId, row.key, choices[index][2]) return true end } elseif row.type == "number" then local function clamp(v) if row.min then v = math.max(row.min, v) end if row.max then v = math.min(row.max, v) end return v end rows[#rows + 1] = { id = row.key, label = row.label or row.key, value = function() return tostring(self:optionValue(modId, row) or 0) end, step = function(_, dir) local cur = tonumber(self:optionValue(modId, row)) or 0 self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1))) return true end, activate = function() local QuantityBox = require("src.ui.QuantityBox") self.game.stack:push(QuantityBox.new(self.game, { max = row.max or 99, start = math.max(1, tonumber(self:optionValue(modId, row)) or 1), onDone = function(qty) if qty then self:setOption(modId, row.key, clamp(qty)) end end, })) end } elseif row.type == "text" then rows[#rows + 1] = { id = row.key, label = row.label or row.key, value = function() return tostring(self:optionValue(modId, row) or "") end, activate = function() local NamingScreen = require("src.ui.NamingScreen") self.game.stack:push(NamingScreen.new(self.game, { title = (row.label or row.key) .. "?", maxLen = row.maxLen or 7, default = self:optionValue(modId, row), onDone = function(name) self:setOption(modId, row.key, name) end, })) end } end end rows[#rows + 1] = { id = "__reset", label = Strings("RESET DEFAULTS"), value = function() return "" end, activate = function() for _, row in ipairs(schema) do if type(row) == "table" and type(row.key) == "string" and OPTION_TYPES[row.type] then self:setOption(modId, row.key, row.default) end end self:notify("DEFAULTS RESTORED") end } return rows end function ManagerState:openOptions(m) local schema = self:schemaFor(m) if not schema then self:notify("NO OPTIONS") return end self.optionRows = self:buildOptionRows(m, schema) self:goTo("options") end function ManagerState:updateOptions(input) local rows = self.optionRows or {} local n = #rows if input:wasPressed("b") then self:goBack() return end if n == 0 then return end if input:wasPressed("up") then self.cursor = clampIndex(self.cursor - 1, n) elseif input:wasPressed("down") then self.cursor = clampIndex(self.cursor + 1, n) elseif input:wasPressed("left") or input:wasPressed("right") or input:wasPressed("a") then local dir = input:wasPressed("left") and -1 or 1 local row = rows[self.cursor] if row.activate and input:wasPressed("a") then self:confirmSound() row.activate() elseif row.step then row.step(self.game, dir) end end self.scroll = OptionRows.clampScroll(self.cursor, self.scroll or 0, n, nil) end -- ------- drawing local function drawTruncated(text, x, y, cols) text = tostring(text or "") if #text > cols then text = text:sub(1, cols) end Font.draw(text, x, y) end function ManagerState:drawRows(rows) local last = math.min(#rows, self.scroll + LIST_ROWS - 1) local y = LIST_TOP for i = self.scroll, last do local row = rows[i] if row.header then drawTruncated(row.label, 16, y * 8, 17) else if row.glyph and row.glyph ~= " " then Font.draw(row.glyph, 16, y * 8) end drawTruncated(row.label, 32, y * 8, 15) if i == self.cursor then Font.drawCode(Theme.cursor, 8, y * 8) end end y = y + 1 end if #rows > last then Font.drawCode(Theme.moreArrow, 18 * 8, (LIST_TOP + LIST_ROWS) * 8) end end function ManagerState:drawFooter(line1, line2) if self.notice then Font.draw(self.notice, 16, 15 * 8) return end if line1 then Font.draw(line1, 16, 15 * 8) end if line2 then Font.draw(line2, 16, 16 * 8) end end function ManagerState:drawList() Font.draw(TAB_LINE[self.tab], 16, 2 * 8) self:drawRows(self:rowsForScreen()) if self.tab == 1 then self:drawFooter("A:OPEN SEL:TOGGLE", "START:APPLY B:EXIT") elseif self.tab == 2 then self:drawFooter("A:APPLY SEL:RENAME", "START:DELETE") else self:drawFooter("UP/DOWN:SCROLL") end end function ManagerState:drawDetail() local m = self.currentMod if not m then return end local title = wrap(m.name or m.id, 14) drawTruncated(title[1] .. " " .. (m.version or ""), 16, 2 * 8, 17) local statusLine = m.enabled and "ENABLED" or "DISABLED" if m.state == "blocked_dependency" then statusLine = statusLine .. " ?" elseif m.error then statusLine = statusLine .. " !" end if self:isStaged(m) then statusLine = statusLine .. " (STAGED)" end drawTruncated(statusLine, 16, 3 * 8, 17) drawTruncated((m.category or "OTHER") .. " / " .. (m.profile or "content"), 16, 4 * 8, 17) local lines = wrap(m.error and ("FAILED: " .. m.error) or m.description, 16) local visible = 5 for i = 1, visible do local line = lines[self.descScroll + i - 1] if not line then break end Font.draw(line, 16, (5 + i) * 8) end if self.descScroll + visible <= #lines then Font.drawCode(Theme.moreArrow, 17 * 8, 10 * 8) end local rows = self:rowsForScreen() local y = 11 for i, row in ipairs(rows) do drawTruncated(row.label, 32, y * 8, 15) if i == self.cursor then Font.drawCode(Theme.cursor, 24, y * 8) end y = y + 1 end self:drawFooter("A:CHOOSE B:BACK") end function ManagerState:drawPermissions() drawTruncated("PERMISSIONS", 16, 2 * 8, 17) self:drawRows(self:rowsForScreen()) self:drawFooter("DECLARED BY AUTHOR,", "NOT ENFORCED") end function ManagerState:drawErrors() drawTruncated("ERRORS", 16, 2 * 8, 17) self:drawRows(self:rowsForScreen()) self:drawFooter("UP/DOWN:SCROLL B:BACK") end function ManagerState:drawApply() drawTruncated("PENDING CHANGES", 16, 2 * 8, 17) local staged = self:stagedList() local y = LIST_TOP local shown = math.min(#staged, 7) for i = 1, shown do local m = staged[i] local verb = m.enabled and "ON " or "OFF " drawTruncated(verb .. (m.name or m.id), 16, y * 8, 17) y = y + 1 end if #staged == 0 then Font.draw(Runtime.safeMode and "SAFE MODE" or Strings("NO CHANGES"), 16, y * 8) y = y + 1 end local rows = self:rowsForScreen() local base = 12 for i, row in ipairs(rows) do drawTruncated(row.label, 32, (base + i - 1) * 8, 15) if i == self.cursor then Font.drawCode(Theme.cursor, 24, (base + i - 1) * 8) end end self:drawFooter("A:CHOOSE B:BACK") end function ManagerState:drawOverlay() local overlay = self.overlay local lines = {} for _, raw in ipairs(overlay.lines) do for _, line in ipairs(wrap(raw, 14)) do lines[#lines + 1] = line end end local th = math.max(6, #lines + (overlay.kind == "confirm" and 5 or 3)) local ty = math.max(1, math.floor((18 - th) / 2)) love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", 2 * 8, ty * 8, 16 * 8, th * 8) love.graphics.setColor(1, 1, 1, 1) Font.drawBox(2, ty, 16, th) for i, line in ipairs(lines) do drawTruncated(line, 4 * 8, (ty + i) * 8, 14) end if overlay.kind == "confirm" then local yesY = ty + #lines + 1 Font.draw(Strings("YES"), 5 * 8, yesY * 8) Font.draw(Strings("NO"), 5 * 8, (yesY + 1) * 8) Font.drawCode(Theme.cursor, 4 * 8, (overlay.index == 1 and yesY or yesY + 1) * 8) else Font.draw(Strings("A:OK"), 5 * 8, (ty + #lines + 1) * 8) end end function ManagerState:draw() if self.screen == "options" then OptionRows.draw(self.game, self.optionRows or {}, self.cursor, self.scroll or 0) love.graphics.setColor(0, 0, 0, 1) Font.draw(self.notice or Strings("B:DONE (NO RESTART)"), 8, 136) love.graphics.setColor(1, 1, 1, 1) if self.overlay then self:drawOverlay() end return end love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) Font.drawBox(0, 0, 20, 18) Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8) if self.screen == "list" then self:drawList() elseif self.screen == "detail" then self:drawDetail() elseif self.screen == "permissions" then self:drawPermissions() elseif self.screen == "errors" then self:drawErrors() elseif self.screen == "apply" then self:drawApply() end if self.overlay then self:drawOverlay() end end return ManagerState