From 5c1837b1eb61ca3501f47c443e44e8c38cd2cd53 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 21 Aug 2026 09:17:06 -0400 Subject: [PATCH] maybe smoother mobile scroll on installed mods --- src/import/LauncherView.lua | 211 +++++++++++++------------- src/import/RomImporter.lua | 16 +- tests/engine/launcher_scroll_test.lua | 29 ++++ 3 files changed, 142 insertions(+), 114 deletions(-) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 11a36a41..a3cfac09 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -2142,124 +2142,127 @@ local function buildModsPanel(imp, x, y, w, availH, m) + math.floor(8 * m.s) local listTop = cy - -- One continuous list: every row is laid out, the region scroll moves - -- through all of it, and only rows inside the region's viewport draw -- - -- so the per-frame cost stays bounded by the window, not the list. + -- One continuous list: derive the rows that can touch the viewport before + -- entering the loop. Drawing was already culled, but scanning every + -- installed row to discover that defeats the point on a large mod library. local view = imp._tabRegionRect local viewTop = view and view.y or listTop 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 ry = listTop + (i - 1) * (rowH + gap) - if ry + rowH >= viewTop and ry <= viewBot then - local rowKey = rowKeyFor(imp, "mod-row-", mod.id) - local isFullyDisabled = true - if mod.enabledByVersion then - for _, on in pairs(mod.enabledByVersion) do - if on then isFullyDisabled = false; break end - end - else - isFullyDisabled = not mod.enabled + local rowKey = rowKeyFor(imp, "mod-row-", mod.id) + local isFullyDisabled = true + if mod.enabledByVersion then + for _, on in pairs(mod.enabledByVersion) do + if on then isFullyDisabled = false; break end end + else + isFullyDisabled = not mod.enabled + end - local focused = Kit.focusable(rowKey, x, ry, w, rowH) - local hot = focused or Kit.hover(x, ry, w, rowH) - if isFullyDisabled then - Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted") - else - Kit.card(x, ry, w, rowH, hot) - end - local pad = math.floor(12 * m.s) - local px, inner = x + pad, w - 2 * pad - local ly = ry + math.floor(10 * m.s) + local focused = Kit.focusable(rowKey, x, ry, w, rowH) + local hot = focused or Kit.hover(x, ry, w, rowH) + if isFullyDisabled then + Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted") + else + Kit.card(x, ry, w, rowH, hot) + end + local pad = math.floor(12 * m.s) + local px, inner = x + pad, w - 2 * pad + local ly = ry + math.floor(10 * m.s) - local togGap = math.floor(5 * m.s) + 1 - local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) + local togGap = math.floor(5 * m.s) + 1 + local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) - -- These answer separate games, not a single shared install flag. The - -- importer receives the game id so an experimental confirmation also - -- applies only to the checkbox the player pressed. - local flipped = false - local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s) - Kit.text("micro", gamesLabel, px, - gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted) - local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s) - for _, game in ipairs(GameVersion.ORDER) do - local togKey = "mod-toggle-" .. mod.id .. "-" .. game - if modGameCheckbox(tx, gamesY, togH, - mod.enabledByVersion and mod.enabledByVersion[game] == true, - game, togKey, not safeMode) then - local version = game - queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) - flipped = true - end - tx = tx + togH + togGap + -- These answer separate games, not a single shared install flag. The + -- importer receives the game id so an experimental confirmation also + -- applies only to the checkbox the player pressed. + local flipped = false + local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s) + Kit.text("micro", gamesLabel, px, + gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted) + local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s) + for _, game in ipairs(GameVersion.ORDER) do + local togKey = "mod-toggle-" .. mod.id .. "-" .. game + if modGameCheckbox(tx, gamesY, togH, + mod.enabledByVersion and mod.enabledByVersion[game] == true, + game, togKey, not safeMode) then + local version = game + queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) + flipped = true end - -- The checkboxes sit inside the row's rect, so their press also passes the - -- row hit test; `flipped` gates the row action to everywhere else. - if not flipped - and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then - local id = mod.id - queueAction(imp, rowKey, function() imp._modActions = id end) - end - local textW = inner + tx = tx + togH + togGap + end + -- The checkboxes sit inside the row's rect, so their press also passes the + -- row hit test; `flipped` gates the row action to everywhere else. + if not flipped + and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then + local id = mod.id + queueAction(imp, rowKey, function() imp._modActions = id end) + end + local textW = inner - 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 - -- in-game manager shows (src/mods/ModTargets.lua) - local gamesW = mod.targets - and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0 - local nameShown = Kit.ellipsize("button", mod.name, - textW - badgeW - gamesW - math.floor(12 * m.s)) - local headingCol = isFullyDisabled and PAL.muted or PAL.heading - Kit.text("button", nameShown, px, ly, headingCol) - local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s) - Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge, - mod.experimental and PAL.yellow or PAL.muted) - if mod.targets then - Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW, - Kit.textHeight("button"), mod.targets, - mod.targetsHere == false and PAL.steel or PAL.blue) - end - ly = ly + Kit.textHeight("button") + math.floor(4 * 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 + -- in-game manager shows (src/mods/ModTargets.lua) + local gamesW = mod.targets + and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0 + local nameShown = Kit.ellipsize("button", mod.name, + textW - badgeW - gamesW - math.floor(12 * m.s)) + local headingCol = isFullyDisabled and PAL.muted or PAL.heading + Kit.text("button", nameShown, px, ly, headingCol) + local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s) + Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge, + mod.experimental and PAL.yellow or PAL.muted) + if mod.targets then + Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW, + Kit.textHeight("button"), mod.targets, + mod.targetsHere == false and PAL.steel or PAL.blue) + end + ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) - -- version + status + update state - local statusText, statusCol = modStatusColor(mod.status) - local line = "v" .. tostring(mod.version or "?") .. " " .. statusText - Kit.text("small", line, px, ly, statusCol) - local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s) - if imp:_modInfoPending(mod.id) then - -- An inline spinner, because this row's release check is genuinely in - -- flight -- the list stays usable while it resolves. - Loader.dot(lx, ly, Kit.textHeight("small")) - Kit.text("small", Strings("Checking..."), - lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted) - elseif info and info.status == "available" then - Kit.text("small", Strings("v%s available", tostring(info.latest)), - lx, ly, PAL.yellow) - elseif info and info.status == "current" then - Kit.text("small", Strings("up to date"), lx, ly, PAL.muted) - elseif info and info.status == "error" then - Kit.text("small", Strings("check failed"), lx, ly, PAL.red) - end - ly = ly + Kit.textHeight("small") + math.floor(2 * m.s) + -- version + status + update state + local statusText, statusCol = modStatusColor(mod.status) + local line = "v" .. tostring(mod.version or "?") .. " " .. statusText + Kit.text("small", line, px, ly, statusCol) + local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s) + if imp:_modInfoPending(mod.id) then + -- An inline spinner, because this row's release check is genuinely in + -- flight -- the list stays usable while it resolves. + Loader.dot(lx, ly, Kit.textHeight("small")) + Kit.text("small", Strings("Checking..."), + lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted) + elseif info and info.status == "available" then + Kit.text("small", Strings("v%s available", tostring(info.latest)), + lx, ly, PAL.yellow) + elseif info and info.status == "current" then + Kit.text("small", Strings("up to date"), lx, ly, PAL.muted) + elseif info and info.status == "error" then + Kit.text("small", Strings("check failed"), lx, ly, PAL.red) + end + ly = ly + Kit.textHeight("small") + math.floor(2 * m.s) - -- one line of description, or the download stats when we have them - -- (download count in green so popularity reads at a glance) - if info and info.downloads then - local d = info.dates - local dl = ModUpdate.downloadsLine(info.downloads.total) - local dates = ModUpdate.datesLine(d and d.first, d and d.latest) - local segs = {} - if dl then segs[#segs + 1] = { dl, PAL.green } end - if dates then - 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) + -- one line of description, or the download stats when we have them + -- (download count in green so popularity reads at a glance) + if info and info.downloads then + local d = info.dates + local dl = ModUpdate.downloadsLine(info.downloads.total) + local dates = ModUpdate.datesLine(d and d.first, d and d.latest) + local segs = {} + if dl then segs[#segs + 1] = { dl, PAL.green } end + if dates then + 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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 5845f810..88ce014e 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -4154,7 +4154,6 @@ function RomImporter:_refreshMods() end self.mods = kept end - self:_syncModUpdateInfo(false) end -- 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 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 }. --- ASYNC (was synchronous). This runs on every _refreshMods -- boot, and any --- toggle or install -- and used to make one blocking curl call per mod with a --- github field, in a loop, on the render thread. A handful of mods was a --- 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. +-- Resolve GitHub status for every installed mod that declares a github field. +-- This is deliberately opt-in: only the explicit "Check for updates" action +-- calls it. Opening, scrolling, toggling, or relisting the MODS tab must not +-- create a burst of release work behind the list. force=true bypasses the 6h +-- cache on every repo. Results live on self.modUpdateInfo[id] = { +-- status, latest, best, releases } and resolve asynchronously across frames. function RomImporter:_syncModUpdateInfo(force) local ModUpdate = require("src.mods.ModUpdate") self.modUpdateInfo = self.modUpdateInfo or {} diff --git a/tests/engine/launcher_scroll_test.lua b/tests/engine/launcher_scroll_test.lua index 89b48242..a9440ced 100644 --- a/tests/engine/launcher_scroll_test.lua +++ b/tests/engine/launcher_scroll_test.lua @@ -388,6 +388,35 @@ mdown = false LauncherView.update(gameImp, 0.016) 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 f = assert(io.open(path, "r")) local src = f:read("*a")