maybe smoother mobile scroll on installed mods

This commit is contained in:
bryanthaboi
2026-08-21 09:17:06 -04:00
parent a56add6d17
commit 5c1837b1eb
3 changed files with 142 additions and 114 deletions
+107 -104
View File
@@ -2142,124 +2142,127 @@ local function buildModsPanel(imp, x, y, w, availH, m)
+ math.floor(8 * m.s) + math.floor(8 * m.s)
local listTop = cy local listTop = cy
-- One continuous list: every row is laid out, the region scroll moves -- One continuous list: derive the rows that can touch the viewport before
-- through all of it, and only rows inside the region's viewport draw -- -- entering the loop. Drawing was already culled, but scanning every
-- so the per-frame cost stays bounded by the window, not the list. -- installed row to discover that defeats the point on a large mod library.
local view = imp._tabRegionRect local view = imp._tabRegionRect
local viewTop = view and view.y or listTop local viewTop = view and view.y or listTop
local viewBot = view and (view.y + view.h) or (listTop + availH) local viewBot = view and (view.y + view.h) or (listTop + availH)
for i = 1, #mods do local stride = rowH + gap
local first = math.max(1,
math.ceil((viewTop - rowH - listTop) / stride) + 1)
local last = math.min(#mods,
math.floor((viewBot - listTop) / stride) + 1)
for i = first, last do
local mod = mods[i] local mod = mods[i]
local ry = listTop + (i - 1) * (rowH + gap) local ry = listTop + (i - 1) * (rowH + gap)
if ry + rowH >= viewTop and ry <= viewBot then local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
local rowKey = rowKeyFor(imp, "mod-row-", mod.id) local isFullyDisabled = true
local isFullyDisabled = true if mod.enabledByVersion then
if mod.enabledByVersion then for _, on in pairs(mod.enabledByVersion) do
for _, on in pairs(mod.enabledByVersion) do if on then isFullyDisabled = false; break end
if on then isFullyDisabled = false; break end
end
else
isFullyDisabled = not mod.enabled
end end
else
isFullyDisabled = not mod.enabled
end
local focused = Kit.focusable(rowKey, x, ry, w, rowH) local focused = Kit.focusable(rowKey, x, ry, w, rowH)
local hot = focused or Kit.hover(x, ry, w, rowH) local hot = focused or Kit.hover(x, ry, w, rowH)
if isFullyDisabled then if isFullyDisabled then
Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted") Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted")
else else
Kit.card(x, ry, w, rowH, hot) Kit.card(x, ry, w, rowH, hot)
end end
local pad = math.floor(12 * m.s) local pad = math.floor(12 * m.s)
local px, inner = x + pad, w - 2 * pad local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s) local ly = ry + math.floor(10 * m.s)
local togGap = math.floor(5 * m.s) + 1 local togGap = math.floor(5 * m.s) + 1
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
-- These answer separate games, not a single shared install flag. The -- These answer separate games, not a single shared install flag. The
-- importer receives the game id so an experimental confirmation also -- importer receives the game id so an experimental confirmation also
-- applies only to the checkbox the player pressed. -- applies only to the checkbox the player pressed.
local flipped = false local flipped = false
local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s) local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
Kit.text("micro", gamesLabel, px, Kit.text("micro", gamesLabel, px,
gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted) gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s) local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s)
for _, game in ipairs(GameVersion.ORDER) do for _, game in ipairs(GameVersion.ORDER) do
local togKey = "mod-toggle-" .. mod.id .. "-" .. game local togKey = "mod-toggle-" .. mod.id .. "-" .. game
if modGameCheckbox(tx, gamesY, togH, if modGameCheckbox(tx, gamesY, togH,
mod.enabledByVersion and mod.enabledByVersion[game] == true, mod.enabledByVersion and mod.enabledByVersion[game] == true,
game, togKey, not safeMode) then game, togKey, not safeMode) then
local version = game local version = game
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
flipped = true flipped = true
end
tx = tx + togH + togGap
end end
-- The checkboxes sit inside the row's rect, so their press also passes the tx = tx + togH + togGap
-- row hit test; `flipped` gates the row action to everywhere else. end
if not flipped -- The checkboxes sit inside the row's rect, so their press also passes the
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then -- row hit test; `flipped` gates the row action to everywhere else.
local id = mod.id if not flipped
queueAction(imp, rowKey, function() imp._modActions = id end) and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
end local id = mod.id
local textW = inner queueAction(imp, rowKey, function() imp._modActions = id end)
end
local textW = inner
local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s) local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s)
-- the games the mod is for, beside its category: the same chip the -- the games the mod is for, beside its category: the same chip the
-- in-game manager shows (src/mods/ModTargets.lua) -- in-game manager shows (src/mods/ModTargets.lua)
local gamesW = mod.targets local gamesW = mod.targets
and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0 and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0
local nameShown = Kit.ellipsize("button", mod.name, local nameShown = Kit.ellipsize("button", mod.name,
textW - badgeW - gamesW - math.floor(12 * m.s)) textW - badgeW - gamesW - math.floor(12 * m.s))
local headingCol = isFullyDisabled and PAL.muted or PAL.heading local headingCol = isFullyDisabled and PAL.muted or PAL.heading
Kit.text("button", nameShown, px, ly, headingCol) Kit.text("button", nameShown, px, ly, headingCol)
local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s) local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s)
Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge, Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge,
mod.experimental and PAL.yellow or PAL.muted) mod.experimental and PAL.yellow or PAL.muted)
if mod.targets then if mod.targets then
Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW, Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW,
Kit.textHeight("button"), mod.targets, Kit.textHeight("button"), mod.targets,
mod.targetsHere == false and PAL.steel or PAL.blue) mod.targetsHere == false and PAL.steel or PAL.blue)
end end
ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) ly = ly + Kit.textHeight("button") + math.floor(4 * m.s)
-- version + status + update state -- version + status + update state
local statusText, statusCol = modStatusColor(mod.status) local statusText, statusCol = modStatusColor(mod.status)
local line = "v" .. tostring(mod.version or "?") .. " " .. statusText local line = "v" .. tostring(mod.version or "?") .. " " .. statusText
Kit.text("small", line, px, ly, statusCol) Kit.text("small", line, px, ly, statusCol)
local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s) local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s)
if imp:_modInfoPending(mod.id) then if imp:_modInfoPending(mod.id) then
-- An inline spinner, because this row's release check is genuinely in -- An inline spinner, because this row's release check is genuinely in
-- flight -- the list stays usable while it resolves. -- flight -- the list stays usable while it resolves.
Loader.dot(lx, ly, Kit.textHeight("small")) Loader.dot(lx, ly, Kit.textHeight("small"))
Kit.text("small", Strings("Checking..."), Kit.text("small", Strings("Checking..."),
lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted) lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted)
elseif info and info.status == "available" then elseif info and info.status == "available" then
Kit.text("small", Strings("v%s available", tostring(info.latest)), Kit.text("small", Strings("v%s available", tostring(info.latest)),
lx, ly, PAL.yellow) lx, ly, PAL.yellow)
elseif info and info.status == "current" then elseif info and info.status == "current" then
Kit.text("small", Strings("up to date"), lx, ly, PAL.muted) Kit.text("small", Strings("up to date"), lx, ly, PAL.muted)
elseif info and info.status == "error" then elseif info and info.status == "error" then
Kit.text("small", Strings("check failed"), lx, ly, PAL.red) Kit.text("small", Strings("check failed"), lx, ly, PAL.red)
end end
ly = ly + Kit.textHeight("small") + math.floor(2 * m.s) ly = ly + Kit.textHeight("small") + math.floor(2 * m.s)
-- one line of description, or the download stats when we have them -- one line of description, or the download stats when we have them
-- (download count in green so popularity reads at a glance) -- (download count in green so popularity reads at a glance)
if info and info.downloads then if info and info.downloads then
local d = info.dates local d = info.dates
local dl = ModUpdate.downloadsLine(info.downloads.total) local dl = ModUpdate.downloadsLine(info.downloads.total)
local dates = ModUpdate.datesLine(d and d.first, d and d.latest) local dates = ModUpdate.datesLine(d and d.first, d and d.latest)
local segs = {} local segs = {}
if dl then segs[#segs + 1] = { dl, PAL.green } end if dl then segs[#segs + 1] = { dl, PAL.green } end
if dates then if dates then
segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail } segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail }
end
segLine("small", segs, px, ly, textW)
elseif (mod.description or "") ~= "" then
Kit.text("small", Kit.ellipsize("small", mod.description, textW),
px, ly, PAL.detail)
end end
segLine("small", segs, px, ly, textW)
elseif (mod.description or "") ~= "" then
Kit.text("small", Kit.ellipsize("small", mod.description, textW),
px, ly, PAL.detail)
end end
end end
+6 -10
View File
@@ -4154,7 +4154,6 @@ function RomImporter:_refreshMods()
end end
self.mods = kept self.mods = kept
end end
self:_syncModUpdateInfo(false)
end end
-- Point the MODS panel at one game (or nil for all of them) and relist, so -- Point the MODS panel at one game (or nil for all of them) and relist, so
@@ -4168,15 +4167,12 @@ function RomImporter:_ensureMods()
if not self.mods then self:_refreshMods() end if not self.mods then self:_refreshMods() end
end end
-- Resolve cached (or freshly fetched) GitHub status for every mod that -- Resolve GitHub status for every installed mod that declares a github field.
-- declares a github field. force=true bypasses the 6h cache on every repo. -- This is deliberately opt-in: only the explicit "Check for updates" action
-- Results live on self.modUpdateInfo[id] = { status, latest, best, releases }. -- calls it. Opening, scrolling, toggling, or relisting the MODS tab must not
-- ASYNC (was synchronous). This runs on every _refreshMods -- boot, and any -- create a burst of release work behind the list. force=true bypasses the 6h
-- toggle or install -- and used to make one blocking curl call per mod with a -- cache on every repo. Results live on self.modUpdateInfo[id] = {
-- github field, in a loop, on the render thread. A handful of mods was a -- status, latest, best, releases } and resolve asynchronously across frames.
-- multi-second freeze of the whole launcher. Now each mod gets a handle and
-- they resolve together across later frames; a mod whose cache is still fresh
-- resolves on the first pump with no network at all.
function RomImporter:_syncModUpdateInfo(force) function RomImporter:_syncModUpdateInfo(force)
local ModUpdate = require("src.mods.ModUpdate") local ModUpdate = require("src.mods.ModUpdate")
self.modUpdateInfo = self.modUpdateInfo or {} self.modUpdateInfo = self.modUpdateInfo or {}
+29
View File
@@ -388,6 +388,35 @@ mdown = false
LauncherView.update(gameImp, 0.016) LauncherView.update(gameImp, 0.016)
love.mouse.isDown = nil love.mouse.isDown = nil
-- Opening or relisting MODS is display work only. Release checks are an
-- explicit action, otherwise each installed GitHub mod creates background
-- work exactly when the player starts scrolling the panel.
do
local savedMods = package.loaded["src.mods.LauncherMods"]
local savedSaveData = package.loaded["src.core.SaveData"]
local listCalls, updateCalls = 0, 0
package.loaded["src.mods.LauncherMods"] = {
list = function()
listCalls = listCalls + 1
return { { id = "test", github = "owner/repo" } }
end,
}
package.loaded["src.core.SaveData"] = {
loadOptions = function() return {} end,
isSafeMode = function() return false end,
}
local refreshImp = setmetatable({
modStraysChecked = true,
_syncModUpdateInfo = function() updateCalls = updateCalls + 1 end,
}, RomImporter)
refreshImp:_refreshMods()
eq(listCalls, 1, "relisting MODS still obtains its installed rows")
eq(updateCalls, 0,
"relisting MODS does not start release checks without the explicit button")
package.loaded["src.mods.LauncherMods"] = savedMods
package.loaded["src.core.SaveData"] = savedSaveData
end
local function read(path) local function read(path)
local f = assert(io.open(path, "r")) local f = assert(io.open(path, "r"))
local src = f:read("*a") local src = f:read("*a")