mod manager now supports auto updates

This commit is contained in:
bryanthaboi
2026-07-31 10:19:51 -04:00
parent 6c4c9d14e2
commit 9d3ee6a597
15 changed files with 2002 additions and 95 deletions
+711 -75
View File
@@ -923,24 +923,38 @@ end
-- or a love DroppedFile.
function RomImporter:_installMod(source)
if self.workState == "working" then return end
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.installZip(source)
if ok then
self:_refreshMods()
self.tab = "mods"
local ok, installed, res = pcall(function()
local LauncherMods = require("src.mods.LauncherMods")
return LauncherMods.installZip(source)
end)
if not ok then
self.modNotice = { ok = false,
text = "Import failed: " .. tostring(installed) }
return
end
if installed then
pcall(self._refreshMods, self)
self.modNotice = { ok = true, text = "Installed " .. tostring(res) }
else
self.modNotice = { ok = false, text = tostring(res) }
end
self.tab = "mods"
end
-- Remove an installed mod from the save-dir mods/ tree and refresh the panel.
function RomImporter:_deleteMod(id)
if self.workState == "working" then return end
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.uninstall(id)
if ok then
self:_refreshMods()
local ok, deleted, res = pcall(function()
local LauncherMods = require("src.mods.LauncherMods")
return LauncherMods.uninstall(id)
end)
if not ok then
self.modNotice = { ok = false,
text = "Delete failed: " .. tostring(deleted) }
return
end
if deleted then
pcall(self._refreshMods, self)
self.modNotice = { ok = true, text = "Deleted " .. tostring(id) }
else
self.modNotice = { ok = false, text = tostring(res) }
@@ -1520,13 +1534,15 @@ end
-- Clip text to a pixel width, appending an ellipsis when it overflows (the UI
-- font has no built-in truncation). Used for save-slot names / meta lines.
-- Drops whole UTF-8 codepoints (never mid-byte) so Font:getWidth cannot see
-- a truncated multi-byte sequence and throw "UTF-8 decoding error".
local function ellipsize(font, text, maxW)
text = tostring(text or "")
if maxW <= 0 or font:getWidth(text) <= maxW then return text end
local ell = "..."
local ew = font:getWidth(ell)
while #text > 0 and font:getWidth(text) + ew > maxW do
text = text:sub(1, #text - 1)
text = utf8Back(text)
end
return text .. ell
end
@@ -2083,6 +2099,224 @@ function RomImporter:draw()
dx + 16 * s, dy + dh - 30 * s, dw - 32 * s, "left")
end
-- Mod confirm / versions / release-notes overlays
if self._modConfirm or self._modVersions or self._modReleaseNotes then
col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height)
end
if self._modConfirm then
local c = self._modConfirm
local dw = math.min(appW - 32 * s, 400 * s)
local lineH = self.hintFont:getHeight() + 4 * s
local dh = 36 * s + (#c.lines) * lineH + 56 * s
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
col((c.kind == "update") and PAL.green or PAL.gold, 0.65)
love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr)
love.graphics.setFont(self.slotNameFont)
col(PAL.white)
love.graphics.printf(c.title or "Confirm", dx + 16 * s, dy + 14 * s,
dw - 32 * s, "left")
love.graphics.setFont(self.hintFont)
col(PAL.detail)
local ty = dy + 42 * s
for _, line in ipairs(c.lines) do
love.graphics.printf(line, dx + 16 * s, ty, dw - 32 * s, "left")
ty = ty + lineH
end
local btnH = 34 * s
local btnW = (dw - 48 * s) / 2
local by = dy + dh - btnH - 14 * s
self._modConfirmYes = { x = dx + 16 * s, y = by, width = btnW, height = btnH }
self._modConfirmNo = { x = dx + dw - 16 * s - btnW, y = by,
width = btnW, height = btnH }
local yhot = self:_hover(self._modConfirmYes)
local nhot = self:_hover(self._modConfirmNo)
fillGradRounded(self._modConfirmYes.x, by, btnW, btnH, 8 * s,
PAL.playTop, PAL.playBot, yhot and 1 or 0.85, yhot and 1 or 0.85)
col(PAL.disabled, nhot and 0.55 or 0.35)
love.graphics.rectangle("fill", self._modConfirmNo.x, by, btnW, btnH, 8 * s, 8 * s)
love.graphics.setFont(self.saveBtnFont)
col(PAL.white)
printfB(c.yesLabel or "OK", self._modConfirmYes.x,
by + (btnH - self.saveBtnFont:getHeight()) / 2, btnW, "center")
col(PAL.detail)
printfB("Cancel", self._modConfirmNo.x, by + (btnH - self.saveBtnFont:getHeight()) / 2,
btnW, "center")
elseif self._modReleaseNotes then
local n = self._modReleaseNotes
local ModUpdate = require("src.mods.ModUpdate")
local dw = math.min(appW - 32 * s, 480 * s)
local dh = math.min(height - 48 * s, 360 * s)
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
col(PAL.green, 0.5)
love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr)
love.graphics.setFont(self.slotNameFont)
col(PAL.white)
love.graphics.printf("v" .. tostring(n.version) .. " notes",
dx + 16 * s, dy + 12 * s, dw - 32 * s, "left")
local body = ModUpdate.cleanBody(n.body or "", 0)
if body == "" then body = "(No release notes.)" end
love.graphics.setFont(self.hintFont)
col(PAL.detail)
local textTop = dy + 44 * s
local textH = dh - 44 * s - 52 * s
love.graphics.setScissor(math.floor(dx + 16 * s), math.floor(textTop),
math.ceil(dw - 32 * s), math.ceil(textH))
love.graphics.printf(body, dx + 16 * s, textTop - (n.scroll or 0),
dw - 32 * s, "left")
love.graphics.setScissor()
local closeW = self.hintFont:getWidth("Close") + 28 * s
local closeH = 30 * s
self._modReleaseNotesClose = {
x = dx + (dw - closeW) / 2, y = dy + dh - closeH - 12 * s,
width = closeW, height = closeH,
}
local chot = self:_hover(self._modReleaseNotesClose)
col(PAL.disabled, chot and 0.55 or 0.35)
love.graphics.rectangle("fill", self._modReleaseNotesClose.x,
self._modReleaseNotesClose.y, closeW, closeH, 8 * s, 8 * s)
col(PAL.detail)
printfB("Close", self._modReleaseNotesClose.x,
self._modReleaseNotesClose.y + (closeH - self.hintFont:getHeight()) / 2,
closeW, "center")
elseif self._modVersions then
local ModUpdate = require("src.mods.ModUpdate")
local v = self._modVersions
local dw = math.min(appW - 40 * s, 520 * s)
local pad = 16 * s
local headerH = 56 * s
local rowH = 52 * s
local footerH = 48 * s
local listN = math.min(6, math.max(0, #v.releases))
local listH = math.max(rowH, listN * rowH)
local dh = headerH + listH + footerH
dh = math.min(dh, height - 40 * s)
-- recompute how many rows fit under the clamped dialog height
local fitN = math.max(1, math.floor((dh - headerH - footerH) / rowH))
listN = math.min(listN, fitN)
listH = listN * rowH
dh = headerH + listH + footerH
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
col(PAL.green, 0.5)
love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr)
love.graphics.setFont(self.slotNameFont)
col(PAL.white)
love.graphics.printf("Other versions — " .. tostring(v.name),
dx + pad, dy + 10 * s, dw - pad * 2, "left")
love.graphics.setFont(self.hintFont)
local info = self:_modUpdateInfo(v.id)
local statusTxt = "Installed: v" .. tostring(v.current)
local statusCol = PAL.detail
if info and info.status == "available" then
statusTxt = statusTxt .. " - Update v" .. tostring(info.latest)
statusCol = PAL.playTop
elseif info and info.status == "current" then
statusTxt = statusTxt .. " - Up to date"
statusCol = PAL.playTop
end
col(statusCol)
love.graphics.printf(statusTxt, dx + pad, dy + 34 * s, dw - pad * 2, "left")
self._modVersionRects = {}
self._modVersionNotesRects = {}
local listTop = dy + headerH
local notesW = self.hintFont:getWidth("Read more") + 20 * s
local installW = self.hintFont:getWidth("Install") + 20 * s
local btnH = 26 * s
-- clip the list to the band above the footer so nothing can paint over Close
love.graphics.setScissor(math.floor(dx + 2 * s), math.floor(listTop),
math.ceil(dw - 4 * s), math.ceil(listH))
for i = 1, listN do
local rel = v.releases[i]
local ly = listTop + (i - 1) * rowH
local rect = { x = dx + 12 * s, y = ly + 2 * s, width = dw - 24 * s,
height = rowH - 6 * s, release = rel }
col(PAL.bgBot, 0.45)
love.graphics.rectangle("fill", rect.x, rect.y, rect.width, rect.height, 8 * s, 8 * s)
love.graphics.setLineWidth(1)
col(PAL.cardBorder, 0.35)
love.graphics.rectangle("line", rect.x, rect.y, rect.width, rect.height, 8 * s, 8 * s)
love.graphics.setFont(self.hintFont)
local label = "v" .. rel.version
if rel.version == v.current then label = label .. " (installed)" end
if rel.prerelease then label = label .. " pre" end
col(rel.version == v.current and PAL.warning or PAL.white)
love.graphics.print(label, rect.x + 12 * s, rect.y + 6 * s)
-- one-line ellipsized preview only (never wrap changelog into the row)
local btnStackW = 0
local hasNotes = type(rel.body) == "string" and rel.body:match("%S")
local canInstall = rel.version ~= v.current
if hasNotes then btnStackW = btnStackW + notesW end
if canInstall then btnStackW = btnStackW + (hasNotes and 8 * s or 0) + installW end
local previewW = math.max(24 * s, rect.width - 24 * s - btnStackW - 12 * s)
local preview = ModUpdate.previewLine(rel.body or "", 90)
if preview ~= "" then
col(PAL.detail)
love.graphics.print(
ellipsize(self.hintFont, preview, previewW),
rect.x + 12 * s,
rect.y + 6 * s + self.hintFont:getHeight() + 2 * s)
end
local btnY = rect.y + (rect.height - btnH) / 2
local bx = rect.x + rect.width - 10 * s
if canInstall then
bx = bx - installW
local irect = self:_chipButton(bx, btnY, "Install", {
w = installW, h = btnH, id = v.id, kind = "accent",
})
irect.release = rel
self._modVersionRects[#self._modVersionRects + 1] = irect
bx = bx - 8 * s
end
if hasNotes then
bx = bx - notesW
local nrect = self:_chipButton(bx, btnY, "Read more", {
w = notesW, h = btnH, id = v.id, kind = "neutral",
})
nrect.release = rel
self._modVersionNotesRects[#self._modVersionNotesRects + 1] = nrect
end
end
love.graphics.setScissor()
-- opaque footer so list content can never bleed under Close
local footerY = dy + dh - footerH
col(PAL.slotBg, 1)
love.graphics.rectangle("fill", dx + 2 * s, footerY, dw - 4 * s, footerH - 2 * s)
local closeW = self.hintFont:getWidth("Close") + 32 * s
local closeH = 32 * s
self._modVersionsClose = {
x = dx + (dw - closeW) / 2,
y = footerY + (footerH - closeH) / 2 - 2 * s,
width = closeW, height = closeH,
}
local chot = self:_hover(self._modVersionsClose)
col(PAL.disabled, chot and 0.55 or 0.35)
love.graphics.rectangle("fill", self._modVersionsClose.x,
self._modVersionsClose.y, closeW, closeH, 8 * s, 8 * s)
love.graphics.setFont(self.hintFont)
col(PAL.detail)
printfB("Close", self._modVersionsClose.x,
self._modVersionsClose.y + (closeH - self.hintFont:getHeight()) / 2,
closeW, "center")
end
-- pointer cursor over any interactive element (desktop only)
if self._hoverEnabled and not self._padCursorActive
and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
@@ -2147,6 +2381,53 @@ end
function RomImporter:mousepressed(x, y, button)
if self._rename then return end -- the rename modal swallows all clicks
-- Mod confirm / versions / release-notes modals swallow clicks too.
if self._modConfirm then
if button ~= 1 then return end
if inside(self._modConfirmYes, x, y) then
local c = self._modConfirm
self._modConfirm = nil
if c.kind == "update" then
self:_confirmModUpdate(c.id, c.release)
else
self:_toggleMod(c.id, true)
end
elseif inside(self._modConfirmNo, x, y) then
self._modConfirm = nil
end
return
end
if self._modReleaseNotes then
if button ~= 1 then return end
if inside(self._modReleaseNotesClose, x, y) then
self._modReleaseNotes = nil
end
return
end
if self._modVersions then
if button ~= 1 then return end
if inside(self._modVersionsClose, x, y) then
self._modVersions = nil
return
end
for _, r in ipairs(self._modVersionNotesRects or {}) do
if inside(r, x, y) and r.release then
self._modReleaseNotes = {
version = r.release.version,
body = r.release.body or "",
scroll = 0,
}
return
end
end
for _, r in ipairs(self._modVersionRects or {}) do
if inside(r, x, y) and r.release then
self:_installModVersion(self._modVersions.id, r.release)
return
end
end
return
end
-- Whether a press can be ARMED and resolved on release, which needs a
-- pollable pointer: always on desktop, on Android only where love.touch is.
local armDrag = (not self.android) or self.touchPollable
@@ -2283,6 +2564,18 @@ function RomImporter:mousepressed(x, y, button)
return
end
end
for _, r in ipairs(self.modUpdateRects or {}) do
if inside(r, x, y) then
self:_modGithubAction(r.id, "update")
return
end
end
for _, r in ipairs(self.modVersionsRects or {}) do
if inside(r, x, y) then
self:_modGithubAction(r.id, "versions")
return
end
end
for _, r in ipairs(self.modRects or {}) do
if inside(r, x, y) then
if not armDrag then
@@ -2312,6 +2605,17 @@ function RomImporter:keypressed(key)
end
return
end
if self._modConfirm or self._modVersions or self._modReleaseNotes then
if key == "escape" then
if self._modReleaseNotes then
self._modReleaseNotes = nil
else
self._modConfirm = nil
self._modVersions = nil
end
end
return
end
if self.workState == "working" then return end
if key == "return" or key == "space" or key == "kpenter" then
-- Enter acts on the visible game tab: Play if its ROM is ready, otherwise
@@ -2373,6 +2677,52 @@ function RomImporter:_glassyButton(x, y, w, h, label, font, enabled)
return rect
end
-- Compact pill button for row actions (Edit / Delete / Update / Versions).
-- kind: "neutral" (default), "accent" (green), "danger" (red), "dangerArmed"
-- (filled confirm). Returns the hit rect; opts.id is copied onto it.
function RomImporter:_chipButton(x, y, label, opts)
opts = opts or {}
local s = self._s
local font = opts.font or self.hintFont
local padX = opts.padX or (12 * s)
local h = opts.h or (font:getHeight() + 10 * s)
love.graphics.setFont(font)
local w = opts.w or (font:getWidth(label) + 2 * padX)
local r = opts.r or math.min(8 * s, h / 2)
local kind = opts.kind or "neutral"
local rect = { x = x, y = y, width = w, height = h, id = opts.id }
local hot = self:_hover(rect)
if kind == "dangerArmed" then
fillGradRounded(x, y, w, h, r, PAL.chooseTop, PAL.chooseBot,
hot and 1 or 0.92, hot and 1 or 0.92)
col(PAL.white)
elseif kind == "danger" then
col(PAL.chooseTop, hot and 0.28 or 0.14)
love.graphics.rectangle("fill", x, y, w, h, r, r)
love.graphics.setLineWidth(math.max(1, s))
col(PAL.chooseTop, hot and 0.95 or 0.7)
love.graphics.rectangle("line", x, y, w, h, r, r)
col(hot and PAL.white or PAL.chooseTop)
elseif kind == "accent" then
col(PAL.playTop, hot and 0.28 or 0.12)
love.graphics.rectangle("fill", x, y, w, h, r, r)
love.graphics.setLineWidth(math.max(1, s))
col(PAL.playTop, hot and 0.95 or 0.65)
love.graphics.rectangle("line", x, y, w, h, r, r)
col(hot and PAL.white or PAL.playTop)
else
fillGradRounded(x, y, w, h, r, PAL.white, PAL.white,
hot and 0.22 or 0.12, 0.04)
love.graphics.setLineWidth(math.max(1, s))
col(PAL.white, hot and 0.35 or 0.18)
love.graphics.rectangle("line", x, y, w, h, r, r)
col(PAL.white)
end
printfB(label, x, y + (h - font:getHeight()) / 2, w, "center")
return rect
end
-- The tall green Play button (ready) or a disabled placeholder. Sets
-- self.playButtonRect.
function RomImporter:_playButton(x, y, w, h, gameName, ready, locked)
@@ -2955,9 +3305,15 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged)
local nameH = self.slotNameFont:getHeight()
local metaH = self.labelFont:getHeight()
local rowPadV = 10 * s
local rowH = rowPadV * 2 + nameH + 4 * s + metaH
local chipBtnH = self.hintFont:getHeight() + 10 * s
-- LOADED sits top-right; Edit/Delete sit bottom-right — row must fit both
-- without stacking on the same y (chip buttons are taller than the old text).
local loadedH = self.warningFont:getHeight() + 6 * s
local rowH = math.max(rowPadV * 2 + nameH + 4 * s + metaH,
rowPadV + loadedH + 4 * s + chipBtnH + rowPadV)
local rowGap = 8 * s
local rr = 12 * s
local btnGap = 8 * s
-- an empty registry shows a fixed-height dashed hint box instead of rows
local totalH = (n > 0) and (n * rowH + (n - 1) * rowGap) or (96 * s)
local naturalH = pad + labelH + 12 * s + totalH + 10 * s + newBtnH + pad
@@ -3024,48 +3380,41 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged)
col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22)
love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr)
-- Delete label (bottom-right); reserve its width so name/meta don't overlap
-- Edit + Delete chip buttons (bottom-right row). Delete arms on the
-- first click and asks "Sure?" on the second; width stays on "Delete"
-- so the row never reflows (#433).
love.graphics.setFont(self.hintFont)
local delText = "Delete"
local delW = self.hintFont:getWidth(delText)
local delH = self.hintFont:getHeight()
local delX = rx + rw - 12 * s - delW
local delY = ry + rowH - rowPadV - delH
local drect = { x = delX - 6 * s, y = delY - 4 * s,
width = delW + 12 * s, height = delH + 8 * s, id = slot.id }
local dhot = self:_hover(drect)
-- Armed by a first click, the label asks before a second one commits;
-- the box stays sized to "Delete" so the row never reflows (#433).
local darmed = armedDelete(self._confirmDelete, "slot", slot.id, version)
col((darmed or dhot) and PAL.red or PAL.warning)
love.graphics.printf(darmed and "Sure?" or delText, delX, delY, delW, "center")
local delLabel = darmed and "Sure?" or "Delete"
local delW = self.hintFont:getWidth("Delete") + 24 * s
local delX = rx + rw - 12 * s - delW
local delY = ry + rowH - rowPadV - chipBtnH
local drect = self:_chipButton(delX, delY, delLabel, {
w = delW, h = chipBtnH, id = slot.id,
kind = darmed and "dangerArmed" or "danger",
})
local rightReserve = delW + 18 * s
-- Edit label, immediately left of Delete: opens the bundled save
-- editor (tools/save-editor) on this slot's file. Only drawn when the
-- host supplied onEditSave and the slot actually holds a save -- there
-- is nothing to edit in an empty slot, and offering it would open the
-- editor on a new-game stub the player never asked for.
-- Edit, immediately left of Delete: opens the bundled save editor on
-- this slot's file. Only when the host supplied onEditSave and the
-- slot actually holds a save.
local erect = nil
if self.onEditSave and slot.exists then
local edText = "Edit"
local edW = self.hintFont:getWidth(edText)
local edX = delX - 14 * s - edW
erect = { x = edX - 6 * s, y = delY - 4 * s,
width = edW + 12 * s, height = delH + 8 * s, id = slot.id }
local ehot = self:_hover(erect)
col(ehot and PAL.blue or PAL.warning)
love.graphics.print(edText, edX, delY)
rightReserve = rightReserve + edW + 20 * s
local edW = self.hintFont:getWidth("Edit") + 24 * s
local edX = delX - btnGap - edW
erect = self:_chipButton(edX, delY, "Edit", {
w = edW, h = chipBtnH, id = slot.id, kind = "accent",
})
rightReserve = rightReserve + edW + btnGap + 6 * s
end
-- LOADED pill (top-right of the active row), then reserve its width
-- LOADED pill top-right (above the button row, never over Delete)
local pillW = 0
if selected then
love.graphics.setFont(self.warningFont)
local pText = "LOADED"
local pw = self.warningFont:getWidth(pText) + 14 * s
local ph = self.warningFont:getHeight() + 6 * s
local ph = loadedH
local ppx = rx + rw - 12 * s - pw
local ppy = ry + rowPadV
col(PAL.green)
@@ -3177,24 +3526,241 @@ function RomImporter:_refreshMods()
end
end
self.mods = LauncherMods.list() or {}
self:_syncModUpdateInfo(false)
end
function RomImporter:_ensureMods()
if not self.mods then self:_refreshMods() end
end
-- Resolve cached (or freshly fetched) GitHub status for every mod that
-- declares a github field. force=true bypasses the 6h cache on every repo.
-- Results live on self.modUpdateInfo[id] = { status, latest, best, releases }.
function RomImporter:_syncModUpdateInfo(force)
local ModUpdate = require("src.mods.ModUpdate")
self.modUpdateInfo = self.modUpdateInfo or {}
for _, m in ipairs(self.mods or {}) do
if m.github and m.github ~= "" then
local ok, packed = pcall(function()
local releases, err, meta = ModUpdate.fetchReleases(m.github, m.id, {
force = force == true,
})
local cached = ModUpdate.readCache(m.github)
return {
releases = releases,
err = err,
meta = meta,
checkedAt = (cached and cached.checkedAt) or os.time(),
}
end)
if not ok then
self.modUpdateInfo[m.id] = {
status = "error", err = tostring(packed),
}
elseif packed.releases then
local status, best = ModUpdate.statusFor(m.version, packed.releases)
self.modUpdateInfo[m.id] = {
status = status,
latest = best and best.version or nil,
best = best,
releases = packed.releases,
err = nil,
checkedAt = packed.checkedAt or os.time(),
}
else
self.modUpdateInfo[m.id] = {
status = "error",
latest = nil,
best = nil,
releases = nil,
err = tostring(packed.err),
}
end
else
self.modUpdateInfo[m.id] = nil
end
end
end
function RomImporter:_modUpdateInfo(id)
return self.modUpdateInfo and self.modUpdateInfo[id] or nil
end
-- Flip a mod's enabled flag (persisted via LauncherMods.setEnabled) and relist
-- so the toggle, count, and every status chip reflect the new resolution.
function RomImporter:_toggleMod(id)
-- Enabling an experimental mod arms a confirm first.
function RomImporter:_toggleMod(id, confirmed)
local LauncherMods = require("src.mods.LauncherMods")
local cur = false
local cur, experimental = false, false
for _, m in ipairs(self.mods or {}) do
if m.id == id then cur = m.enabled; break end
if m.id == id then
cur = m.enabled
experimental = m.experimental == true
break
end
end
LauncherMods.setEnabled(id, not cur)
local want = not cur
if want and experimental and not confirmed then
self._modConfirm = {
kind = "experimental", id = id,
title = "Experimental mod",
yesLabel = "Enable",
lines = {
"This mod is marked experimental.",
"It may be unfinished or unstable.",
"Enable it anyway?",
},
}
return
end
self._modConfirm = nil
LauncherMods.setEnabled(id, want)
self:_refreshMods()
end
-- GitHub Update / Check for updates / Versions. Soft-fails into modNotice.
-- Update button: when a newer release is known, confirm then install; when
-- already current, force-refresh the 6h cache and report / offer update.
function RomImporter:_modGithubAction(id, action)
local ran, err = pcall(function()
local ModUpdate = require("src.mods.ModUpdate")
local row
for _, m in ipairs(self.mods or {}) do
if m.id == id then row = m; break end
end
if not row or not row.github then
self.modNotice = { ok = false, text = "This mod has no github field" }
return
end
if action == "versions" then
self.modNotice = { ok = true, text = "Loading versions..." }
local releases, fetchErr = ModUpdate.fetchReleases(row.github, row.id, {})
if not releases then
self.modNotice = { ok = false, text = tostring(fetchErr) }
return
end
local status, best = ModUpdate.statusFor(row.version, releases)
self.modUpdateInfo = self.modUpdateInfo or {}
self.modUpdateInfo[row.id] = {
status = status, latest = best and best.version, best = best,
releases = releases,
}
self._modVersions = {
id = row.id, name = row.name, current = row.version,
releases = releases, scroll = 0,
}
self.modNotice = nil
return
end
-- update / check
local info = self:_modUpdateInfo(id)
if info and info.status == "available" and info.best then
self._modConfirm = {
kind = "update", id = row.id, release = info.best,
title = "Update available",
yesLabel = "Update",
lines = {
"Update " .. row.name .. "?",
"Installed v" .. tostring(row.version),
"Latest v" .. tostring(info.best.version),
},
}
return
end
-- Manual check (or first click when status is current/unknown/error)
self.modNotice = { ok = true, text = "Checking " .. row.github .. "..." }
local releases, fetchErr = ModUpdate.fetchReleases(row.github, row.id, {
force = true,
})
if not releases then
self.modNotice = { ok = false, text = tostring(fetchErr) }
return
end
if #releases == 0 then
self.modNotice = { ok = false, text = "No .zip releases found" }
return
end
local status, best = ModUpdate.statusFor(row.version, releases)
self.modUpdateInfo = self.modUpdateInfo or {}
self.modUpdateInfo[row.id] = {
status = status, latest = best and best.version, best = best,
releases = releases, checkedAt = os.time(),
}
if status == "available" and best then
self.modNotice = { ok = true,
text = row.name .. ": new version available (v" .. best.version .. ")" }
self._modConfirm = {
kind = "update", id = row.id, release = best,
title = "Update available",
yesLabel = "Update",
lines = {
"Update " .. row.name .. "?",
"Installed v" .. tostring(row.version),
"Latest v" .. tostring(best.version),
},
}
else
self.modNotice = { ok = true,
text = row.name .. " is up to date (v"
.. tostring(row.version) .. ")" }
end
end)
if not ran then
self._modVersions = nil
self.modNotice = { ok = false,
text = "Update failed: " .. tostring(err) }
end
end
function RomImporter:_confirmModUpdate(modId, release)
local row
for _, m in ipairs(self.mods or {}) do
if m.id == modId then row = m; break end
end
local name = row and row.name or modId
self.modNotice = { ok = true,
text = "Downloading " .. tostring(release and release.version or "?") .. "..." }
local ran, err = pcall(function()
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.installFromRelease(modId, release)
if ok then
pcall(self._refreshMods, self)
self.modNotice = { ok = true,
text = "Updated " .. name .. " to " .. tostring(res) }
else
self.modNotice = { ok = false, text = tostring(res) }
end
end)
if not ran then
self.modNotice = { ok = false, text = "Update failed: " .. tostring(err) }
end
end
function RomImporter:_installModVersion(modId, release)
self._modVersions = nil
self._modReleaseNotes = nil
local version = release and release.version or "?"
self.modNotice = { ok = true, text = "Downloading " .. tostring(version) .. "..." }
local ran, err = pcall(function()
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.installFromRelease(modId, release)
if ok then
pcall(self._refreshMods, self)
self.modNotice = { ok = true,
text = "Installed " .. tostring(modId) .. " " .. tostring(res) }
else
self.modNotice = { ok = false, text = tostring(res) }
end
end)
if not ran then
self.modNotice = { ok = false,
text = "Install failed: " .. tostring(err) }
end
end
-- The status-chip label + colour for a mod row (deriveList's status verdict).
local function modStatusChip(status)
if status == "ok" then return "Ready", PAL.green end
@@ -3267,18 +3833,21 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center")
self.modRects = {}
self.modDeleteRects = {}
self.modUpdateRects = {}
self.modVersionsRects = {}
self._modMax = 0
return (top - y) + boxH
end
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28; Delete under)
-- card metrics: status + toggle top-right; action chip-buttons in one row
-- under the body (Update / Versions / Delete when github is set).
local padH, padV = 16 * s, 14 * s
local cardGap, cardR = 10 * s, 14 * s
local tw, th = 52 * s, 28 * s
local innerW = w - 2 * padH
local chipH = self.hintFont:getHeight() + 8 * s
local delH = self.hintFont:getHeight()
local clusterH = chipH + 6 * s + th + 6 * s + delH
local btnH = self.hintFont:getHeight() + 10 * s
local btnGap = 8 * s
love.graphics.setFont(self.stateFont)
local nameH = self.stateFont:getHeight()
@@ -3288,8 +3857,36 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
for i, m in ipairs(mods) do
local chipText = modStatusChip(m.status)
local chipW = self.hintFont:getWidth(chipText) + 20 * s
local delW = self.hintFont:getWidth("Delete")
local clusterW = math.max(chipW, tw, delW)
local delW = self.hintFont:getWidth("Delete") + 24 * s
local verW = self.hintFont:getWidth("Versions") + 24 * s
local hasGh = m.github and m.github ~= ""
local info = hasGh and self:_modUpdateInfo(m.id) or nil
local updLabel = "Check for updates"
local updateKind = "neutral"
-- checkLine: always on the mod row for github mods so the check result
-- is visible without relying on the top-of-panel notice.
local checkLine = nil
local checkLineColor = PAL.detail
if info and info.status == "available" then
updLabel = "Update"
updateKind = "accent"
checkLine = "Checked for updates - v" .. tostring(info.latest) .. " available"
checkLineColor = PAL.playTop
elseif info and info.status == "current" then
updLabel = "Check again"
checkLine = "Checked for updates - up to date"
checkLineColor = PAL.playTop
elseif info and info.status == "error" then
checkLine = "Checked for updates - failed"
checkLineColor = PAL.chooseTop
elseif hasGh then
checkLine = "Not checked for updates yet"
checkLineColor = PAL.warning
end
local updW = self.hintFont:getWidth(updLabel) + 24 * s
local btnRowW = delW
if hasGh then btnRowW = updW + btnGap + verW + btnGap + delW end
local clusterW = math.max(chipW, tw)
local leftW = math.max(40 * s, innerW - clusterW - 14 * s)
local descH = 0
if m.description ~= "" then
@@ -3297,10 +3894,22 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
local _, dl = self.hintFont:getWrap(m.description, leftW)
descH = math.max(1, #dl) * self.hintFont:getHeight()
end
local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0)
local cardH = math.max(contentH, padV * 2 + clusterH)
local clusterH = chipH + 6 * s + th
local metaH = self.hintFont:getHeight() + 2 * s
if checkLine then
metaH = metaH + self.hintFont:getHeight() + 2 * s
end
if descH > 0 then
metaH = metaH + self.hintFont:getHeight() + 2 * s + descH
end
local bodyH = math.max(nameH + 4 * s + metaH, clusterH)
local cardH = padV * 2 + bodyH + 10 * s + btnH
layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW,
chipText = chipText, chipW = chipW, delW = delW }
chipText = chipText, chipW = chipW, delW = delW,
updW = updW, verW = verW, hasGh = hasGh, clusterH = clusterH,
btnRowW = btnRowW, bodyH = bodyH, updLabel = updLabel,
updateKind = updateKind, checkLine = checkLine,
checkLineColor = checkLineColor }
total = total + cardH
end
total = total + (#mods - 1) * cardGap
@@ -3314,6 +3923,8 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
self.modScroll = scroll
self.modRects = {}
self.modDeleteRects = {}
self.modUpdateRects = {}
self.modVersionsRects = {}
if not paged then
love.graphics.setScissor(math.floor(x), math.floor(top),
@@ -3343,20 +3954,30 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
col(PAL.cardBorder, 0.5)
love.graphics.rectangle("line", bxx, byy, badgeW, badgeH, 5 * s, 5 * s)
love.graphics.setFont(self.warningFont)
col(PAL.warning)
col(m.experimental and PAL.gold or PAL.warning)
love.graphics.print(m.badge, bxx + 6 * s,
byy + (badgeH - self.warningFont:getHeight()) / 2)
-- description under the name, wrapped in the left block
-- version + check status line + description under the name
love.graphics.setFont(self.hintFont)
col(PAL.detail)
local metaY = ny + nameH + 4 * s
love.graphics.print("v" .. tostring(m.version or "?"), nx, metaY)
metaY = metaY + self.hintFont:getHeight() + 2 * s
if L.checkLine then
col(L.checkLineColor or PAL.detail)
love.graphics.print(
ellipsize(self.hintFont, L.checkLine, L.leftW), nx, metaY)
metaY = metaY + self.hintFont:getHeight() + 2 * s
end
if m.description ~= "" then
love.graphics.setFont(self.hintFont)
col(PAL.detail)
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
love.graphics.printf(m.description, nx, metaY, L.leftW, "left")
end
-- right cluster: status chip, toggle, Delete -- vertically centred
-- right cluster: status chip + toggle (action buttons are a bottom row)
local clusterX = x + w - padH - L.clusterW
local clusterY = cy + (cardH - clusterH) / 2
local clusterY = cy + padV
local _, chipColor = modStatusChip(m.status)
local chipX = clusterX + (L.clusterW - L.chipW) / 2
col(chipColor, 0.1)
@@ -3388,31 +4009,46 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
col(PAL.white)
love.graphics.circle("fill", kcx, ty + th / 2, kd / 2)
-- Delete under the toggle
local delX = clusterX + (L.clusterW - L.delW) / 2
local delY = ty + th + 6 * s
local drect = { x = delX - 6 * s, y = delY - 2 * s,
width = L.delW + 12 * s, height = delH + 4 * s, id = m.id }
local dhot = self:_hover(drect)
love.graphics.setFont(self.hintFont)
-- Same two-click arm as a save slot's Delete (#433).
-- Action chip-buttons in one right-aligned row under the body
local btnY = cy + cardH - padV - btnH
local btnX = x + w - padH - L.btnRowW
local darmed = armedDelete(self._confirmDelete, "mod", m.id, nil)
col((darmed or dhot) and PAL.red or PAL.warning)
love.graphics.printf(darmed and "Sure?" or "Delete", delX, delY, L.delW, "center")
local function clipHit(rect, bucket)
if not rect then return end
local vy = math.max(rect.y, top)
local vy2 = math.min(rect.y + rect.height, top + listH)
if vy2 > vy then
bucket[#bucket + 1] = {
x = rect.x, y = vy, width = rect.width, height = vy2 - vy,
id = rect.id,
}
end
end
if L.hasGh then
local urect = self:_chipButton(btnX, btnY, L.updLabel, {
w = L.updW, h = btnH, id = m.id, kind = L.updateKind or "neutral",
})
clipHit(urect, self.modUpdateRects)
btnX = btnX + L.updW + btnGap
local vrect = self:_chipButton(btnX, btnY, "Versions", {
w = L.verW, h = btnH, id = m.id, kind = "neutral",
})
clipHit(vrect, self.modVersionsRects)
btnX = btnX + L.verW + btnGap
end
local drect = self:_chipButton(btnX, btnY, darmed and "Sure?" or "Delete", {
w = L.delW, h = btnH, id = m.id,
kind = darmed and "dangerArmed" or "danger",
})
clipHit(drect, self.modDeleteRects)
-- hit rects clipped to the visible list band
-- toggle hit rect clipped to the visible list band
local vy = math.max(trect.y, top)
local vy2 = math.min(trect.y + trect.height, top + listH)
if vy2 > vy then
self.modRects[#self.modRects + 1] =
{ x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id }
end
local dvy = math.max(drect.y, top)
local dvy2 = math.min(drect.y + drect.height, top + listH)
if dvy2 > dvy then
self.modDeleteRects[#self.modDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = m.id }
end
end
cy = cy + cardH + cardGap
end