importer for skins now w file picker

This commit is contained in:
bryanthaboi
2026-08-17 20:15:39 -04:00
parent 22bcd95da1
commit c877ea80a7
4 changed files with 322 additions and 38 deletions
+73 -33
View File
@@ -2017,6 +2017,23 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
local gap = m.gap
local cy = y
local title = Strings("Skins/Borders")
local bh = m.btnH
local importLabel = imp:_skinsImportButtonLabel()
local importW = Kit.textWidth("small", importLabel) + math.floor(24 * m.s)
if Kit.textWidth("button", title) + importW + math.floor(24 * m.s) > w then
importLabel = Strings("Import")
importW = Kit.textWidth("small", importLabel) + math.floor(20 * m.s)
end
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
btn(imp, place(importW), cy, importW, bh, "skins-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseSkin() end })
Kit.text("button", Kit.ellipsize("button", title,
math.max(0, w - importW - math.floor(12 * m.s))), x,
cy + math.floor((bh - Kit.textHeight("button")) / 2), PAL.heading)
cy = cy + bh + math.floor(8 * m.s)
if imp._skinNotice then
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w,
imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s)
@@ -2075,30 +2092,57 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
cy = cy + rowH + math.floor(4 * m.s)
end
skinRow("skin-none", nil, Strings("Built-in pad"),
Strings("The default on-screen buttons."), active == nil,
imp.onEditTouchControls and function()
imp.onEditTouchControls(imp.modScope or "red")
end or nil)
local entries = { false }
for _, entry in ipairs(skins) do entries[#entries + 1] = entry end
for _, entry in ipairs(skins) do
local bits = {}
bits[#bits + 1] = entry.source == "user" and Strings("installed")
or Strings("bundled")
if entry.controls > 0 then
bits[#bits + 1] = entry.controls .. " " .. Strings("buttons")
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.",
TouchSkin.USER_ROOT)
local hintH = Kit.wrapHeight("small", hint, w, 3)
local importH = math.floor(10 * m.s) + hintH
local rowGap = math.floor(4 * m.s)
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)
if #entries > perPage then
perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap, 1, 20)
end
local first, last, cur, pages = Kit.pageBounds(page(imp, "skins"),
#entries, perPage)
setPage(imp, "skins", cur)
setPage(imp, "skins",
Kit.wheelPage(x, listTop, w, listH, cur, #entries, perPage))
for i = first, last do
local entry = entries[i]
if not entry then
skinRow("skin-none", nil, Strings("Built-in pad"),
Strings("The default on-screen buttons."), active == nil,
imp.onEditTouchControls and function()
imp.onEditTouchControls(imp.modScope or "red")
end or nil)
else
bits[#bits + 1] = Strings("bezel only")
local bits = {}
bits[#bits + 1] = entry.source == "user" and Strings("installed")
or Strings("bundled")
if entry.controls > 0 then
bits[#bits + 1] = entry.controls .. " " .. Strings("buttons")
else
bits[#bits + 1] = Strings("bezel only")
end
if entry.pages > 1 then
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
skinRow("skin-" .. entry.id, entry.id, entry.id,
table.concat(bits, " \194\183 "), active == entry.id, configure)
end
if entry.pages > 1 then
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
skinRow("skin-" .. entry.id, entry.id, entry.id,
table.concat(bits, " \194\183 "), active == entry.id, configure)
end
if #skins == 0 then
@@ -2107,18 +2151,14 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
cy = cy + math.floor(72 * m.s) + gap
end
cy = cy + math.floor(6 * m.s)
local TouchSkin = require("src.core.TouchSkin")
Kit.caption(x, cy, Strings("IMPORT"))
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
local boxH = math.floor(76 * m.s)
Kit.card(x, cy, w, boxH, "muted")
Kit.textWrapped("small", Strings(
"Drop a skin .zip on this window to install it, or put a folder in the skins folder of your save directory. RetroArch overlay .cfg files work as-is."),
x + math.floor(14 * m.s), cy + math.floor(12 * m.s),
w - math.floor(28 * m.s), PAL.muted, 3)
Kit.text("small", TouchSkin.USER_ROOT .. "/", x + math.floor(14 * m.s),
cy + boxH - Kit.textHeight("small") - math.floor(10 * m.s), PAL.faint)
if pages > 1 then
setPage(imp, "skins",
Kit.pager(x, cy, w, cur, #entries, perPage, "skins"))
cy = cy + pagerH + gap
end
cy = cy + math.floor(10 * m.s)
Kit.textWrapped("small", hint, x, cy, w, PAL.muted, 3)
end
local function buildFindPanel(imp, x, y, w, availH, m)
+116 -5
View File
@@ -1089,6 +1089,39 @@ local function chooseZip()
return nil
end
local function chooseSkinZip()
local prompt = shellSafe(Strings("Choose a skin .zip"))
local platform = love.system.getOS()
if platform == "OS X" then
return commandOutput(
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip"})' 2>/dev/null]])
:format(prompt))
elseif platform == "Windows" then
local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';",
"$d.Filter='Skin archive (*.zip)|*.zip|All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){",
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
"$t=Join-Path $env:TEMP $n;",
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
"[Console]::Write($t)}",
})
return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then
local path = commandOutput(
([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip" 2>/dev/null]])
:format(prompt))
if path then return path end
return commandOutput(
[[kdialog --getopenfilename "$HOME" "*.zip|Skin archive" 2>/dev/null]])
end
return nil
end
-- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS
-- dialogs). Returns the chosen absolute path or nil. Android uses
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
@@ -1478,7 +1511,12 @@ function RomImporter:focus(f)
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
elseif pickError:find("picked_mod", 1, true) then
self.modNotice = { ok = false, text = text }
if self.pickerPendingKind == "skin" then
self.pickerPendingKind = nil
self._skinNotice = { ok = false, text = text }
else
self.modNotice = { ok = false, text = text }
end
elseif pickError:find("picked_save", 1, true) then
local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil
@@ -1504,6 +1542,13 @@ function RomImporter:focus(f)
end
local modName = findPendingMod(false, self.pickSkip)
if modName then
if self.pickerPendingKind == "skin" then
self.pickerPendingKind = nil
self:_installSkinZip(modName)
consumePick(self, modName, "picked_mod.zip",
self._skinNotice and self._skinNotice.ok)
return
end
self:_installMod(modName)
consumePick(self, modName, "picked_mod.zip",
self.modNotice and self.modNotice.ok)
@@ -2498,6 +2543,11 @@ function RomImporter:update(dt)
if Platform.isUWP() and self.modNotice and self.modNotice.ok then
os.remove(path)
end
elseif kind == "skin" then
self:_installSkinZip(path)
if Platform.isUWP() and self._skinNotice and self._skinNotice.ok then
os.remove(path)
end
elseif kind == "sav" then
local target = version or self:_savedropTarget()
self:_importSave(target, path)
@@ -2520,6 +2570,8 @@ function RomImporter:update(dt)
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
elseif kind == "mod" then
self.modNotice = { ok = false, text = errorText }
elseif kind == "skin" then
self._skinNotice = { ok = false, text = errorText }
elseif kind == "sav" then
self.saveNotice[version] = { ok = false, text = errorText }
else
@@ -3047,13 +3099,27 @@ function RomImporter:_useSkin(id)
}
end
function RomImporter:_installSkinZip(file)
function RomImporter:_installSkinZip(source)
if self.workState == "working" then return end
self.tab = "skins"
local TouchSkin = require("src.core.TouchSkin")
local name = file:getFilename() or ""
local data, readError = readDroppedFile(file)
local name, data, readError
if type(source) == "string" then
name = source
if not source:match("^/") and not source:match("^%a:[/\\]")
and not source:match("^[Ss][Dd][Mm][Cc]:") then
data = love.filesystem.read(source)
end
if not data then data, readError = readExternalPath(source) end
if not data then data = love.filesystem.read(source) end
else
name = source:getFilename() or ""
data, readError = readDroppedFile(source)
end
if not data then
self._skinNotice = { ok = false,
text = "Could not read the dropped file: " .. tostring(readError) }
text = "Could not read the skin archive: "
.. tostring(readError or name) }
return
end
local id, err = TouchSkin.installArchive(name, data)
@@ -3065,6 +3131,51 @@ function RomImporter:_installSkinZip(file)
self._skinNotice = { ok = true, text = "Imported " .. id }
end
function RomImporter:_skinsImportButtonLabel()
if self.isNX then return Strings("Scan again") end
return Strings("Import skin .zip")
end
function RomImporter:chooseSkin()
if self.workState == "working" then return end
if self.isNX then
local found = #self:_ensureSkins(true)
self._skinNotice = { ok = true, text = Strings(
"%d skins found. Copy a skin .zip into %s/ over MTP, then scan again.",
found, require("src.core.TouchSkin").USER_ROOT) }
return
end
if self.nativePicker and love.system.getPickedFile then
self.pickerPendingKind = "skin"
if not pickFile("mod") then
self.pickerPendingKind = nil
self._skinNotice = { ok = false, text = "Could not open the file picker." }
end
return
end
if self.android then
local name = findPendingMod(true, self.pickSkip)
if name then
self:_installSkinZip(name)
consumePick(self, name, "picked_mod.zip",
self._skinNotice and self._skinNotice.ok)
return
end
self.pickerPendingKind = "skin"
if not pickFile("mod") then
self.pickerPendingKind = nil
self._skinNotice = { ok = false,
text = "Could not open the file picker. Copy a skin .zip via USB." }
else
self.pickPending = true
self.pickTimer = 0
end
return
end
local path = chooseSkinZip()
if path then self:_installSkinZip(path) end
end
function RomImporter:_toggleFindSearchFocus()
self._findSearchFocus = not self._findSearchFocus
if self._findSearchFocus then
+15
View File
@@ -94,6 +94,21 @@ return function(game)
imp:_useSkin(nil)
shot("skins_tab_imported.png")
-- import button: the picker hands back an absolute host path, not a drop
local picked = dir .. "/picked_skin.zip"
local pf = io.open(picked, "wb")
pf:write(raw)
pf:close()
imp:_installSkinZip(picked)
U.log("path import notice:", imp._skinNotice.text)
U.log("picked_skin found:", tostring(TouchSkin.find("picked_skin") ~= nil))
U.log("import label:", imp:_skinsImportButtonLabel())
shot("skins_tab_path_imported.png")
love.window.setMode(520, 760, { resizable = true, highdpi = true })
U.wait(3)
shot("skins_tab_narrow.png")
U.log("done")
love.event.quit()
while true do coroutine.yield() end
+118
View File
@@ -0,0 +1,118 @@
-- Skins tab Import button: picker plumbing + path/drop install (SKINIMP-01..05).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("skin import picker")
local eq = S.eq
local check = S.check
local RomImporter = require("src.import.RomImporter")
local TouchSkin = require("src.core.TouchSkin")
love.system = love.system or {}
local saved = {
pickFile = love.system.pickFile,
getPickedFile = love.system.getPickedFile,
getPickError = love.system.getPickError,
installArchive = TouchSkin.installArchive,
}
local installed
TouchSkin.installArchive = function(name, data)
installed = { name = name, data = data }
if not data or data == "" then return nil, "empty archive" end
return (name:match("([^/\\]+)$") or name):gsub("%.[Zz][Ii][Pp]$", "")
end
local function freshImporter(fields)
local imp = RomImporter.new(function() end, { launcher = true })
imp.tab = "skins"
imp._skins = {}
imp._ensureSkins = function(self) return self._skins end
for k, v in pairs(fields or {}) do imp[k] = v end
return imp
end
-- SKINIMP-01: a picker path installs as a skin, not a mod
installed = nil
local imp = freshImporter()
local tmp = os.tmpname() .. ".zip"
local f = assert(io.open(tmp, "wb"))
f:write("PK\3\4skin-archive")
f:close()
imp:_installSkinZip(tmp)
os.remove(tmp)
check(installed ~= nil, "an absolute picker path reaches TouchSkin.installArchive")
eq(installed.data, "PK\3\4skin-archive", "the picked file's bytes are installed")
check(imp._skinNotice and imp._skinNotice.ok, "a good pick reports success")
-- SKINIMP-02: a dropped file still installs, and both paths land on the tab
installed = nil
imp = freshImporter({ tab = "red" })
imp:filedropped({
getFilename = function() return "/tmp/dropped_skin.zip" end,
getSize = function() return 4 end,
open = function() return true end,
read = function() return "PKZP" end,
close = function() return true end,
})
check(installed == nil, "a zip dropped off the skins tab is still a mod")
installed = nil
imp = freshImporter()
imp:filedropped({
getFilename = function() return "/tmp/dropped_skin.zip" end,
getSize = function() return 4 end,
open = function() return true end,
read = function() return "PKZP" end,
close = function() return true end,
})
eq(installed and installed.name, "/tmp/dropped_skin.zip",
"a zip dropped on the skins tab installs as a skin")
eq(imp.tab, "skins", "the skin install stays on the skins tab")
-- SKINIMP-03: the mobile bridge borrows the mod picker kind
local requestedKind
love.system.pickFile = function(kind)
requestedKind = kind
return true
end
love.system.getPickedFile = function() return nil end
love.system.getPickError = function() return nil end
imp = freshImporter({ nativePicker = true })
imp:chooseSkin()
eq(requestedKind, "mod", "chooseSkin opens the .zip picker")
eq(imp.pickerPendingKind, "skin", "the pending pick is routed to the skins tab")
-- SKINIMP-04: the picked file comes back through update() as a skin
installed = nil
love.system.getPickedFile = function()
love.system.getPickedFile = function() return nil end
return "/picked/from_bridge.zip"
end
imp._installSkinZip = function(self, source)
self.installedSource = source
self._skinNotice = { ok = true, text = "Imported from_bridge" }
end
imp:update(0)
eq(imp.installedSource, "/picked/from_bridge.zip",
"update() hands the picked path to the skin installer")
eq(imp.pickerPendingKind, nil, "the pending kind clears once consumed")
-- SKINIMP-05: a picker error lands on the skins notice, not the mods one
love.system.getPickedFile = function() return nil end
love.system.getPickError = function()
love.system.getPickError = function() return nil end
return "picker refused"
end
imp = freshImporter({ nativePicker = true, pickerPendingKind = "skin" })
imp:update(0)
check(imp._skinNotice and not imp._skinNotice.ok,
"a failed skin pick reports on the skins tab")
eq(imp.modNotice, nil, "a failed skin pick leaves the mods notice alone")
love.system.pickFile = saved.pickFile
love.system.getPickedFile = saved.getPickedFile
love.system.getPickError = saved.getPickError
TouchSkin.installArchive = saved.installArchive
S.finish()