This commit is contained in:
bryanthaboi
2026-08-18 11:29:50 -04:00
parent d5ad830fb8
commit bd1046f398
11 changed files with 595 additions and 36 deletions
+51 -23
View File
@@ -1626,13 +1626,17 @@ end
-- The persisted sort choice both mod panels share. The chooser itself is a
-- popup (buildSortModal); panels just read the current key and offer a
-- "Sort" button, which is what freed the chip row's two lines of space.
local function sortDefs()
return {
local function sortDefs(scope)
local defs = {
{ key = "name", label = Strings("Name") },
{ key = "popularity", label = Strings("Popularity") },
{ key = "release", label = Strings("Release date") },
{ key = "updated", label = Strings("Last updated") },
{ key = "popularity", label = Strings("Most downloaded") },
}
if scope == "find" then
defs[#defs + 1] = { key = "trending", label = Strings("Trending") }
end
defs[#defs + 1] = { key = "release", label = Strings("Release date") }
defs[#defs + 1] = { key = "updated", label = Strings("Last updated") }
return defs
end
-- Sorting is decorate-sort-undecorate: the key is computed once per entry
@@ -1684,7 +1688,7 @@ local function sortCacheOk(cache, src, key, rev, pending)
return pending and (Kit.time - (cache.at or 0)) < RESORT_DEBOUNCE
end
local function currentSort(imp)
local function currentSort(imp, scope)
local sortKey = imp.modSort
if sortKey == nil then
local ok, opts = pcall(require("src.core.SaveData").loadOptions)
@@ -1694,6 +1698,7 @@ local function currentSort(imp)
sortKey = sortKey or "popularity"
imp.modSort = sortKey
end
if sortKey == "trending" and scope ~= "find" then return "popularity" end
return sortKey
end
@@ -1760,7 +1765,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = true end })
action = function() imp._sortPopup = "mods" end })
elseif medReq <= w then
-- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
@@ -1771,7 +1776,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = true end })
action = function() imp._sortPopup = "mods" end })
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small",
action = function() imp._modHeaderActionsPopup = true end })
@@ -1787,7 +1792,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:chooseMod() end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = true end })
action = function() imp._sortPopup = "mods" end })
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small",
action = function() imp._modHeaderActionsPopup = true end })
@@ -1819,7 +1824,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
return
end
local sortKey = currentSort(imp)
local sortKey = currentSort(imp, "mods")
-- Immediate mode paints this panel every frame; re-sorting the whole list
-- per frame (with lowercased-string allocations in the comparator) fed the
@@ -2210,7 +2215,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(20 * m.s)
btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = true end })
action = function() imp._sortPopup = "find" end })
-- The Filter button carries its state: blue while a category is active,
-- so a filtered-down list never reads as "the index shrank".
local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s)
@@ -2230,7 +2235,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
return
end
local sortKey = currentSort(imp)
local sortKey = currentSort(imp, "find")
-- Same caching rule as the MODS tab: the comparator allocates, so only
-- re-sort when the inputs actually change.
@@ -2249,6 +2254,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
-- fetch for every entry in the index (see _findStatsCached).
local stats = imp:_findStatsCached(entry)
if sortKey == "popularity" then return stats and stats.total or -1 end
if sortKey == "trending" then return stats and stats.recent or -1 end
if sortKey == "release" then return stats and stats.first or "0000-00-00" end
return stats and stats.latest or "0000-00-00"
end,
@@ -2336,13 +2342,14 @@ local function buildFindPanel(imp, x, y, w, availH, m)
bx, ly, PAL.heading)
local by2 = ly + Kit.textHeight("button") + math.floor(4 * m.s)
-- meta and stats on one line, the download count first (and green)
-- because it is what the default Popularity sort is ordering by: a
-- because it is what the default Most-downloaded sort is ordering by: a
-- narrow window ellipsizes the tail, and the count must survive that.
local stats = imp:_findStats(entry)
local baseCol = note and PAL.green or PAL.detail
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
if note then lead = lead .. " - " .. note end
local dl = stats and ModUpdate.downloadsLine(stats.total) or nil
local dl = stats and ModUpdate.downloadsShort(stats.total) or nil
local hasCount = stats ~= nil and stats.total ~= nil
local dates = stats and ModUpdate.datesLine(stats.first, stats.latest)
or nil
local rest = {}
@@ -2352,11 +2359,13 @@ local function buildFindPanel(imp, x, y, w, availH, m)
end
if dates then
rest[#rest + 1] = dates
elseif not dl and (entry.summary or "") ~= "" then
elseif not hasCount and (entry.summary or "") ~= "" then
rest[#rest + 1] = entry.summary
end
local segs = { { lead, baseCol } }
if dl then segs[#segs + 1] = { " - " .. dl, PAL.green } end
if dl then
segs[#segs + 1] = { " - " .. dl, hasCount and PAL.green or PAL.faint }
end
if #rest > 0 then
segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol }
end
@@ -2944,7 +2953,7 @@ local function buildModHeaderActionsModal(imp, m)
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
{ label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end },
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end },
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = true end },
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end },
}
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #btns * (m.btnH + gap) + m.btnH + pad
@@ -2972,7 +2981,8 @@ end
-- Sort chooser, shared by the MODS and FIND MODS tabs (they share the
-- persisted key, so one popup serves both).
local function buildSortModal(imp, m)
local defs = sortDefs()
local scope = imp._sortPopup
local defs = sortDefs(scope)
local pad = math.floor(18 * m.s)
local w = math.floor(360 * m.s)
local gap = math.floor(8 * m.s)
@@ -2982,7 +2992,7 @@ local function buildSortModal(imp, m)
local cy = py + pad
Kit.text("button", Strings("Sort by"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
local cur = currentSort(imp)
local cur = currentSort(imp, scope)
for _, s in ipairs(defs) do
local key = s.key
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-" .. key, s.label, {
@@ -3362,25 +3372,43 @@ local function buildFindEntryModal(imp, m)
local gap = math.floor(8 * m.s)
local nBtns = 3 -- install row, details/source row, close row
local noteH = note and (Kit.textHeight("small") + math.floor(4 * m.s)) or 0
local stats = imp:_findStats(entry)
local trend = {}
local trendLine = stats and ModUpdate.trendingLine(stats.recent, stats.windowDays)
if trendLine then trend[#trend + 1] = trendLine end
if stats and stats.asOf then
trend[#trend + 1] = Strings("counts approximate, as of %s",
tostring(stats.asOf):match("^%d%d%d%d%-%d%d%-%d%d") or stats.asOf)
end
trend = (#trend > 0) and table.concat(trend, " - ") or nil
local trendH = trend and (Kit.textHeight("small") + math.floor(2 * m.s)) or 0
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + noteH + math.floor(12 * m.s)
+ Kit.textHeight("small") + trendH + noteH + math.floor(12 * m.s)
+ nBtns * (m.btnH + gap) - gap + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Kit.ellipsize("button", entry.title or entry.id,
pw - 2 * pad), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
local stats = imp:_findStats(entry)
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
if entry.author then lead = lead .. " - " .. entry.author end
if entry.categories and entry.categories[1] then
lead = lead .. " - " .. entry.categories[1]
end
local dl = stats and ModUpdate.downloadsLine(stats.total) or nil
local dl = stats and ModUpdate.downloadsShort(stats.total) or nil
local hasCount = stats ~= nil and stats.total ~= nil
local segs = { { lead, PAL.detail } }
if dl then segs[#segs + 1] = { " - " .. dl, PAL.green } end
if dl then
segs[#segs + 1] = { " - " .. dl, hasCount and PAL.green or PAL.faint }
end
segLine("small", segs, px + pad, cy, pw - 2 * pad)
cy = cy + Kit.textHeight("small")
if trend then
cy = cy + math.floor(2 * m.s)
Kit.text("small", Kit.ellipsize("small", trend, pw - 2 * pad),
px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small")
end
if note then
cy = cy + math.floor(4 * m.s)
Kit.text("small", note, px + pad, cy, PAL.green)
+8 -3
View File
@@ -4449,9 +4449,14 @@ function RomImporter:_findStatsCached(entry)
end
return nil -- retry window open; _requestFindStats decides what to do
end
if entry.downloads ~= nil or entry.first_release or entry.last_release then
cached = { total = entry.downloads, first = entry.first_release,
latest = entry.last_release, done = true }
local ModIndex = require("src.mods.ModIndex")
local dl = ModIndex.downloadStats(entry)
local dates = ModIndex.releaseDates(entry)
if dl or dates then
cached = { total = dl and dl.total, recent = dl and dl.recent,
windowDays = dl and dl.window_days, asOf = dl and dl.as_of,
first = dates and dates.first, latest = dates and dates.latest,
done = true }
self._findStatsCache[entry.id] = cached
return cached
end
+42 -2
View File
@@ -26,6 +26,7 @@ local ModIndex = {}
-- single repo's releases; a whole index is heavier and changes more slowly.
ModIndex.CACHE_TTL = 24 * 60 * 60
ModIndex.SCHEMA_VERSION = 1
ModIndex.CACHE_VERSION = 2
-- ------- pure: source resolution
@@ -149,6 +150,43 @@ local function parseLatest(raw)
}
end
local function parseDownloads(raw)
if type(raw) == "number" or type(raw) == "string" then
local n = tonumber(raw)
return n and { total = n } or nil
end
if type(raw) ~= "table" then return nil end
local out = {
total = tonumber(raw.total),
recent = tonumber(raw.recent),
window_days = tonumber(raw.window_days),
as_of = str(raw.as_of),
}
if out.total == nil and out.recent == nil then return nil end
return out
end
-- downloadStats(entry) -> { total, recent, window_days, as_of } | nil
function ModIndex.downloadStats(entry)
if type(entry) ~= "table" then return nil end
return parseDownloads(entry.downloads)
end
local function isoDay(v)
if type(v) ~= "string" then return nil end
return v:match("^(%d%d%d%d%-%d%d%-%d%d)")
end
-- releaseDates(entry) -> { first, latest } | nil
function ModIndex.releaseDates(entry)
if type(entry) ~= "table" then return nil end
local first = isoDay(entry.first_release)
local latest = isoDay(entry.last_release)
or isoDay(entry.latest and entry.latest.published_at)
if not (first or latest) then return nil end
return { first = first, latest = latest }
end
local function parseEntry(raw)
if type(raw) ~= "table" or not str(raw.id) then return nil end
return {
@@ -175,11 +213,11 @@ local function parseEntry(raw)
conflicts = raw.conflicts,
thumbnail = str(raw.thumbnail),
description_url = str(raw.description_url),
-- Optional release stats a feed author can publish: total downloads
-- Optional release stats a feed author can publish: download counts
-- across all releases plus first/last release dates. Additive-only,
-- so a feed that carries them stays readable by every build that
-- predates them (and one that does not still renders fine here).
downloads = tonumber(raw.downloads),
downloads = parseDownloads(raw.downloads),
first_release = str(raw.first_release),
last_release = str(raw.last_release),
latest = parseLatest(raw.latest),
@@ -516,6 +554,7 @@ function ModIndex.cacheFresh(entry, now, ttl)
now = now or os.time()
ttl = ttl or ModIndex.CACHE_TTL
return entry ~= nil and type(entry.checkedAt) == "number"
and entry.version == ModIndex.CACHE_VERSION
and (now - entry.checkedAt) < ttl
end
@@ -527,6 +566,7 @@ function ModIndex.writeCache(feed, index)
opts.modIndexCache = opts.modIndexCache or {}
opts.modIndexCache[feed] = {
checkedAt = os.time(),
version = ModIndex.CACHE_VERSION,
generatedAt = index.generatedAt,
categories = index.categories,
mods = index.mods,
+34 -1
View File
@@ -265,9 +265,42 @@ function ModUpdate.downloadsLine(total)
ModUpdate.formatCount(total))
end
ModUpdate.NO_COUNT = "?"
function ModUpdate.abbrevCount(n)
n = tonumber(n)
if not n or n ~= n or n < 0 then return ModUpdate.NO_COUNT end
n = math.floor(n)
if n < 1000 then return tostring(n) end
local units, idx, value = { "k", "M", "B" }, 0, n
repeat
value = value / 1000
idx = idx + 1
until idx >= #units or math.floor(value * 10 + 0.5) / 10 < 1000
local scaled = math.floor(value * 10 + 0.5) / 10
local shown = (scaled % 1 == 0) and tostring(math.floor(scaled))
or string.format("%.1f", scaled)
return shown .. units[idx]
end
function ModUpdate.downloadsShort(total)
return Strings("%s downloads", ModUpdate.abbrevCount(total))
end
function ModUpdate.trendingLine(recent, windowDays)
if recent == nil then return nil end
if not windowDays then
return Strings("%s recently", ModUpdate.abbrevCount(recent))
end
return Strings("%s in the last %d days", ModUpdate.abbrevCount(recent),
math.floor(windowDays))
end
function ModUpdate.datesLine(first, latest)
if not (first or latest) then return nil end
return Strings("Released %s - Updated %s", first or "?", latest or "?")
if not first then return Strings("Updated %s", latest) end
if not latest then return Strings("Released %s", first) end
return Strings("Released %s - Updated %s", first, latest)
end
function ModUpdate.statsLine(total, first, latest)