diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 669b39f5..9d5c67e4 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -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) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 37fa0868..7001e2f2 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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 diff --git a/tests/drivers/launcher_skins_tab_shot.lua b/tests/drivers/launcher_skins_tab_shot.lua index 6f0460b7..22d95bca 100644 --- a/tests/drivers/launcher_skins_tab_shot.lua +++ b/tests/drivers/launcher_skins_tab_shot.lua @@ -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 diff --git a/tests/engine/skin_import_picker_test.lua b/tests/engine/skin_import_picker_test.lua new file mode 100644 index 00000000..fbb48824 --- /dev/null +++ b/tests/engine/skin_import_picker_test.lua @@ -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()