diff --git a/docs/skin-studio.md b/docs/skin-studio.md index d7a834a4..27218336 100644 --- a/docs/skin-studio.md +++ b/docs/skin-studio.md @@ -6,6 +6,7 @@ control layout, and the rectangle the Game Boy screen is drawn into. Engine: (draw and input), `src/render/Renderer.lua` (the screen viewport), `src/ui/SkinStudio.lua` (the desktop editor). Tests: `tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`, +`tests/engine/skin_studio_image_import.lua`, `tests/engine/launcher_skins_tab.lua`. Skins are picked in the launcher's **Skins** tab, which also imports them and @@ -159,8 +160,16 @@ The Super Game Boy preset locks the viewport to the real screen window, resize. X / Y / W / H are in canvas pixels, so a control can be typed to the coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and pressed images are per control; the bezel, the pages and the screen cutout are -per page. The cutout is itself a draggable element with a 10:9 lock. Drop a PNG -or JPG on the window to import art into the skin. +per page. The cutout is itself a draggable element with a 10:9 lock. + +**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the +images already in the skin folder; the **Import** button beside each one opens +the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell, +zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in +the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the +window does the same for whichever slot was last touched. A new bezel does not +move the screen cutout: press **Detect screen from bezel** to measure it out of +the art's alpha. **Testing.** **Test** makes the canvas live: clicking presses real Game Boy buttons and the footer reports what is held. **Play** saves the skin, selects @@ -175,5 +184,3 @@ straight back into `skins/` and still opens in RetroArch. ## Not implemented RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types. -Image assignment cycles through art already in the skin folder; there is no -file browser, so new art arrives by drag and drop. diff --git a/main.lua b/main.lua index 10a07426..eb0768c6 100644 --- a/main.lua +++ b/main.lua @@ -506,6 +506,7 @@ function love.update(dt) if TouchEditor then return TouchEditor.update(dt) end if Studio then return Studio.update(dt) end if Importer then return Importer:update(dt) end + if not Game then return end -- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly -- once per Game:update, so they must keep a 1:1 relationship with the @@ -602,12 +603,14 @@ function love.keypressed(key, scancode, isrepeat) if TouchEditor then return TouchEditor.keypressed(key) end if Studio then return Studio.keypressed(key) end if Importer then return Importer:keypressed(key) end + if not Game then return end Game:keypressed(key) end function love.keyreleased(key) if editorMode or TouchEditor or Studio then return end if Importer then return end + if not Game then return end Game:keyreleased(key) end @@ -625,7 +628,9 @@ function love.gamepadpressed(joystick, button) end return end + if Studio then return end if Importer then return Importer:gamepadpressed(joystick, button) end + if not Game then return end Game:gamepadpressed(joystick, button) end @@ -643,7 +648,9 @@ function love.gamepadreleased(joystick, button) end return end + if Studio then return end if Importer then return Importer:gamepadreleased(joystick, button) end + if not Game then return end Game:gamepadreleased(joystick, button) end @@ -661,7 +668,9 @@ function love.gamepadaxis(joystick, axis, value) end return end + if Studio then return end if Importer then return Importer:gamepadaxis(joystick, axis, value) end + if not Game then return end Game:gamepadaxis(joystick, axis, value) end @@ -679,7 +688,9 @@ function love.joystickpressed(joystick, button) end return end + if Studio then return end if Importer then return Importer:joystickpressed(joystick, button) end + if not Game then return end Game:joystickpressed(joystick, button) end @@ -697,7 +708,9 @@ function love.joystickreleased(joystick, button) end return end + if Studio then return end if Importer then return Importer:joystickreleased(joystick, button) end + if not Game then return end Game:joystickreleased(joystick, button) end @@ -715,7 +728,9 @@ function love.joystickaxis(joystick, axis, value) end return end + if Studio then return end if Importer then return Importer:joystickaxis(joystick, axis, value) end + if not Game then return end Game:joystickaxis(joystick, axis, value) end @@ -733,21 +748,25 @@ function love.joystickhat(joystick, hat, direction) end return end + if Studio then return end if Importer then return Importer:joystickhat(joystick, hat, direction) end + if not Game then return end Game:joystickhat(joystick, hat, direction) end function love.joystickadded(joystick) SwitchDiagnostics.onJoystickEvent("joystickadded", joystick) - if editorMode or TouchEditor then return end + if editorMode or TouchEditor or Studio then return end if Importer then return end + if not Game then return end Game:joystickadded(joystick) end function love.joystickremoved(joystick) SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick) - if editorMode or TouchEditor then return end + if editorMode or TouchEditor or Studio then return end if Importer then return end + if not Game then return end Game:joystickremoved(joystick) end @@ -756,26 +775,36 @@ end -- unfocused, so reset input on either transition rather than trust it. function love.focus(f) if editorMode or TouchEditor then return end + if Studio then + if Studio.focus then Studio.focus(f) end + return + end if Importer then require("src.core.Input"):reset() if Importer.focus then Importer:focus(f) end return end + if not Game then return end Game:focus(f) end -- v is true when the window becomes visible again, false on minimize. function love.visible(v) if editorMode or TouchEditor then return end + if Studio then + if Studio.visible then Studio.visible(v) end + return + end if Importer then require("src.core.Input"):reset() return end + if not Game then return end Game:visible(v) end function love.lowmemory() - if editorMode or TouchEditor or Importer then return end + if editorMode or TouchEditor or Studio or Importer then return end if Game then Game:onResume() end end @@ -796,12 +825,14 @@ function love.touchpressed(id, x, y, dx, dy, pressure) if love.system.getOS() == "iOS" then return end return TouchEditor.touchpressed(id, x, y) end + if Studio then return end if Importer then -- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are -- polled inside the view; the istouch filter on mousepressed still drops -- Android's synthesized mouse twin so Import cannot double-fire (#553). return Importer:touchpressed(id, x, y, dx, dy, pressure) end + if not Game then return end Game:touchpressed(id, x, y, dx, dy, pressure) end @@ -811,9 +842,11 @@ function love.touchmoved(id, x, y, dx, dy, pressure) if love.system.getOS() == "iOS" then return end return TouchEditor.touchmoved(id, x, y) end + if Studio then return end if Importer then return Importer:touchmoved(id, x, y, dx, dy, pressure) end + if not Game then return end Game:touchmoved(id, x, y, dx, dy, pressure) end @@ -823,9 +856,11 @@ function love.touchreleased(id, x, y, dx, dy, pressure) if love.system.getOS() == "iOS" then return end return TouchEditor.touchreleased(id, x, y) end + if Studio then return end if Importer then return Importer:touchreleased(id, x, y, dx, dy, pressure) end + if not Game then return end Game:touchreleased(id, x, y, dx, dy, pressure) end @@ -837,6 +872,7 @@ function love.wheelmoved(x, y) if TouchEditor then return end if Studio then return Studio.wheelmoved(x, y) end if Importer then return end + if not Game then return end Game:wheelmoved(x, y) end diff --git a/src/core/FilePicker.lua b/src/core/FilePicker.lua new file mode 100644 index 00000000..d56cc116 --- /dev/null +++ b/src/core/FilePicker.lua @@ -0,0 +1,109 @@ +local Platform = require("src.core.Platform") +local HostShell = require("src.core.HostShell") + +local FilePicker = {} + +FilePicker.IMAGE = { + label = "Image", + exts = { "png", "jpg", "jpeg" }, + tempName = "pokeport_image_pick", +} + +local function trim(value) + return value and value:gsub("^%s+", ""):gsub("%s+$", "") or "" +end + +local function shellSafe(s) + s = tostring(s):gsub("%%", "%%%%") + return s:gsub('"', '\\"'):gsub("'", "''") +end + +local function commandOutput(command) + if not Platform.canSpawnProcess() then return nil end + local pipe = HostShell.popen(command) + if not pipe then return nil end + local result = pipe:read("*a") + HostShell.pclose(pipe) + result = trim(result) + return result ~= "" and result or nil +end + +local function appleTypes(exts) + local out = {} + for _, ext in ipairs(exts) do out[#out + 1] = '"' .. ext .. '"' end + return table.concat(out, ", ") +end + +local function windowsPatterns(exts) + local out = {} + for _, ext in ipairs(exts) do out[#out + 1] = "*." .. ext end + return table.concat(out, ";") +end + +local function globPatterns(exts) + local out = {} + for _, ext in ipairs(exts) do out[#out + 1] = "*." .. ext end + return table.concat(out, " ") +end + +function FilePicker.available() + return Platform.canSpawnProcess() +end + +function FilePicker.matches(name, kind) + local lower = tostring(name or ""):lower() + for _, ext in ipairs(kind.exts) do + if lower:match("%." .. ext .. "$") then return true end + end + return false +end + +function FilePicker.open(prompt, kind) + if not Platform.canSpawnProcess() then return nil end + local title = shellSafe(prompt) + local platform = love.system.getOS() + if platform == "OS X" then + return commandOutput( + ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {%s})' 2>/dev/null]]) + :format(title, appleTypes(kind.exts))) + elseif platform == "Windows" then + local script = table.concat({ + "Add-Type -AssemblyName System.Windows.Forms;", + "$d=New-Object System.Windows.Forms.OpenFileDialog;", + "$d.Title='" .. title .. "';", + "$d.Filter='" .. kind.label .. " (" .. windowsPatterns(kind.exts) .. ")|" + .. windowsPatterns(kind.exts) .. "|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="%s | %s" 2>/dev/null]]) + :format(title, kind.label, globPatterns(kind.exts))) + if path then return path end + return commandOutput(([[kdialog --getopenfilename "$HOME" "%s|%s" 2>/dev/null]]) + :format(globPatterns(kind.exts), kind.label)) + end + return nil +end + +function FilePicker.read(path) + local file, openError = io.open(path, "rb") + if not file then return nil, openError end + local data = file:read("*a") + file:close() + if not data or data == "" then return nil, "empty file" end + return data +end + +function FilePicker.basename(path) + return tostring(path or ""):match("([^/\\]+)$") or tostring(path or "") +end + +return FilePicker diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index 6c37bef8..936ada14 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -4,6 +4,7 @@ local PAL = Theme.PAL local TouchSkin = require("src.core.TouchSkin") local TouchControls = require("src.core.TouchControls") local SaveData = require("src.core.SaveData") +local FilePicker = require("src.core.FilePicker") local Studio = {} @@ -214,12 +215,19 @@ function Studio.cycleImage(dir) markDirty() end +function Studio.imageTargetLabel() + local target = Studio.imageTarget + if target == "bezel" or not Studio.selectedControl() then return "bezel" end + return target == "pressed" and "pressed art" or "idle art" +end + function Studio.assignImage(rel) local page, ctl = Studio.page(), Studio.selectedControl() local img = TouchSkin.resolveImage(Studio.skin.root, rel) - if ctl and Studio.imageTarget == "pressed" then + local target = Studio.imageTarget + if ctl and target == "pressed" then ctl.pressedImagePath, ctl.pressedImage = rel, img - elseif ctl and Studio.imageTarget == "idle" then + elseif ctl and target == "idle" then ctl.imagePath, ctl.image = rel, img elseif page then page.imagePath, page.image = rel, img @@ -228,11 +236,69 @@ function Studio.assignImage(rel) Studio.dirty = true end +local function commitSkinId() + local skin = Studio.skin + if not skin then return end + local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "") + if id == "" or id == skin.id then return end + TouchSkin.saveTo(skin, id) + Studio.available = TouchSkin.list() +end + +function Studio.adoptImage(name, data, target) + if not Studio.skin then return false end + if target then Studio.imageTarget = target end + if not FilePicker.matches(name, FilePicker.IMAGE) then + Studio.status = "Pick a PNG or JPG." + return false + end + commitSkinId() + local rel, err = TouchSkin.importImage(Studio.skin, name, data) + if not rel then + Studio.status = "Import failed: " .. tostring(err) + return false + end + local where = Studio.imageTargetLabel() + Studio.assignImage(rel) + Studio.skinIdField = Studio.skin.id + Studio.status = "Imported " .. rel .. " as " .. where + if where == "bezel" and not Studio.canvas().lockViewport then + Studio.status = Studio.status + .. " -- use Detect screen from bezel to place the screen" + end + return true +end + +function Studio.importImageFile(target) + if not Studio.skin then return end + target = target or Studio.imageTarget + Studio.imageTarget = target + if target ~= "bezel" and not Studio.selectedControl() then + Studio.status = "Select a control first, or import a bezel image." + return + end + if not FilePicker.available() then + Studio.status = "No file picker here -- drag a PNG onto the window instead." + return + end + local prompt = (target == "bezel") and "Choose a bezel image" + or "Choose a button image" + local path = FilePicker.open(prompt, FilePicker.IMAGE) + if not path then return end + local base = FilePicker.basename(path) + local data, err = FilePicker.read(path) + if not data then + Studio.status = "Could not read " .. base .. ": " .. tostring(err) + return + end + Studio.adoptImage(base, data, target) +end + function Studio.filedropped(file) if not Studio.skin then return end local path = (file.getFilename and file:getFilename()) or "" - local base = path:match("([^/\\]+)$") or path - if not base:lower():match("%.png$") and not base:lower():match("%.jpe?g$") then + local base = FilePicker.basename(path) + if not FilePicker.matches(base, FilePicker.IMAGE) then Studio.status = "Drop a PNG or JPG to use it as art." return end @@ -246,17 +312,7 @@ function Studio.filedropped(file) Studio.status = "Could not read " .. base return end - local rel, err = TouchSkin.importImage(Studio.skin, base, data) - if not rel then - Studio.status = "Import failed: " .. tostring(err) - return - end - Studio.assignImage(rel) - local where = Studio.selectedControl() - and (Studio.imageTarget == "pressed" and "pressed art" or "idle art") - or "bezel" - Studio.status = "Imported " .. rel .. " as " .. where - Studio.skinIdField = Studio.skin.id + Studio.adoptImage(base, data) end function Studio.detectViewport() @@ -617,10 +673,16 @@ local function inspectorBody(x, y, w) if page then local bezel = page.imagePath or "(none)" - if Kit.button(x, cy, w, rowH, "Bezel: " .. bezel, { id = "bezel" }) then + local pickW = 82 * Kit.scale + local cycleW = w - pickW - gap + if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then Studio.imageTarget = "bezel" Studio.cycleImage(1) end + if Kit.button(x + cycleW + gap, cy, pickW, rowH, "Import", + { id = "bezelpick" }) then + Studio.importImageFile("bezel") + end cy = cy + rowH + gap local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF" if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp", @@ -701,17 +763,25 @@ local function inspectorBody(x, y, w) Kit.text("small", ("canvas %dx%d px"):format(canvas.w, canvas.h), x, cy, PAL.faint) cy = cy + Kit.textHeight("small") + gap + local pickW = 82 * Kit.scale + local artW = w - pickW - gap local idle = ctl.imagePath or "(none)" - if Kit.button(x, cy, w, rowH, "Idle art: " .. idle, { id = "img" }) then + if Kit.button(x, cy, artW, rowH, "Idle art: " .. idle, { id = "img" }) then Studio.imageTarget = "idle" Studio.cycleImage(1) end + if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgpick" }) then + Studio.importImageFile("idle") + end cy = cy + rowH + gap local pressed = ctl.pressedImagePath or "(none)" - if Kit.button(x, cy, w, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then + if Kit.button(x, cy, artW, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then Studio.imageTarget = "pressed" Studio.cycleImage(1) end + if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgppick" }) then + Studio.importImageFile("pressed") + end return cy + rowH end @@ -885,6 +955,16 @@ function Studio.wheelmoved(_, dy) Studio.wheel = dy end +function Studio.focus() + Studio.drag = nil + Studio.clicked = false + if Studio.testing then TouchControls:reset() end +end + +function Studio.visible() + Studio.focus() +end + function Studio.textinput(text) Kit.textinput(text) end diff --git a/tests/drivers/skin_studio_focus_test.lua b/tests/drivers/skin_studio_focus_test.lua new file mode 100644 index 00000000..71793dc9 --- /dev/null +++ b/tests/drivers/skin_studio_focus_test.lua @@ -0,0 +1,51 @@ +-- Driver: the studio's side of the alt-tab crash. Leaving the window mid-drag +-- must drop the drag and the queued click and still draw; main.lua's routing of +-- love.focus / love.visible / pad events while the studio owns the window is +-- pinned by tests/engine/skin_studio_image_import.lua (a driver run boots a +-- game, so main.lua's Studio branch is not live here). +-- POKEPORT_DRIVER=tests/drivers/skin_studio_focus_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Studio = require("src.ui.SkinStudio") + local TouchControls = require("src.core.TouchControls") + + love.window.setMode(1280, 720, { resizable = true, highdpi = true }) + U.wait(2) + + Studio.load({ version = "red", skinId = "gb_anim", onClose = function() end }) + U.wait(2) + U.log("skin:", Studio.skin and Studio.skin.id) + + Studio.drag = { kind = "control-move", mx = 1, my = 1, bx = 0, by = 0, + bw = 10, bh = 10 } + Studio.clicked = true + local okFocus, errFocus = pcall(Studio.focus, false) + U.log("focus lost:", okFocus, errFocus or "") + U.log("drag:", tostring(Studio.drag), "click:", tostring(Studio.clicked)) + local droppedDrag = Studio.drag == nil and Studio.clicked == false + + -- a held test press must not survive the trip out of the window either + Studio.testing = true + TouchControls:setPreview(false) + TouchControls.held = TouchControls.held or {} + TouchControls.held.start = true + pcall(Studio.focus, false) + local held = next(TouchControls.held or {}) + U.log("held after focus loss:", tostring(held)) + + local okVisible = pcall(Studio.visible, false) + U.log("minimize:", okVisible) + + local okDraw, errDraw = pcall(Studio.draw) + U.log("draw after alt-tab:", okDraw, errDraw or "") + + Studio.unload() + + if droppedDrag and held == nil and okVisible and okDraw then + U.log("RESULT pass") + else + U.log("RESULT FAIL") + end + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/skin_studio_import_shot.lua b/tests/drivers/skin_studio_import_shot.lua new file mode 100644 index 00000000..e2014874 --- /dev/null +++ b/tests/drivers/skin_studio_import_shot.lua @@ -0,0 +1,120 @@ +-- Driver: imports a bezel image into a fresh skin through the studio's Import +-- button and shoots the result, with the native dialog stubbed out (the real +-- one blocks on osascript / zenity / PowerShell). Also covers button art and +-- the drag-and-drop path. +-- SHOT_DIR=/tmp/studioimport POKEPORT_DRIVER=tests/drivers/skin_studio_import_shot.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Studio = require("src.ui.SkinStudio") + local FilePicker = require("src.core.FilePicker") + local TouchSkin = require("src.core.TouchSkin") + + local dir = os.getenv("SHOT_DIR") or "/tmp/studioimport" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1440, 900, { resizable = true, highdpi = true }) + U.wait(2) + + local pending = nil + love.draw = function() + Studio.draw() + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 90 do + if not pending then break end + coroutine.yield() + end + U.wait(3) + local f = io.open(dir .. "/" .. name, "rb") + U.log(f and "shot" or "FAIL shot", name) + if f then f:close() end + end + + Studio.load({ version = "red", onClose = function() end }) + U.wait(3) + -- start from New so the run does not depend on the saved skin choice + Studio.skin = TouchSkin.newSkin("import_probe") + Studio.skinIdField = "import_probe" + Studio.pageIndex, Studio.selected, Studio.images = 1, nil, {} + U.log("skin:", Studio.skin.id, "bezel:", tostring(Studio.page().imagePath)) + + -- what the native dialog would hand back + local picked = "assets/skins/gb_anim/img/gb_back.png" + local realOpen = FilePicker.open + FilePicker.open = function(prompt, kind) + U.log("picker prompt:", prompt, "exts:", table.concat(kind.exts, "/")) + return picked + end + + Studio.importImageFile("bezel") + U.log("status:", tostring(Studio.status)) + U.log("bezel now:", tostring(Studio.page().imagePath), + "loaded:", tostring(Studio.page().image ~= nil)) + U.log("skin root:", Studio.skin.root) + U.log("file on disk:", + tostring(love.filesystem.getInfo(Studio.skin.root .. "/img/gb_back.png") ~= nil)) + local viewport = Studio.page().viewport + U.log("viewport left alone:", viewport and + ("%.3f %.3f %.3f %.3f"):format(viewport.x, viewport.y, viewport.w, viewport.h) + or "none") + shot("import_bezel.png") + + -- button art goes onto the selected control, not the page + Studio.addControl() + FilePicker.open = function() return "assets/skins/gb_anim/img/gbc_a.png" end + Studio.importImageFile("idle") + local ctl = Studio.selectedControl() + U.log("idle art:", tostring(ctl.imagePath), + "bezel untouched:", tostring(Studio.page().imagePath)) + FilePicker.open = function() return "assets/skins/gb_anim/img/gbc_b.png" end + Studio.importImageFile("pressed") + U.log("pressed art:", tostring(ctl.pressedImagePath)) + shot("import_button_art.png") + + -- cancelling the dialog changes nothing + local before = Studio.page().imagePath + FilePicker.open = function() return nil end + Studio.importImageFile("bezel") + U.log("after cancel:", tostring(Studio.page().imagePath), + "unchanged:", tostring(before == Studio.page().imagePath)) + + -- the drop path shares the import, and refuses non-art + Studio.imageTarget = "bezel" + local raw = love.filesystem.read("assets/skins/tv_crt/img/tv-integer.png") + Studio.filedropped({ + getFilename = function() return "/tmp/tv_frame.png" end, + open = function() return true end, + read = function() return raw end, + close = function() return true end, + }) + U.log("dropped bezel:", tostring(Studio.page().imagePath)) + Studio.filedropped({ + getFilename = function() return "/tmp/notes.txt" end, + open = function() return true end, + read = function() return "nope" end, + close = function() return true end, + }) + U.log("after dropping a txt:", tostring(Studio.page().imagePath), + "status:", tostring(Studio.status)) + shot("import_dropped.png") + + love.window.setMode(760, 900, { resizable = true, highdpi = true }) + U.wait(3) + shot("import_narrow.png") + + FilePicker.open = realOpen + local saved = TouchSkin.find("import_probe") + U.log("skin discoverable:", tostring(saved ~= nil)) + Studio.unload() + U.log("done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/engine/skin_studio_image_import.lua b/tests/engine/skin_studio_image_import.lua new file mode 100644 index 00000000..3012e6ca --- /dev/null +++ b/tests/engine/skin_studio_image_import.lua @@ -0,0 +1,137 @@ +-- Skin Studio art import: the native image picker behind the "Import" buttons +-- (bezel / idle / pressed), and the alt-tab crash that took the studio down -- +-- love.focus fell through to Game:focus with no game booted. +-- luajit tests/engine/skin_studio_image_import.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local FilePicker = require("src.core.FilePicker") +local TouchSkin = require("src.core.TouchSkin") +local Studio = require("src.ui.SkinStudio") + +local function session() + Studio.skin = TouchSkin.newSkin("t") + Studio.skinIdField = "t" + Studio.pageIndex = 1 + Studio.selected = nil + Studio.canvasIndex = 1 + Studio.drag = nil + Studio.dirty = false + Studio.status = nil + Studio.testing = false + Studio.images = {} + Studio.imageTarget = "idle" + return Studio.skin +end + +-- ------------------------------------------------------------ file picker + +check(FilePicker.matches("bezel.png", FilePicker.IMAGE), "png is art") +check(FilePicker.matches("BEZEL.PNG", FilePicker.IMAGE), "case does not matter") +check(FilePicker.matches("shot.jpg", FilePicker.IMAGE), "jpg is art") +check(FilePicker.matches("shot.jpeg", FilePicker.IMAGE), "jpeg is art") +check(not FilePicker.matches("skin.zip", FilePicker.IMAGE), "a zip is not art") +eq(FilePicker.basename("/home/me/art/bezel.png"), "bezel.png", "posix basename") +eq(FilePicker.basename("C:\\Users\\me\\bezel.png"), "bezel.png", "windows basename") +eq(FilePicker.basename("bezel.png"), "bezel.png", "a bare name is its own basename") + +-- ----------------------------------------------------------- import target + +session() +Studio.addControl() +local ctl = Studio.selectedControl() +check(Studio.adoptImage("bezel.png", "PNGDATA", "bezel"), "bezel import succeeds") +eq(Studio.page().imagePath, "img/bezel.png", "bezel art lands on the page") +eq(ctl.imagePath, nil, "and not on the selected control") +check(Studio.dirty, "importing marks the skin dirty") +check(Studio.status:find("bezel", 1, true) ~= nil, "the status names the target") + +check(Studio.adoptImage("a_button.png", "PNGDATA", "idle"), "idle import succeeds") +eq(ctl.imagePath, "img/a_button.png", "idle art lands on the control") +eq(Studio.page().imagePath, "img/bezel.png", "and leaves the bezel alone") + +check(Studio.adoptImage("a_down.png", "PNGDATA", "pressed"), "pressed import succeeds") +eq(ctl.pressedImagePath, "img/a_down.png", "pressed art lands on the control") +eq(ctl.imagePath, "img/a_button.png", "idle art survives") + +check(not Studio.adoptImage("notes.txt", "junk", "bezel"), "non-art is refused") +eq(Studio.page().imagePath, "img/bezel.png", "and changes nothing") + +-- the imported files are listed for the cycle buttons +local listed = {} +for _, rel in ipairs(Studio.images or {}) do listed[rel] = true end +check(listed["img/bezel.png"], "the imported bezel joins the image list") + +-- -------------------------------------------------------- picker plumbing + +local realOpen, realRead = FilePicker.open, FilePicker.read +local asked +FilePicker.open = function(prompt, kind) + asked = { prompt = prompt, kind = kind } + return "/tmp/some folder/frame.png" +end +FilePicker.read = function() return "PNGDATA" end + +session() +Studio.importImageFile("bezel") +check(asked ~= nil, "the bezel button opens a picker") +eq(asked.kind, FilePicker.IMAGE, "and asks for images, not archives") +check(asked.prompt:lower():find("bezel", 1, true) ~= nil, "with a bezel prompt") +eq(Studio.page().imagePath, "img/frame.png", "the pick becomes the bezel") +eq(Studio.imageTarget, "bezel", "and the target sticks for the cycle button") + +-- no control selected: an art import says so instead of writing to the page +session() +asked = nil +Studio.importImageFile("idle") +check(asked == nil, "no picker opens without a control to paint") +eq(Studio.page().imagePath, nil, "and the bezel is not overwritten") +check(Studio.status ~= nil, "the studio explains why") + +-- a cancelled dialog leaves the skin exactly as it was +session() +FilePicker.open = function() return nil end +Studio.importImageFile("bezel") +eq(Studio.page().imagePath, nil, "cancelling imports nothing") +check(not Studio.dirty, "and does not dirty the skin") + +FilePicker.open, FilePicker.read = realOpen, realRead + +-- --------------------------------------------------------- focus / alt-tab + +session() +Studio.drag = { kind = "control-move", mx = 1, my = 1, bx = 0, by = 0, bw = 1, bh = 1 } +Studio.clicked = true +Studio.focus(false) +eq(Studio.drag, nil, "losing focus drops the in-flight drag") +eq(Studio.clicked, false, "and the queued click") +check(pcall(Studio.visible, false), "minimizing is survivable too") + +-- main.lua used to hand focus / visibility / pad events straight to Game while +-- the studio owned the window, and the launcher has no Game -- alt-tab took the +-- app down with "attempt to index a nil value (global 'Game')". +local f = assert(io.open("main.lua", "r")) +local src = f:read("*a") +f:close() + +local checked = 0 +for name, body in ("\n" .. src):gmatch("\nfunction love%.([%w_]+)%b()\n(.-)\nend\n") do + if body:find("Game:") then + checked = checked + 1 + check(body:find("Studio") ~= nil, + "love." .. name .. " checks Studio before dispatching to Game") + check(body:find("not Game then") ~= nil + or body:find("if Game then") ~= nil + or body:find("Game and ") ~= nil, + "love." .. name .. " tolerates a nil Game (launcher / studio session)") + end +end +check(checked >= 10, "the handler scan actually found handlers (" .. checked .. ")") +check(src:find("Studio.focus", 1, true) ~= nil, "love.focus routes to the studio") +check(src:find("Studio.visible", 1, true) ~= nil, "so does love.visible") + +T.finish("skin_studio_image_import")