skin studio updates, save sync CLOSES #1533

This commit is contained in:
bryanthaboi
2026-08-19 05:57:44 -04:00
parent fd73ab2a11
commit 93374fbbbb
54 changed files with 9762 additions and 271 deletions
+632 -40
View File
@@ -48,6 +48,14 @@ local TAP_SLOP2 = 16 * 16
-- Installed mods should not turn into a one- or two-item pager on a compact
-- display. Keep a useful page size, then let the list viewport scroll.
local MIN_MODS_PER_PAGE = 10
local MIN_SKIN_ROWS = 4
local SKIN_FORMAT_LABEL = {
native = "GEN1",
retroarch = "RETROARCH",
delta = "DELTA",
}
local MIN_FIND_ROWS = 3
local PANEL_OVERSCAN = 0.75
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
@@ -56,6 +64,32 @@ local function inRect(rect, x, y)
and y >= rect.y and y <= rect.y + rect.h
end
local function tabKeyOf(imp) return imp.tab or "red" end
local function tabScrollMax(imp)
local t = imp._tabScrollMax
return (t and t[tabKeyOf(imp)]) or 0
end
local function tabScrollAt(imp)
local t = imp._tabScroll
return clamp((t and t[tabKeyOf(imp)]) or 0, 0, tabScrollMax(imp))
end
local function setTabScroll(imp, value)
imp._tabScroll = imp._tabScroll or {}
imp._tabScroll[tabKeyOf(imp)] = clamp(value, 0, tabScrollMax(imp))
end
local function modListWantsWheel(imp, wheel)
if imp.tab ~= "mods" or (imp._modScrollMax or 0) <= 0 then return false end
if not inRect(imp._modListRect, Kit.mouseX, Kit.mouseY) then return false end
if not inRect(imp._tabRegionRect, Kit.mouseX, Kit.mouseY) then return false end
local at = clamp(imp.modScroll or 0, 0, imp._modScrollMax)
if wheel < 0 then return at < imp._modScrollMax end
return at > 0
end
-- ------------------------------------------------------------- lifecycle
local function ensureState(imp)
@@ -65,6 +99,9 @@ local function ensureState(imp)
imp._actAt = imp._actAt or {}
imp._uiActions = imp._uiActions or {}
imp._pages = imp._pages or {}
imp._tabScroll = imp._tabScroll or {}
imp._tabScrollMax = imp._tabScrollMax or {}
imp._tabContentH = imp._tabContentH or {}
-- Held backspace/arrows must repeat in the text fields; restored on
-- detach because the game's Input does its own per-step edge detection
-- and never expects repeated keypressed events.
@@ -155,7 +192,10 @@ function LauncherView.touchpressed(imp, id, x, y)
imp._touchAt = imp._touchAt or {}
imp._touchAt[tostring(id)] = {
x = x, y = y,
modsList = (imp._modScrollMax or 0) > 0 and inRect(imp._modListRect, x, y),
modsList = imp.tab == "mods" and (imp._modScrollMax or 0) > 0
and inRect(imp._modListRect, x, y)
and inRect(imp._tabRegionRect, x, y),
region = tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y),
}
end
@@ -170,13 +210,25 @@ function LauncherView.touchmoved(imp, id, x, y)
-- A drag that began in the installed-mod viewport scrolls that page's
-- rows. Its pager remains available for moving to the next ten-plus
-- entries; a drag elsewhere keeps the normal short-window page scroll.
if start.dragged and start.modsList then
if start.dragged then
local last = start.lastY or start.y
imp.modScroll = clamp((imp.modScroll or 0) - (y - last), 0,
imp._modScrollMax or 0)
elseif start.dragged and (imp._pageScrollMax or 0) > 0 then
local last = start.lastY or start.y
imp._pageScroll = (imp._pageScroll or 0) - (y - last)
local move = -(y - last)
if start.modsList then
local listMax = imp._modScrollMax or 0
local at, leftover = Kit.scrollHandoff(
clamp(imp.modScroll or 0, 0, listMax), listMax, move)
imp.modScroll = at
move = leftover
end
if move ~= 0 and start.region then
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
tabScrollMax(imp), move)
setTabScroll(imp, at)
move = leftover
end
if move ~= 0 and (imp._pageScrollMax or 0) > 0 then
imp._pageScroll = (imp._pageScroll or 0) + move
end
end
start.lastY = y
end
@@ -796,6 +848,34 @@ local function drawSkinGlyph(x, y, w, h, hot)
Theme.fillRounded(bx + pad + ow * 0.80, by + pad + oh * 0.50, r * 2, r * 2, ink, a, r)
end
local function drawSyncGlyph(x, y, w, h, hot)
local box = math.min(w, h)
local bx = x + (w - box) / 2
local by = y + (h - box) / 2
local pad = box * 0.24
local ink = hot and PAL.inverse or PAL.ink
local left, right = bx + pad, bx + box - pad
local head = box * 0.15
local bar = math.max(1, box * 0.09)
local topY, botY = by + box * 0.34, by + box * 0.58
Theme.fill(left, topY, math.max(0, right - left - head * 0.5), bar, ink, 1)
Theme.fill(left + head * 0.5, botY, math.max(0, right - left - head * 0.5),
bar, ink, 1)
if love.graphics.line then
love.graphics.push("all")
Theme.col(ink, 1)
if love.graphics.setLineWidth then
love.graphics.setLineWidth(math.max(1.5, bar))
end
local ty, byy = topY + bar / 2, botY + bar / 2
love.graphics.line(right - head, ty - head, right, ty, right - head,
ty + head)
love.graphics.line(left + head, byy - head, left, byy, left + head,
byy + head)
love.graphics.pop()
end
end
local function drawCross(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
@@ -885,6 +965,8 @@ local function headerChrome(imp)
hot and QUIT_INK_HOT or QUIT_INK_REST)
end },
tab = {},
sync = { face = "tab", drawFn = drawSyncGlyph,
action = function() imp:_openSync() end },
game = { face = "tab", font = "tab",
action = function()
local g = currentGame(imp)
@@ -1050,6 +1132,27 @@ local function buildHeader(imp, m)
tx = tx + w + tabGap
end
do
local w = tabH
if tx > tabLeft and tx + w > tabRight then
tx = tabLeft
ty = ty + tabH + tabRowGap
end
local o = chrome.sync
o.active = imp._syncModal ~= nil
btn(imp, tx, ty, w, tabH, "tab-sync", "", o)
local bh = math.floor(11 * m.s)
local bw = math.min(w, Kit.textWidth("micro", "BETA") + math.floor(10 * m.s))
Kit.tag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh,
"BETA", o.active and PAL.inverse or PAL.yellow)
local eng = imp._sync
if eng and eng.busy and eng:busy() then
Kit.spinner(tx + w - math.floor(8 * m.s), ty + math.floor(8 * m.s),
math.max(2, math.floor(4 * m.s)))
end
tx = tx + w + tabGap
end
-- `ty` has walked down with the wraps, so this stays correct at one row too.
y = ty + tabH + math.floor(8 * m.s)
Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline)
@@ -1481,7 +1584,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
return h
end
local function buildGamePanel(imp, x, y, w, availH, m, version)
local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH)
imp.panelVersion = version
local info = GameVersion.info(version)
local locked = info == nil
@@ -1527,12 +1630,15 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
local afterTitle = math.floor((ready and 22 or 12) * m.s)
local cy = y + titleH + afterTitle
local remaining = availH - (titleH + afterTitle)
local budgetLeft = math.max(remaining,
(budgetH or availH) - (titleH + afterTitle))
local gap = m.gap
local lx, lw, rx2, rw
if m.twoCol then
lx, lw = x, m.colW
rx2, rw = x + m.colW + m.colGap, m.colW
local colW = math.floor((w - m.colGap) / 2)
lx, lw = x, colW
rx2, rw = x + colW + m.colGap, colW
else
lx, lw, rx2, rw = x, w, x, w
end
@@ -1579,15 +1685,19 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
-- Save slots. Two columns put them beside the left stack; ONE column
-- stacks them underneath. Either way the card is clipped to the room it
-- actually has, and sizes its own list to that budget.
local bottom = ly
if not locked then
local slotY = m.twoCol and cy or ly
local slotAvail = m.twoCol and remaining or (cy + remaining - ly)
local slotAvail = m.twoCol and budgetLeft or (cy + budgetLeft - ly)
if slotAvail > 80 * m.s then
Kit.pushClip(rx2, slotY, rw, math.max(0, slotAvail))
buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version, ready)
local slotH = buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version,
ready)
Kit.popClip()
bottom = math.max(bottom, slotY + math.min(slotH or 0, slotAvail))
end
end
return bottom - y
end
-- --------------------------------------------------------------- mods panel
@@ -1821,7 +1931,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
if #mods == 0 then
imp.modScroll, imp._modScrollMax, imp._modListRect = 0, 0, nil
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
return
return (cy - y) + math.floor(110 * m.s)
end
local sortKey = currentSort(imp, "mods")
@@ -1882,7 +1992,8 @@ local function buildModsPanel(imp, x, y, w, availH, m)
if not lr then lr = {}; imp._modListRect = lr end
lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH
imp._modScrollMax = scrollMax
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(x, listTop, w, listH) then
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks
and Kit.hit(x, listTop, w, listH) then
scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
Kit.wheelY = 0
elseif scrollMax == 0 then
@@ -2008,9 +2119,10 @@ local function buildModsPanel(imp, x, y, w, availH, m)
Kit.popClip()
local pagerY = listTop + listH + gap
local newPage = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods")
local newPage, newPagerH = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods")
if newPage ~= cur then imp.modScroll = 0 end
setPage(imp, "mods", newPage)
return pagerY + newPagerH - y
end
-- ---------------------------------------------------------- find mods panel
@@ -2044,6 +2156,32 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s)
end
local urlH = m.btnH
local addLabel = Strings("Add")
local addW = Kit.textWidth("small", addLabel) + math.floor(24 * m.s)
local pasteLabel = Strings("Paste")
local pasteW = Kit.textWidth("small", pasteLabel) + math.floor(20 * m.s)
if imp._skinFetch then
Loader.inline(x, cy, w, urlH,
Strings("Downloading %s...", tostring(imp._skinFetch.name or "")))
else
local urlPlace = Layout.rightCluster(x, w, math.floor(6 * m.s))
btn(imp, urlPlace(addW), cy, addW, urlH, "skins-url-add", addLabel, {
kind = "accent", font = "small",
action = function() imp:_addSkinFromUrl() end })
if w - addW - pasteW > math.floor(140 * m.s) then
btn(imp, urlPlace(pasteW), cy, pasteW, urlH, "skins-url-paste",
pasteLabel, {
font = "small", action = function() imp:_pasteSkinUrl() end })
end
local fieldW = math.max(0, urlPlace(0) - x - math.floor(6 * m.s))
textField(imp, x, cy, fieldW, urlH, "skins-url", imp.skinUrl or "",
Strings("Paste a skin link (.zip, .cfg, .deltaskin)"),
imp._skinUrlFocus == true,
function() imp:_toggleSkinUrlFocus() end)
end
cy = cy + urlH + math.floor(8 * m.s)
-- Studio button. Desktop only: the host supplies the hook nowhere else.
if imp.onOpenSkinStudio then
local label = Strings("Open Skin Studio")
@@ -2071,7 +2209,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
-- The row itself is "use this skin"; the gear beside it configures that
-- entry -- the built-in pad opens the drag-a-button layout editor, a skin
-- opens the studio, so neither lands on a screen that cannot edit it.
local function skinRow(key, id, title, detail, selected, configure)
local function skinRow(key, id, title, detail, selected, configure, format)
local gearW = configure and rowH or 0
local rowW = w - (gearW > 0 and (gearW + math.floor(6 * m.s)) or 0)
local ink = rowHit(imp, x, cy, rowW, rowH, selected, key,
@@ -2079,7 +2217,17 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
local tagW = selected
and (Kit.textWidth("small", Strings("IN USE")) + math.floor(20 * m.s))
or math.floor(12 * m.s)
local textW = rowW - math.floor(24 * m.s) - tagW
local badge = format and SKIN_FORMAT_LABEL[format] or nil
local badgeW = 0
if badge then
badgeW = Kit.textWidth("micro", badge) + math.floor(16 * m.s)
local badgeH = math.floor(16 * m.s)
Kit.tag(x + rowW - tagW - badgeW - math.floor(12 * m.s),
cy + (rowH - badgeH) / 2, badgeW, badgeH, badge,
format == "native" and PAL.green or PAL.blue)
badgeW = badgeW + math.floor(10 * m.s)
end
local textW = math.max(0, rowW - math.floor(24 * m.s) - tagW - badgeW)
local tx = x + math.floor(12 * m.s)
local ty = cy + math.floor(7 * m.s)
Kit.text("mono", Kit.ellipsize("mono", title, textW), tx, ty,
@@ -2102,7 +2250,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
local TouchSkin = require("src.core.TouchSkin")
local hint = Strings(
"You can also drop a skin .zip on this window, or put a folder in %s/ of your save directory. RetroArch overlay .cfg files work as-is.",
"You can also drop a skin .zip or .deltaskin on this window, or put a folder in %s/ of your save directory. RetroArch overlay .cfg files and Delta skins work as-is.",
TouchSkin.USER_ROOT)
local hintH = Kit.wrapHeight("small", hint, w, 3)
local importH = math.floor(10 * m.s) + hintH
@@ -2111,9 +2259,10 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listTop = cy
local listH = availH - (cy - y) - importH
local perPage = Kit.rowsThatFit(listH, rowH, rowGap, 1, 20)
local perPage = Kit.rowsThatFit(listH, rowH, rowGap, MIN_SKIN_ROWS, 20)
if #entries > perPage then
perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap, 1, 20)
perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap,
MIN_SKIN_ROWS, 20)
end
local first, last, cur, pages = Kit.pageBounds(page(imp, "skins"),
#entries, perPage)
@@ -2142,11 +2291,10 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
bits[#bits + 1] = entry.pages .. " " .. Strings("pages")
end
if entry.screen then bits[#bits + 1] = Strings("screen cutout") end
local configure = imp.onOpenSkinStudio and function()
imp.onOpenSkinStudio(imp.modScope or "red", entry.id)
end or nil
local configure = function() imp._skinActions = { id = entry.id } end
skinRow("skin-" .. entry.id, entry.id, entry.id,
table.concat(bits, " \194\183 "), active == entry.id, configure)
table.concat(bits, " \194\183 "), active == entry.id, configure,
entry.format)
end
end
@@ -2164,6 +2312,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
cy = cy + math.floor(10 * m.s)
Kit.textWrapped("small", hint, x, cy, w, PAL.muted, 3)
return cy + hintH - y
end
local function buildFindPanel(imp, x, y, w, availH, m)
@@ -2201,7 +2350,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
aw, m.btnH, "find-add", Strings("Add an index"), {
kind = "accent", font = "small",
action = function() imp._indexManage = true end })
return
return (cy - y) + h
end
-- One row: the search field, then Filter / Sort / Indexes popup buttons.
@@ -2232,7 +2381,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
Kit.emptyBox(x, cy, w, math.floor(110 * m.s),
(total == 0) and Strings("This index lists no mods yet.")
or Strings("No mods match that search."))
return
return (cy - y) + math.floor(110 * m.s)
end
local sortKey = currentSort(imp, "find")
@@ -2283,7 +2432,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
+ math.floor(8 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listH = availH - (cy - y) - pagerH - gap
local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 20)
local perPage = Kit.rowsThatFit(listH, rowH, gap, MIN_FIND_ROWS, 20)
local first, last, cur, pages = Kit.pageBounds(page(imp, "find"), #rows, perPage)
setPage(imp, "find", cur)
local listTop = cy
@@ -2384,7 +2533,10 @@ local function buildFindPanel(imp, x, y, w, availH, m)
end
local pagerY = listTop + (last - first + 1) * (rowH + gap)
setPage(imp, "find", Kit.pager(x, pagerY, w, cur, #rows, perPage, "find"))
local findPage, findPagerH = Kit.pager(x, pagerY, w, cur, #rows, perPage,
"find")
setPage(imp, "find", findPage)
local bottom = pagerY + findPagerH
-- Aggregate progress. Enrichment happens a page at a time and each row says
-- so for itself, but with nothing summarising it the panel looked idle while
@@ -2398,7 +2550,9 @@ local function buildFindPanel(imp, x, y, w, availH, m)
Kit.text("micro", Strings("Checking %d of %d on this page...",
waiting, last - first + 1),
x + dh + math.floor(6 * m.s), py, PAL.muted)
bottom = math.max(bottom, py + dh)
end
return bottom - y
end
-- ------------------------------------------------------------------ footer
@@ -3161,6 +3315,75 @@ end
-- Per-mod actions for the MODS tab: the row itself only carries the enable
-- toggle, everything episodic (update check, versions, delete) lives here.
local SKIN_EXPORTS = {
{ id = "native", key = "skinact-exp-native", label = "Export as gen1recomp .zip" },
{ id = "retroarch", key = "skinact-exp-ra", label = "Export as RetroArch .zip" },
{ id = "delta", key = "skinact-exp-delta", label = "Export as Delta .deltaskin" },
}
local function buildSkinActionsModal(imp, m)
local id = imp._skinActions and imp._skinActions.id
if not id then imp._skinActions = nil return end
local entry
for _, e in ipairs(imp:_ensureSkins()) do
if e.id == id then entry = e break end
end
if not entry then imp._skinActions = nil return end
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local rows = #SKIN_EXPORTS + 2 + (imp.onOpenSkinStudio and 1 or 0)
+ (imp._skinExport and imp._skinExport.dir and 1 or 0)
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(12 * m.s)
+ rows * (m.btnH + gap) - gap + pad
local px, py, pw = modalPanel(m, math.floor(440 * m.s), h)
local cy = py + pad
Kit.text("button", Kit.ellipsize("button", entry.id, pw - 2 * pad),
px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
local fmt = SKIN_FORMAT_LABEL[entry.format or ""] or Strings("unknown format")
Kit.text("small", Kit.ellipsize("small",
fmt .. " \194\183 " .. entry.pages .. " " .. Strings("pages")
.. " \194\183 " .. entry.controls .. " " .. Strings("buttons"),
pw - 2 * pad), px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(12 * m.s)
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-use",
Strings("Use this skin"), { kind = "primary", font = "small",
action = function()
imp:_useSkin(id)
imp._skinActions = nil
end })
cy = cy + m.btnH + gap
if imp.onOpenSkinStudio then
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-edit",
Strings("Open in Skin Studio"), { kind = "accent", font = "small",
action = function()
imp._skinActions = nil
imp.onOpenSkinStudio(imp.modScope or "red", id)
end })
cy = cy + m.btnH + gap
end
for _, spec in ipairs(SKIN_EXPORTS) do
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, spec.key, Strings(spec.label), {
font = "small",
action = function()
imp:_exportSkin(id, spec.id)
imp._skinActions = nil
end })
cy = cy + m.btnH + gap
end
if imp._skinExport and imp._skinExport.dir then
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-reveal",
Strings("Show the exported file"), { font = "small",
action = function() imp:_revealSkinExport() end })
cy = cy + m.btnH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-close",
Strings("Close"), { font = "small",
action = function() imp._skinActions = nil end })
end
local function buildModActionsModal(imp, m)
local mod
for _, mm in ipairs(imp.mods or {}) do
@@ -3907,6 +4130,335 @@ local function buildDepResolverModal(imp, m)
end
end
local SYNC_HINT = "Save sync keeps your saves and your mod list on our server so another device can pick them up. It is brand new, so keep your own backups too."
local function syncTitle(imp, m, px, py, pw, pad)
local label = Strings("SAVE SYNC")
Kit.text("button", label, px + pad, py, PAL.heading)
local bh = math.floor(15 * m.s)
local bw = Kit.textWidth("micro", "BETA") + math.floor(14 * m.s)
Kit.tag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s),
py + (Kit.textHeight("button") - bh) / 2, bw, bh, "BETA", PAL.yellow)
return py + Kit.textHeight("button") + math.floor(12 * m.s)
end
local function syncStatus(imp, m, x, y, w, eng)
if eng:busy() then
Loader.inline(x, y, w, m.btnH, eng.status)
return m.btnH + math.floor(8 * m.s)
end
Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y,
eng.phase == "error" and PAL.red or PAL.muted)
return Kit.textHeight("small") + math.floor(10 * m.s)
end
local function syncRow(imp, m, x, y, w, key, label, opts)
opts = opts or {}
opts.font = "small"
btn(imp, x, y, w, m.btnH, key, label, opts)
return y + m.btnH + math.floor(8 * m.s)
end
function LauncherView.syncSideText(meta)
meta = type(meta) == "table" and meta or {}
local summary = type(meta.summary) == "table" and meta.summary or {}
local bits = {}
if type(summary.name) == "string" and summary.name ~= "" then
bits[#bits + 1] = summary.name
end
if tonumber(summary.badges) then
bits[#bits + 1] = tostring(math.floor(summary.badges)) .. " "
.. Strings("badges")
end
if type(summary.timeText) == "string" and summary.timeText ~= "" then
bits[#bits + 1] = summary.timeText
end
if tonumber(summary.dexCount) then
bits[#bits + 1] = tostring(math.floor(summary.dexCount)) .. " "
.. Strings("seen")
end
local when = tonumber(meta.savedAt)
if when then
bits[#bits + 1] = Strings("saved") .. " " .. os.date("%Y-%m-%d %H:%M", when)
end
if #bits == 0 then return Strings("no details") end
return table.concat(bits, " \194\183 ")
end
local function buildSyncConflict(imp, m, eng)
local row = eng.conflicts[1]
local pad = math.floor(18 * m.s)
local w = math.floor(520 * m.s)
local innerW = w - 2 * pad
local lead = row.overlap
and Strings("These saves were played at the same time.")
or Strings("This save also changed on another device.")
local leadH = Kit.wrapHeight("small", lead, innerW, 2)
local sideH = Kit.textHeight("small") + math.floor(2 * m.s)
+ Kit.wrapHeight("micro", "x", innerW, 2)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + leadH
+ math.floor(10 * m.s) + 2 * (sideH + math.floor(10 * m.s))
+ 4 * (m.btnH + math.floor(8 * m.s)) + pad
local px, py, pw = modalPanel(m, w, h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, pw - 2 * pad,
PAL.detail, 2) + math.floor(10 * m.s)
local function side(title, meta)
Kit.text("small", title, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("small") + math.floor(2 * m.s)
cy = cy + Kit.textWrapped("micro", LauncherView.syncSideText(meta),
px + pad, cy, pw - 2 * pad, PAL.muted, 2) + math.floor(10 * m.s)
end
side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"),
row.localMeta)
side(Strings("Other device"), row.remoteMeta)
local key = row.key
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-this",
Strings("Keep this device"), { kind = "primary",
action = function() imp:_syncResolve(key, "local") end })
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-other",
Strings("Keep the other device"), { kind = "accent",
action = function() imp:_syncResolve(key, "remote") end })
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-both",
Strings("Keep both"), {
action = function() imp:_syncResolve(key, "both") end })
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-conflict-close",
Strings("Close"), { action = function() imp:_closeSync() end })
end
local function buildSyncLink(imp, m, eng)
local mo = imp._syncModal
local pad = math.floor(18 * m.s)
local w = math.floor(460 * m.s)
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local hint = Strings("Enter the two codes the other device is showing.")
local hintH = Kit.wrapHeight("small", hint, w - 2 * pad, 2)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH
+ math.floor(10 * m.s) + 2 * (fieldH + math.floor(8 * m.s))
+ Kit.textHeight("small") + math.floor(10 * m.s)
+ 2 * (m.btnH + math.floor(8 * m.s)) + pad
local px, py, pw = modalPanel(m, w, h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, pw - 2 * pad,
PAL.detail, 2) + math.floor(10 * m.s)
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code1",
mo.code1 or "", Strings("First code"), imp._syncFocus == "code1",
function() imp:_syncFocusField("code1") end)
cy = cy + fieldH + math.floor(8 * m.s)
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code2",
mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2",
function() imp:_syncFocusField("code2") end)
cy = cy + fieldH + math.floor(8 * m.s)
cy = cy + syncStatus(imp, m, px + pad, cy, pw - 2 * pad, eng)
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-go",
Strings("Link this device"), { kind = "primary", enabled = not eng:busy(),
action = function() imp:_syncLink() end })
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-back",
Strings("Back"), { action = function() imp:_syncView("home") end })
end
local function buildSyncMods(imp, m, eng)
local mo = imp._syncModal
local pad = math.floor(18 * m.s)
local w = math.floor(500 * m.s)
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local plan = eng.modPlan
local rows = 4 + (plan and 1 or 0)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ 3 * (Kit.textHeight("small") + math.floor(8 * m.s))
+ fieldH + math.floor(8 * m.s)
+ rows * (m.btnH + math.floor(8 * m.s)) + pad
local px, py, pw = modalPanel(m, w, h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
local innerW = pw - 2 * pad
if eng.shareCode then
Kit.text("small", Strings("Share this code:"), px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(4 * m.s)
Kit.text("stat", eng.shareCode, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("stat") + math.floor(4 * m.s)
Kit.text("micro", Kit.ellipsize("micro",
Strings("Enter this code in Save Sync > Get mod list"), innerW),
px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s)
end
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-mods",
Strings("Share mod list"), { kind = "accent", enabled = not eng:busy(),
action = function() imp:_syncShareMods() end })
textField(imp, px + pad, cy, innerW, fieldH, "sync-share-code",
mo.share or "", Strings("Paste a 6-character mod code"),
imp._syncFocus == "share", function() imp:_syncFocusField("share") end)
cy = cy + fieldH + math.floor(8 * m.s)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-get-mods",
Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(),
action = function() imp:_syncGetShare() end })
if plan then
local line = Strings("%d mods, %d indexes to add",
#(plan.toInstall or {}) + #(plan.toEnable or {}), #(plan.indexes or {}))
if #(plan.missing or {}) > 0 then
line = line .. " \194\183 " .. Strings("%d not in your indexes",
#plan.missing)
end
Kit.text("small", Kit.ellipsize("small", line, innerW), px + pad, cy,
PAL.detail)
cy = cy + Kit.textHeight("small") + math.floor(8 * m.s)
local prog = mo.progress
if prog then
Loader.inline(px + pad, cy, innerW, m.btnH,
Strings("%d of %d", prog.done or 0, prog.total or 0))
cy = cy + m.btnH + math.floor(8 * m.s)
else
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-apply-mods",
Strings("Apply these mods"), { kind = "primary",
enabled = not eng:busy(),
action = function() imp:_syncApplyMods() end })
end
end
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng)
syncRow(imp, m, px + pad, cy, innerW, "sync-mods-back", Strings("Back"),
{ action = function() imp:_syncView("home") end })
end
function LauncherView.syncDeviceRows(eng, limit)
local out = {}
if not eng or type(eng.devices) ~= "table" then return out end
for _, row in ipairs(eng.devices) do
if #out >= (limit or 6) then break end
if type(row) == "table" and type(row.id) == "string" then
out[#out + 1] = {
id = row.id,
current = row.current == true,
label = type(row.label) == "string" and row.label ~= "" and row.label
or "device",
}
end
end
return out
end
local function buildSyncHome(imp, m, eng)
local pad = math.floor(18 * m.s)
local w = math.floor(460 * m.s)
local linked = eng:linked()
local codes = eng.codes
local body = linked
and Strings("This device is linked. Saves and the mod list sync when the launcher opens and a few seconds after each save.")
or Strings(SYNC_HINT)
local innerW = w - 2 * pad
local hintH = Kit.wrapHeight("small", body, innerW, 5)
local codesH = codes
and (Kit.textHeight("small") + math.floor(6 * m.s)
+ 2 * (Kit.textHeight("title") + math.floor(4 * m.s))
+ math.floor(8 * m.s)) or 0
local devices = linked and LauncherView.syncDeviceRows(eng) or {}
local devicesH = #devices > 0
and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0
local rows = (linked and 5 or 3) + #devices
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH
+ math.floor(10 * m.s) + codesH + devicesH + m.btnH + math.floor(10 * m.s)
+ rows * (m.btnH + math.floor(8 * m.s)) + pad
local px, py, pw = modalPanel(m, w, h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail, 5)
+ math.floor(10 * m.s)
if codes then
Kit.text("small", Strings("Enter these on your other device:"), px + pad,
cy, PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
Kit.text("title", codes.code1, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("title") + math.floor(4 * m.s)
Kit.text("title", codes.code2, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("title") + math.floor(8 * m.s)
end
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng)
if #devices > 0 then
Kit.text("small", Strings("Devices on this account:"), px + pad, cy,
PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
for i, device in ipairs(devices) do
local id = device.id
if device.current then
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
device.label .. " \194\183 " .. Strings("this device"),
{ enabled = false })
else
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
Strings("Unlink %s", device.label), { kind = "danger",
enabled = not eng:busy(),
action = function() imp:_syncUnlinkDevice(id) end })
end
end
end
if linked then
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"),
{ kind = "primary", enabled = not eng:busy(),
action = function() imp:_syncNow() end })
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-mods",
Strings("Share or get a mod list"), { kind = "accent",
action = function() imp:_syncView("mods") end })
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink",
Strings("Unlink this device"), { kind = "danger",
action = function() imp:_syncUnlink() end })
else
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-create",
Strings("Create sync account"), { kind = "primary",
enabled = not eng:busy(),
action = function() imp:_syncCreate() end })
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link",
Strings("Link this device"), { kind = "accent",
action = function() imp:_syncView("link") end })
end
syncRow(imp, m, px + pad, cy, innerW, "sync-close", Strings("Close"),
{ action = function() imp:_closeSync() end })
end
local function buildSyncUnavailable(imp, m, msg)
local pad = math.floor(18 * m.s)
local w = math.floor(420 * m.s)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ Kit.wrapHeight("small", msg, w - 2 * pad, 4) + math.floor(10 * m.s)
+ m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", msg, px + pad, cy, pw - 2 * pad,
PAL.detail, 4) + math.floor(10 * m.s)
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-close",
Strings("Close"), { action = function() imp:_closeSync() end })
end
local function buildSyncModal(imp, m)
if not imp:_syncSupported() then
buildSyncUnavailable(imp, m, Strings(
"Save sync cannot run on this build: it has no way to send the signed requests it needs. Update to the latest app build, or use a desktop build."))
return
end
local eng = imp._sync
if not eng then
buildSyncUnavailable(imp, m,
Strings("Save sync is not available in this build."))
return
end
if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then
buildSyncConflict(imp, m, eng)
return
end
local view = imp._syncModal and imp._syncModal.view or "home"
if view == "link" then
buildSyncLink(imp, m, eng)
elseif view == "mods" then
buildSyncMods(imp, m, eng)
else
buildSyncHome(imp, m, eng)
end
end
-- Whether ANY modal will draw this frame. draw() consults this BEFORE the
-- panels build: immediate mode hit-tests each control as it draws, so the
-- panels underneath a modal must run with Kit.blockClicks already raised or
@@ -3919,7 +4471,7 @@ local function modalUp(imp)
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
or imp._filterPopup or imp._modScopePopup or imp._indexManage
or imp._gamePopup
or imp._modActions or imp._modImports
or imp._modActions or imp._modImports or imp._skinActions or imp._syncModal
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
end
@@ -4064,6 +4616,8 @@ local function buildModals(imp, m)
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
if imp._filterPopup then buildFilterModal(imp, m) return true end
if imp._indexManage then buildIndexesModal(imp, m) return true end
if imp._syncModal then buildSyncModal(imp, m) return true end
if imp._skinActions then buildSkinActionsModal(imp, m) return true end
if imp._modActions then buildModActionsModal(imp, m) return true end
if imp._findEntry then buildFindEntryModal(imp, m) return true end
if imp._gameManage then buildGameManageModal(imp, m) return true end
@@ -4173,13 +4727,6 @@ function LauncherView.draw(imp)
local footH = footerHeight(imp, m)
local naturalAvail = m.h - headerHeight(m) - footH - m.gap
local scrollMax = math.max(0, minPanelHeight(m) - naturalAvail)
local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax))
if scrollMax > 0 and (imp._wheelY or 0) ~= 0 then
scroll = math.max(0, math.min(
scroll - imp._wheelY * math.floor(48 * m.s), scrollMax))
imp._wheelY = 0 -- the page consumed the wheel; lists page by tap here
end
imp._pageScroll, imp._pageScrollMax = scroll, scrollMax
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
imp._clickPt = nil
@@ -4192,6 +4739,35 @@ function LauncherView.draw(imp)
-- one is up; buildModals lowers the shield for the modal's own controls.
Kit.blockClicks = modalUp(imp)
local step = Kit.scrollStep(m.s)
local nested = modListWantsWheel(imp, Kit.wheelY or 0)
if not nested then
local rect = imp._tabRegionRect
if rect then
setTabScroll(imp, (Kit.scrollWheel(tabScrollAt(imp), tabScrollMax(imp),
rect.x, rect.y, rect.w, rect.h, step)))
end
end
local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax))
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not nested
and not Kit.blockClicks then
local moved = math.max(0, math.min(scroll - Kit.wheelY * step, scrollMax))
if moved ~= scroll then
scroll = moved
Kit.wheelY = 0
end
end
imp._pageScroll, imp._pageScrollMax = scroll, scrollMax
if (Kit.wheelY or 0) ~= 0 and not nested and not Kit.blockClicks
and tabScrollMax(imp) > 0 then
local was = tabScrollAt(imp)
local to = Kit.scrollClamp(was - Kit.wheelY * step, tabScrollMax(imp))
if to ~= was then
setTabScroll(imp, to)
Kit.wheelY = 0
end
end
-- The header is the only block that moves with the page scroll, so shift
-- m.top across the call and put it back rather than wrapping `m` in a
-- proxy: the proxy cost two tables a frame and put a metatable lookup on
@@ -4210,15 +4786,31 @@ function LauncherView.draw(imp)
end
local x, w = m.contentX, m.contentW
local viewH = math.max(0, availH)
local rect = imp._tabRegionRect
if not rect then rect = {}; imp._tabRegionRect = rect end
rect.x, rect.y, rect.w, rect.h = x, contentY, w, viewH
local at = tabScrollAt(imp)
local py = Kit.scrollBegin(x, contentY, w, viewH, at, tabScrollMax(imp))
local budgetH = math.floor(viewH * (1 + PANEL_OVERSCAN))
local panelW = math.max(0, w - Kit.scrollGutter(m.s))
local contentH
if imp.tab == "mods" then
buildModsPanel(imp, x, contentY, w, availH, m)
contentH = buildModsPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "find" then
buildFindPanel(imp, x, contentY, w, availH, m)
contentH = buildFindPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "skins" then
buildSkinsPanel(imp, x, contentY, w, availH, m)
contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m)
else
buildGamePanel(imp, x, contentY, w, availH, m, imp.tab)
contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH)
end
contentH = contentH or availH
imp._tabContentH[tabKeyOf(imp)] = contentH
imp._tabScrollMax[tabKeyOf(imp)] = Kit.scrollExtent(contentH, viewH)
at = clamp(at, 0, tabScrollMax(imp))
imp._tabScroll[tabKeyOf(imp)] = at
Kit.scrollEnd(x, contentY, w, viewH, at, tabScrollMax(imp))
buildFooter(imp, m, footY)
Kit.blockClicks = false