Files
gen1recomp/src/ui/SkinStudio.lua
T

2968 lines
98 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
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 SafeArea = require("src.core.SafeArea")
local GamepadMap = require("src.core.GamepadMap")
local PadCursor = require("src.ui.PadCursor")
local Studio = {}
Studio.CANVASES = {
{ id = "phone_portrait", label = "Phone portrait", w = 1080, h = 1920 },
{ id = "phone_landscape", label = "Phone landscape", w = 1920, h = 1080 },
{ id = "this_device", label = "This screen", live = true },
{ id = "tablet_portrait", label = "Tablet portrait", w = 1536, h = 2048 },
{ id = "tablet_landscape", label = "Tablet landscape", w = 2048, h = 1536 },
{ id = "steamdeck", label = "Steam Deck", w = 1280, h = 800 },
{ id = "desktop_1080", label = "Desktop 1080p", w = 1920, h = 1080 },
{ id = "ultrawide", label = "Ultrawide 21:9", w = 2560, h = 1080 },
{ id = "sgb_border", label = "Super Game Boy border", w = 256, h = 224,
lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } },
}
-- Editor view zoom: 1 is contain-fit. Smaller values shrink the mock
-- device inside the workspace so the screen hole can be dragged past the
-- bezel and you can still grab the handles.
Studio.ZOOM_LEVELS = { 1, 0.75, 0.5, 0.35 }
Studio.viewZoom = 1
-- Hydrated live canvas; canvas() is called many times a frame so this is
-- mutated in place instead of allocating a new table.
local liveCanvas = { id = "this_device", label = "This screen", live = true,
w = 1080, h = 1920 }
-- Per-page lock, and whether the canvas preset follows it. Match is on
-- by default so a portrait/landscape overlay pair does not need two
-- separate clicks to preview the right way up. #1503
Studio.matchOrient = true
Studio.ORIENT_CYCLE = { "any", "portrait", "landscape" }
Studio.ORIENT_LABEL = {
any = "Lock: Off",
portrait = "Lock: Portrait",
landscape = "Lock: Landscape",
}
local HANDLE = 7
local HANDLES = {
{ "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 },
{ "w", 0, 0.5 }, { "e", 1, 0.5 },
{ "sw", 0, 1 }, { "s", 0.5, 1 }, { "se", 1, 1 },
}
local GB_ASPECT = 160 / 144
Studio.UNDO_CAP = 50
Studio.SNAP_PX = 4
Studio.STATUS_HOLD = 4
Studio.FORMAT_LABEL = {
native = "gen1recomp",
retroarch = "RetroArch",
delta = "Delta",
}
Studio.EXPORTS = {
{ id = "native", label = "gen1recomp .zip",
hint = "Reopens here and in the launcher." },
{ id = "retroarch", label = "RetroArch .zip",
hint = "An overlay .cfg RetroArch can load." },
{ id = "delta", label = "Delta .deltaskin",
hint = "info.json plus art, for Delta and Ignited." },
}
Studio.BIND_GROUPS = {
{ title = "GAME BOY",
specs = { "a", "b", "start", "select", "up", "down", "left", "right" } },
{ title = "DIAGONALS",
specs = { "left|up", "right|up", "left|down", "right|down" } },
{ title = "HOTKEYS",
specs = { "menu_toggle", "reset", "hold_fast_forward",
"toggle_fast_forward", "overlay_next", "overlay_previous",
"pause_toggle", "screenshot", "exit_emulator" } },
{ title = "KEYBOARD",
specs = { "key:escape", "key:return", "key:space", "key:tab", "key:f1" } },
-- These are deliberately keyboard binds rather than new skin-only actions.
-- They therefore take the exact same path as their desktop counterparts in
-- Game:keypressed, including any future changes to those shortcuts.
{ title = "DESKTOP HOTKEYS",
specs = { "key:-", "key:=", "key:1", "key:2", "key:3", "key:4",
"key:5", "key:f1", "key:f2", "key:f10" } },
{ title = "NO INPUT", specs = { "nul" } },
}
Studio.BIND_PARTS = { "left", "right", "up", "down", "a", "b", "start", "select" }
local PART_RANK = {
left = 1, right = 2, up = 3, down = 4,
a = 5, b = 6, start = 7, select = 8,
}
local function now()
if love and love.timer and love.timer.getTime then return love.timer.getTime() end
return 0
end
local function clamp01(v)
if v ~= v then return 0 end
if v < 0 then return 0 end
if v > 1 then return 1 end
return v
end
local function round(v) return math.floor(v + 0.5) end
function Studio.deviceSize()
if love and love.graphics and love.graphics.getDimensions then
local w, h = love.graphics.getDimensions()
w, h = tonumber(w), tonumber(h)
if w and h and w > 1 and h > 1 then return w, h end
end
return 1080, 1920
end
function Studio.canvasIndexById(id)
for i, c in ipairs(Studio.CANVASES) do
if c.id == id then return i end
end
return nil
end
function Studio.canvas()
local spec = Studio.CANVASES[Studio.canvasIndex] or Studio.CANVASES[1]
if spec.live then
local w, h = Studio.deviceSize()
liveCanvas.id = spec.id
liveCanvas.label = spec.label
liveCanvas.w, liveCanvas.h = w, h
liveCanvas.live = true
liveCanvas.lockViewport = spec.lockViewport
return liveCanvas
end
return spec
end
function Studio.zoomIndex()
local z = Studio.viewZoom or 1
local best, dist = 1, math.huge
for i, v in ipairs(Studio.ZOOM_LEVELS) do
local d = math.abs(v - z)
if d < dist then best, dist = i, d end
end
return best
end
function Studio.zoomOut()
local i = Studio.zoomIndex()
if i < #Studio.ZOOM_LEVELS then
Studio.viewZoom = Studio.ZOOM_LEVELS[i + 1]
end
return Studio.viewZoom
end
function Studio.zoomIn()
local i = Studio.zoomIndex()
if i > 1 then
Studio.viewZoom = Studio.ZOOM_LEVELS[i - 1]
end
return Studio.viewZoom
end
function Studio.zoomFit()
Studio.viewZoom = Studio.ZOOM_LEVELS[1]
return Studio.viewZoom
end
function Studio.detectDeviceCanvas()
local idx = Studio.canvasIndexById("this_device")
if not idx then return nil end
Studio.setCanvas(idx)
local canvas = Studio.canvas()
Studio.setStatus(("This screen: %dx%d"):format(canvas.w, canvas.h))
return canvas
end
-- Usable chrome rect. Background still fills the window so the notch
-- band is the same colour as the rest of the studio; every button and
-- the mock device live inside the safe area, matching the launcher.
function Studio.safeFrame()
local W, H = 0, 0
if love and love.graphics and love.graphics.getDimensions then
W, H = love.graphics.getDimensions()
end
W, H = math.max(1, tonumber(W) or 1), math.max(1, tonumber(H) or 1)
local ox, oy, sw, sh = SafeArea.rect()
ox = math.max(0, tonumber(ox) or 0)
oy = math.max(0, tonumber(oy) or 0)
sw = math.max(1, tonumber(sw) or W)
sh = math.max(1, tonumber(sh) or H)
Studio._frame = { W = W, H = H, x = ox, y = oy, w = sw, h = sh }
return W, H, ox, oy, sw, sh
end
function Studio.page()
local skin = Studio.skin
if not skin then return nil end
return skin.pages[Studio.pageIndex] or skin.pages[1]
end
function Studio.selectedControl()
local page = Studio.page()
if not page then return nil end
return page.controls[Studio.selected]
end
local function setStatus(text, isError)
Studio.status = text
Studio.statusErr = isError == true
Studio.statusAt = now()
end
Studio.setStatus = setStatus
function Studio.expireStatus()
if not Studio.statusErr or not Studio.status then return end
if now() - (Studio.statusAt or 0) > Studio.STATUS_HOLD then
Studio.status, Studio.statusErr = nil, false
end
end
local function markDirty()
Studio.dirty = true
if not Studio.statusErr then Studio.status = nil end
end
local function snapshot()
return {
skin = TouchSkin.clone(Studio.skin),
pageIndex = Studio.pageIndex,
selected = Studio.selected,
idField = Studio.skinIdField,
}
end
local function restore(snap)
Studio.skin = snap.skin
Studio.pageIndex = snap.pageIndex or 1
Studio.selected = snap.selected
Studio.skinIdField = snap.idField or Studio.skinIdField
TouchSkin.setActive(Studio.skin)
TouchSkin.pageIndex = Studio.pageIndex
Studio.dirty = true
end
function Studio.pushUndo(tag)
if not Studio.skin then return false end
Studio.undoStack = Studio.undoStack or {}
Studio.redoStack = {}
if tag and Studio.undoTag == tag and now() - (Studio.undoAt or 0) < 1 then
return false
end
Studio.undoTag, Studio.undoAt = tag, now()
local stack = Studio.undoStack
stack[#stack + 1] = snapshot()
while #stack > Studio.UNDO_CAP do table.remove(stack, 1) end
return true
end
function Studio.undo()
local stack = Studio.undoStack or {}
local snap = stack[#stack]
if not snap then
setStatus("Nothing to undo.")
return false
end
table.remove(stack)
Studio.redoStack = Studio.redoStack or {}
Studio.redoStack[#Studio.redoStack + 1] = snapshot()
Studio.undoTag = nil
restore(snap)
setStatus("Undo")
return true
end
function Studio.redo()
local stack = Studio.redoStack or {}
local snap = stack[#stack]
if not snap then
setStatus("Nothing to redo.")
return false
end
table.remove(stack)
Studio.undoStack = Studio.undoStack or {}
Studio.undoStack[#Studio.undoStack + 1] = snapshot()
Studio.undoTag = nil
restore(snap)
setStatus("Redo")
return true
end
function Studio.canUndo() return #(Studio.undoStack or {}) > 0 end
function Studio.canRedo() return #(Studio.redoStack or {}) > 0 end
function Studio.openModal(kind, data)
data = data or {}
data.kind = kind
data.scroll = 0
Studio.modal = data
return data
end
function Studio.closeModal()
Studio.modal = nil
Kit.blur()
end
function Studio.modalUp()
return Studio.modal ~= nil or Studio.confirm ~= nil
end
function Studio.ask(text, onYes, yesLabel)
Studio.confirm = { text = text, onYes = onYes,
yesLabel = yesLabel or "Discard" }
return Studio.confirm
end
function Studio.confirmYes()
local c = Studio.confirm
Studio.confirm = nil
if c and c.onYes then c.onYes() end
return c ~= nil
end
function Studio.confirmNo()
local had = Studio.confirm ~= nil
Studio.confirm = nil
return had
end
function Studio.guard(text, fn)
if not Studio.dirty or not Studio.skin then
fn()
return true
end
Studio.ask(text, fn)
return false
end
local function syncActive()
TouchSkin.setActive(Studio.skin)
TouchSkin.pageIndex = Studio.pageIndex
end
local function canvasOrientation(canvas)
canvas = canvas or Studio.canvas()
if not canvas then return nil end
local w, h = canvas.w, canvas.h
if canvas.live and (not w or not h) then
w, h = Studio.deviceSize()
end
if not w or not h then return nil end
return w > h and "landscape" or "portrait"
end
local function pickCanvasIndex(want)
local cur = Studio.canvas()
-- A live "this screen" canvas already is the window; keep it so Match
-- canvas cannot yank a phone author back onto a generic 16:9 preset.
if cur and cur.live then return Studio.canvasIndex end
if canvasOrientation(cur) == want then return Studio.canvasIndex end
if cur and cur.id then
local hint = cur.id:gsub("portrait", want):gsub("landscape", want)
for i, c in ipairs(Studio.CANVASES) do
if c.id == hint then return i end
end
end
for i, c in ipairs(Studio.CANVASES) do
if not c.live and not c.lockViewport
and canvasOrientation(c) == want then
return i
end
end
return nil
end
function Studio.applyImportedOrient()
if not Studio.skin then return false end
if not TouchSkin.hasOrientPair(Studio.skin) then
Studio.syncCanvasToPage()
return false
end
-- A RetroArch overlay that already auto-rotates should do the same in
-- the studio: lock is on, canvas follows, and the visible page matches
-- the mock device. #1503
Studio.matchOrient = true
Studio.syncPageToCanvas()
Studio.syncCanvasToPage()
return true
end
function Studio.syncCanvasToPage()
if not Studio.matchOrient then return end
local want = TouchSkin.pageOrient(Studio.page())
if not want then return end
local idx = pickCanvasIndex(want)
if idx and idx ~= Studio.canvasIndex then Studio.setCanvas(idx, true) end
end
function Studio.syncPageToCanvas()
if not Studio.matchOrient or not Studio.skin then return end
local want = canvasOrientation()
if TouchSkin.pageOrient(Studio.page()) == want then return end
for i, page in ipairs(Studio.skin.pages or {}) do
if TouchSkin.pageOrient(page) == want then
Studio.pageIndex = i
Studio.selected = nil
syncActive()
return
end
end
end
function Studio.cyclePageOrient(dir)
local page = Studio.page()
if not page then return end
local cur = TouchSkin.pageOrient(page) or "any"
local idx = 1
for i, o in ipairs(Studio.ORIENT_CYCLE) do
if o == cur then idx = i break end
end
local n = #Studio.ORIENT_CYCLE
local nxt = Studio.ORIENT_CYCLE[((idx - 1 + (dir or 1)) % n) + 1]
page.orient = nxt
local name = tostring(page.name or "")
if (nxt == "portrait" or nxt == "landscape")
and (name == "" or name == "main" or name:match("^page%d+$")) then
page.name = nxt
end
markDirty()
Studio.syncCanvasToPage()
return nxt
end
function Studio.setCanvas(index, fromSync)
local n = #Studio.CANVASES
Studio.canvasIndex = ((index - 1) % n) + 1
-- Pick the matching page before writing canvas-owned fields onto it,
-- so a landscape preset does not stamp a portrait page. #1503
if not fromSync then Studio.syncPageToCanvas() end
local canvas = Studio.canvas()
local page = Studio.page()
if page and canvas.lockViewport then
page.viewport = {
x = canvas.lockViewport.x, y = canvas.lockViewport.y,
w = canvas.lockViewport.w, h = canvas.lockViewport.h,
}
page.viewportFill = false
markDirty()
end
-- A cfg-authored aspect_ratio is the overlay's design aspect, and bezel art
-- is its own design canvas (TouchSkin.applyImageAspect); keep either so the
-- preview lays the page out exactly the way the game does (#1503).
if page and not (page.aspectFromCfg or page.aspectFromImage) then
page.aspect = canvas.w / canvas.h
end
end
function Studio.load(opts)
opts = opts or {}
Studio.onClose = opts.onClose
Studio.onPlay = opts.onPlay
Studio.version = opts.version
Studio.pageIndex = 1
Studio.selected = nil
Studio.testing = false
Studio.dirty = false
Studio.status = nil
Studio.drag = nil
Studio.pendingPlay = false
Studio.canvasIndex = 1
Studio.viewZoom = 1
-- On a phone the mock device should be this screen's form factor, not a
-- generic 1080x1920 16:9, so the hole and the pad land where they will
-- at play time.
if Studio.isMobile() then
local live = Studio.canvasIndexById("this_device")
if live then Studio.canvasIndex = live end
end
-- A free-form screen is the default. 10:9 is an optional convenience,
-- never a restriction on imported or custom skins.
Studio.aspectLock = false
Studio.skinIdField = ""
Studio.available = TouchSkin.list()
Studio.availableMeta = {}
Studio.imageTarget = "idle"
Studio.undoStack, Studio.redoStack = {}, {}
Studio.undoTag, Studio.undoAt = nil, nil
Studio.modal, Studio.confirm = nil, nil
Studio.guides = nil
Studio.showLabels = true
Studio.statusErr = false
Studio.thumbs = {}
Studio.pointerX, Studio.pointerY = nil, nil
Studio.pointerDown, Studio.touchId = false, nil
PadCursor.reset()
-- Studio always opens on the library. Creating and choosing a skin are
-- first-class tasks, not controls buried inside the editor workspace.
Studio.mode = "library"
Studio.libraryThumbs = {}
Studio.libraryPage = 1
TouchControls:init()
TouchControls.active = true
TouchControls.enabled = true
TouchControls:setPreview(true)
-- Play snaps pages from the window aspect. The studio uses its own
-- Match canvas toggle against the mock device instead. #1503
TouchSkin.autoOrient = false
Studio.matchOrient = true
local start = opts.skinId
if not start then
local saved = SaveData.loadOptions()
local tc = type(saved.touchControls) == "table" and saved.touchControls or {}
start = tc.skin
end
if start and TouchSkin.find(start) then
Studio.open(start)
else
Studio.skin = TouchSkin.newSkin("new_skin")
Studio.skinIdField = Studio.skin.id
syncActive()
end
end
function Studio.open(id)
local entry = TouchSkin.find(id)
if not entry then return false end
local loaded = TouchSkin.load(entry.root, entry.id)
if not loaded then return false end
Studio.skin = TouchSkin.clone(loaded)
Studio.skinIdField = Studio.skin.id
Studio.pageIndex = 1
Studio.selected = nil
Studio.dirty = false
Studio.images = TouchSkin.listImages(Studio.skin.root)
Studio.undoStack, Studio.redoStack = {}, {}
Studio.undoTag = nil
Studio.thumbs = {}
Studio.libraryThumbs = {}
syncActive()
Studio.applyImportedOrient()
return true
end
function Studio.newSkin()
Studio.pushUndo()
Studio.skin = TouchSkin.newSkin("new_skin")
Studio.skinIdField = "new_skin"
Studio.pageIndex, Studio.selected = 1, nil
Studio.images = {}
Studio.thumbs = {}
Studio.libraryThumbs = {}
syncActive()
markDirty()
end
function Studio.skinFormat(skin)
skin = skin or Studio.skin
local fmt = skin and skin.format or "native"
return Studio.FORMAT_LABEL[fmt] or fmt
end
function Studio.describeSkin(entry)
Studio.availableMeta = Studio.availableMeta or {}
local meta = Studio.availableMeta[entry.id]
if meta then return meta end
local skin = TouchSkin.load(entry.root, entry.id)
local page = skin and skin.pages[1]
local controls = 0
for _, ctl in ipairs(page and page.controls or {}) do
if not ctl.decorative then controls = controls + 1 end
end
meta = {
id = entry.id,
source = entry.source,
format = Studio.skinFormat(skin),
pages = skin and #skin.pages or 0,
controls = controls,
ok = skin ~= nil,
}
Studio.availableMeta[entry.id] = meta
return meta
end
function Studio.skinSummary(entry)
local meta = Studio.describeSkin(entry)
local bits = { meta.source == "user" and "installed" or "bundled", meta.format }
bits[#bits + 1] = meta.controls .. " buttons"
if meta.pages > 1 then bits[#bits + 1] = meta.pages .. " pages" end
return table.concat(bits, " \194\183 ")
end
function Studio.refreshAvailable()
Studio.available = TouchSkin.list()
Studio.availableMeta = {}
Studio.libraryThumbs = {}
return Studio.available
end
function Studio.enterEditor()
Studio.mode = "editor"
Studio.selected = nil
Studio.modal, Studio.confirm = nil, nil
Studio.drag, Studio.guides = nil, nil
end
function Studio.backToLibrary()
Studio.guard("Return to My Skins and lose the unsaved changes?", function()
Studio.refreshAvailable()
Studio.mode = "library"
Studio.selected = nil
Studio.modal, Studio.confirm = nil, nil
Studio.drag, Studio.guides = nil, nil
end)
end
function Studio.libraryThumb(entry)
if not entry then return nil end
Studio.libraryThumbs = Studio.libraryThumbs or {}
local cached = Studio.libraryThumbs[entry.id]
if cached ~= nil then return cached or nil end
local skin = TouchSkin.load(entry.root, entry.id)
local image = skin and skin.pages and skin.pages[1] and skin.pages[1].image
Studio.libraryThumbs[entry.id] = image or false
return image
end
function Studio.exportEntry(id)
local entry = TouchSkin.find(id)
local skin = entry and TouchSkin.load(entry.root, entry.id)
if not skin then
setStatus("Could not read " .. tostring(id), true)
return nil
end
local path, missing = TouchSkin.export(skin)
if not path then
setStatus("Export failed: " .. tostring(missing), true)
return nil
end
Studio.lastExport = path
setStatus("Exported " .. path)
return path
end
function Studio.selectEntry(id)
local entry = TouchSkin.find(id)
if not entry then
setStatus("Could not find " .. tostring(id), true)
return false
end
local opts = SaveData.loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled, tc.skin = true, id
opts.touchControls = tc
SaveData.saveOptions(opts)
TouchControls:applyOptions(opts)
setStatus("Using " .. id)
return true
end
function Studio.deleteEntry(id)
local entry = TouchSkin.find(id)
if not entry then return false end
if entry.source ~= "user" then
setStatus("Bundled skins cannot be deleted.", true)
return false
end
Studio.ask("Delete " .. tostring(id) .. "? This removes its artwork and cannot be undone.",
function()
local ok, err = TouchSkin.remove(id)
if not ok then
setStatus("Delete failed: " .. tostring(err), true)
return
end
local opts = SaveData.loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
if tc.skin == id then
tc.enabled, tc.skin = true, nil
opts.touchControls = tc
SaveData.saveOptions(opts)
TouchControls:applyOptions(opts)
end
Studio.refreshAvailable()
setStatus("Deleted " .. id)
end, "Delete")
return true
end
function Studio.importSkinFile()
if not FilePicker.available() then
setStatus("On mobile, use Import in the Skins tab to add a downloaded skin.", true)
return false
end
local kind = { label = "Skin", exts = { "zip", "deltaskin", "cfg" } }
local path = FilePicker.open("Choose a skin", kind)
if not path then return false end
local name, data = FilePicker.basename(path), FilePicker.read(path)
if not data then
setStatus("Could not read " .. name, true)
return false
end
local id, note = TouchSkin.installArchive(name, data)
if not id then
setStatus("Import failed: " .. tostring(note), true)
return false
end
Studio.refreshAvailable()
Studio.open(id)
Studio.enterEditor()
setStatus("Imported " .. id)
return true
end
function Studio.openLoadPicker()
return Studio.guard("Open another skin and lose the unsaved changes?",
function()
Studio.refreshAvailable()
Studio.openModal("open")
end)
end
function Studio.loadEntry(id)
Studio.closeModal()
if not Studio.open(id) then
setStatus("Could not open " .. tostring(id), true)
return false
end
local warning = Studio.skin and Studio.skin.warnings
and Studio.skin.warnings[1]
if warning then
setStatus("Opened " .. id .. ": " .. tostring(warning), true)
else
setStatus("Opened " .. id)
end
return true
end
function Studio.unload()
Studio.pendingPlay = false
TouchSkin.setSurface(nil)
TouchSkin.setActive(nil)
TouchSkin.autoOrient = true
TouchControls:setPreview(false)
TouchControls:reset()
Studio.skin = nil
Studio.onClose = nil
Studio.onPlay = nil
Studio.drag = nil
Studio.modal, Studio.confirm = nil, nil
Studio.guides = nil
Studio.undoStack, Studio.redoStack = {}, {}
Studio.pointerX, Studio.pointerY = nil, nil
Studio.pointerDown, Studio.touchId = false, nil
PadCursor.reset()
end
-- The Studio is a real touch editor on Android and iOS. Keeping this here,
-- instead of asking the host to synthesize a mouse, makes the drag state and
-- the immediate-mode hit tests agree on the same finger position.
function Studio.isMobile()
local osName = love.system and love.system.getOS and love.system.getOS()
return osName == "Android" or osName == "iOS"
end
function Studio.disableTouchControls()
local opts = SaveData.loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled, tc.skin = false, nil
opts.touchControls = tc
SaveData.saveOptions(opts)
TouchControls:applyOptions(opts)
TouchSkin.setActive(nil)
Studio.closeModal()
setStatus("On-screen controls are off.")
return true
end
-- --------------------------------------------------------------- editing
function Studio.addControl()
local page = Studio.page()
if not page then return end
Studio.pushUndo()
page.controls[#page.controls + 1] =
TouchSkin.newControl("a", 0.5, 0.5, 0.16, 0.09, "radial")
Studio.selected = #page.controls
markDirty()
end
function Studio.deleteControl()
local page = Studio.page()
if not page or not Studio.selected then return end
Studio.pushUndo()
table.remove(page.controls, Studio.selected)
Studio.selected = page.controls[Studio.selected] and Studio.selected
or (#page.controls > 0 and #page.controls or nil)
markDirty()
end
function Studio.duplicateControl()
local page, ctl = Studio.page(), Studio.selectedControl()
if not page or not ctl then return end
Studio.pushUndo()
local copy = TouchSkin.newControl(ctl.spec, clamp01(ctl.x + 0.04),
clamp01(ctl.y + 0.04), ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape)
copy.imagePath, copy.image = ctl.imagePath, ctl.image
copy.pressedImagePath, copy.pressedImage = ctl.pressedImagePath, ctl.pressedImage
copy.rangeMod, copy.alphaMod = ctl.rangeMod, ctl.alphaMod
table.insert(page.controls, Studio.selected + 1, copy)
Studio.selected = Studio.selected + 1
markDirty()
end
function Studio.cycleBind(dir)
local ctl = Studio.selectedControl()
if not ctl then return end
local list, at = TouchSkin.BINDS, 1
for i, spec in ipairs(list) do
if spec == ctl.spec then at = i break end
end
Studio.pushUndo("bind")
TouchSkin.setBind(ctl, list[((at - 1 + dir) % #list) + 1])
markDirty()
end
function Studio.bindParts(spec)
local out = {}
for raw in tostring(spec or ""):gmatch("[^|]+") do
local name = raw:gsub("^%s+", ""):gsub("%s+$", ""):lower()
if name ~= "" and name ~= "nul" then out[#out + 1] = name end
end
return out
end
function Studio.hasBindPart(spec, part)
for _, name in ipairs(Studio.bindParts(spec)) do
if name == part then return true end
end
return false
end
function Studio.toggleBindPart(spec, part)
local kept, found = {}, false
for _, name in ipairs(Studio.bindParts(spec)) do
if name == part then
found = true
else
kept[#kept + 1] = name
end
end
if not found then kept[#kept + 1] = part end
if #kept == 0 then return "nul" end
table.sort(kept, function(a, b)
local ra, rb = PART_RANK[a] or 9, PART_RANK[b] or 9
if ra ~= rb then return ra < rb end
return a < b
end)
return table.concat(kept, "|")
end
function Studio.setBindSpec(spec)
local ctl = Studio.selectedControl()
if not ctl or not spec then return false end
Studio.pushUndo()
TouchSkin.setBind(ctl, spec)
markDirty()
return true
end
function Studio.toggleSelectedBindPart(part)
local ctl = Studio.selectedControl()
if not ctl then return false end
return Studio.setBindSpec(Studio.toggleBindPart(ctl.spec, part))
end
function Studio.openBindPicker()
if not Studio.selectedControl() then
setStatus("Select a control first.", true)
return false
end
Studio.openModal("bind")
return true
end
function Studio.moveControlOrder(delta)
local page, i = Studio.page(), Studio.selected
if not page or not i then return false end
local j = i + delta
if j < 1 or j > #page.controls then return false end
Studio.pushUndo()
page.controls[i], page.controls[j] = page.controls[j], page.controls[i]
Studio.selected = j
markDirty()
return true
end
function Studio.nudge(dx, dy, big)
local ctl = Studio.selectedControl()
if not ctl then return false end
local canvas = Studio.canvas()
local step = big and 10 or 1
Studio.pushUndo("nudge")
ctl.x = clamp01(ctl.x + dx * step / canvas.w)
ctl.y = clamp01(ctl.y + dy * step / canvas.h)
markDirty()
return true
end
function Studio.snapOffset(edges, lines, tol)
local best, at = nil, nil
for _, e in ipairs(edges) do
for _, l in ipairs(lines) do
local d = l - e
if math.abs(d) <= tol and (best == nil or math.abs(d) < math.abs(best)) then
best, at = d, l
end
end
end
return best or 0, at
end
function Studio.cycleImage(dir)
local ctl = Studio.selectedControl()
local page = Studio.page()
if not page then return end
Studio.images = Studio.images or TouchSkin.listImages(Studio.skin.root)
local list = Studio.images
local field = Studio.imageTarget
local owner = (field == "bezel") and page or ctl
if not owner then return end
local key = (field == "pressed") and "pressedImagePath" or "imagePath"
if field == "bezel" then key = "imagePath" end
local at = 0
for i, rel in ipairs(list) do
if rel == owner[key] then at = i break end
end
local next_ = at + dir
if next_ < 0 then next_ = #list end
if next_ > #list then next_ = 0 end
local rel = (next_ >= 1) and list[next_] or nil
owner[key] = rel
local img = rel and TouchSkin.resolveImage(Studio.skin.root, rel) or nil
if field == "pressed" then
owner.pressedImage = img
else
owner.image = img
if field == "bezel" then TouchSkin.applyImageAspect(owner) end
end
markDirty()
end
function Studio.refreshImages()
Studio.images = Studio.skin and TouchSkin.listImages(Studio.skin.root) or {}
Studio.thumbs = {}
return Studio.images
end
function Studio.openImagePicker(target)
if not Studio.skin then return false end
target = target or Studio.imageTarget
if target ~= "bezel" and not Studio.selectedControl() then
setStatus("Select a control first, or pick a bezel image.", true)
return false
end
Studio.imageTarget = target
Studio.refreshImages()
Studio.openModal("image")
return true
end
function Studio.currentImagePath()
local target = Studio.imageTarget
if target == "bezel" then
local page = Studio.page()
return page and page.imagePath
end
local ctl = Studio.selectedControl()
if not ctl then return nil end
return (target == "pressed") and ctl.pressedImagePath or ctl.imagePath
end
function Studio.thumb(rel)
if not rel or not Studio.skin then return nil end
Studio.thumbs = Studio.thumbs or {}
local cached = Studio.thumbs[rel]
if cached ~= nil then return cached or nil end
local img = TouchSkin.resolveImage(Studio.skin.root, rel)
Studio.thumbs[rel] = img or false
return img
end
function Studio.chooseImage(rel)
if not Studio.skin then return false end
Studio.pushUndo()
Studio.assignImage(rel)
Studio.closeModal()
setStatus(rel and ("Using " .. rel .. " as " .. Studio.imageTargetLabel())
or ("Cleared the " .. Studio.imageTargetLabel()))
return true
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)
local target = Studio.imageTarget
if ctl and target == "pressed" then
ctl.pressedImagePath, ctl.pressedImage = rel, img
elseif ctl and target == "idle" then
ctl.imagePath, ctl.image = rel, img
elseif page then
page.imagePath, page.image = rel, img
TouchSkin.applyImageAspect(page)
end
Studio.images = TouchSkin.listImages(Studio.skin.root)
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
setStatus("Pick a PNG or JPG.", true)
return false
end
commitSkinId()
local rel, err = TouchSkin.importImage(Studio.skin, name, data)
if not rel then
setStatus("Import failed: " .. tostring(err), true)
return false
end
local where = Studio.imageTargetLabel()
Studio.pushUndo()
Studio.assignImage(rel)
Studio.skinIdField = Studio.skin.id
local text = "Imported " .. rel .. " as " .. where
if where == "bezel" and not Studio.canvas().lockViewport then
text = text .. " -- use Detect screen from bezel to place the screen"
end
setStatus(text)
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
setStatus("Select a control first, or import a bezel image.", true)
return
end
if not FilePicker.available() then
setStatus("No file picker here -- drag a PNG onto the window instead.", true)
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
setStatus("Could not read " .. base .. ": " .. tostring(err), true)
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 = FilePicker.basename(path)
if not FilePicker.matches(base, FilePicker.IMAGE) then
setStatus("Drop a PNG or JPG to use it as art.", true)
return
end
local ok, data = pcall(function()
file:open("r")
local bytes = file:read()
file:close()
return bytes
end)
if not ok or not data then
setStatus("Could not read " .. base, true)
return
end
Studio.adoptImage(base, data)
end
function Studio.detectViewport()
local page = Studio.page()
if not page or not Studio.skin then return end
if Studio.canvas().lockViewport then
setStatus("This preset locks the screen position.", true)
return
end
if not page.imagePath then
setStatus("Pick a bezel image first.", true)
return
end
local rect, pw, ph = TouchSkin.detectViewport(Studio.skin.root, page.imagePath)
if not rect then
setStatus("No transparent screen hole found in " .. page.imagePath, true)
return
end
Studio.pushUndo()
page.viewport = rect
page.screenFit = nil
setStatus(("Screen detected: %dx%d px in the bezel art"):format(pw, ph))
Studio.dirty = true
end
function Studio.toggleViewport()
local page = Studio.page()
if not page then return end
if Studio.canvas().lockViewport then return end
Studio.pushUndo()
if page.viewport or page.screenFit == "remainder" then
page.viewport = nil
page.screenFit = nil
else
page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 }
end
markDirty()
end
function Studio.setPage(index)
local skin = Studio.skin
if not skin or not skin.pages[index] then return false end
Studio.pageIndex = index
Studio.selected = nil
syncActive()
Studio.syncCanvasToPage()
return true
end
function Studio.nextPage()
local skin = Studio.skin
if not skin or #skin.pages == 0 then return false end
return Studio.setPage((Studio.pageIndex % #skin.pages) + 1)
end
function Studio.pageLabel(index)
local skin = Studio.skin
local page = skin and skin.pages[index]
if not page then return "" end
local name = tostring(page.name or ("page" .. index))
local orient = TouchSkin.pageOrient(page)
local bits = { #(page.controls or {}) .. " controls" }
if orient then bits[#bits + 1] = orient end
if page.viewport or page.screenFit == "remainder" then bits[#bits + 1] = "screen" end
return name, table.concat(bits, " \194\183 ")
end
function Studio.openPageMenu()
local skin = Studio.skin
if not skin then return false end
Studio.pageNameField = tostring(Studio.page() and Studio.page().name or "")
Studio.openModal("page")
return true
end
function Studio.openScreenMenu()
Studio.openModal("screen")
return true
end
function Studio.renamePage(name)
local page = Studio.page()
if not page then return false end
name = tostring(name or ""):gsub("^%s+", ""):gsub("%s+$", "")
if name == "" then
setStatus("Give the page a name first.", true)
return false
end
Studio.pushUndo()
page.name = name
markDirty()
setStatus("Renamed page " .. Studio.pageIndex .. " to " .. name)
return true
end
function Studio.deletePage(index)
local skin = Studio.skin
index = index or Studio.pageIndex
if not skin or not skin.pages[index] then return false end
if #skin.pages <= 1 then
setStatus("A skin needs at least one page.", true)
return false
end
Studio.pushUndo()
table.remove(skin.pages, index)
for i, page in ipairs(skin.pages) do page.index = i end
Studio.pageIndex = math.min(Studio.pageIndex, #skin.pages)
Studio.selected = nil
syncActive()
markDirty()
setStatus("Deleted a page")
return true
end
function Studio.addPage()
local skin = Studio.skin
if not skin then return end
Studio.pushUndo()
local page = TouchSkin.newSkin(skin.id).pages[1]
page.name = "page" .. (#skin.pages + 1)
page.index = #skin.pages + 1
skin.pages[#skin.pages + 1] = page
Studio.pageIndex = #skin.pages
Studio.selected = nil
syncActive()
Studio.syncCanvasToPage()
markDirty()
end
function Studio.save()
local skin = Studio.skin
if not skin then return end
local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "")
if id == "" then
setStatus("Give the skin a name first.", true)
return
end
local dest, failed = TouchSkin.saveTo(skin, id)
if not dest then
setStatus("Save failed: " .. tostring(failed), true)
return
end
Studio.dirty = false
Studio.refreshAvailable()
local text = "Saved to " .. dest
if type(failed) == "table" and failed[1] then
text = text .. " (" .. #failed .. " image(s) not found)"
end
setStatus(text)
end
function Studio.exportAs(kind)
local skin = Studio.skin
if not skin then return nil end
if Studio.dirty then Studio.save() end
if Studio.dirty then return nil end
local path, missing, warnings
if kind == "retroarch" then
path, missing = TouchSkin.exportRetroArch(skin)
elseif kind == "delta" then
path, missing, warnings = TouchSkin.exportDelta(skin)
else
path, missing = TouchSkin.export(skin)
end
if not path then
setStatus("Export failed: " .. tostring(missing), true)
return nil
end
local text = "Exported " .. path
if type(missing) == "table" and missing[1] then
text = text .. " (" .. #missing .. " image(s) not found)"
end
if type(warnings) == "table" and warnings[1] then
text = text .. " " .. tostring(warnings[1])
end
setStatus(text)
Studio.lastExport = path
Studio.refreshAvailable()
return path
end
function Studio.export()
return Studio.exportAs("native")
end
function Studio.openExportMenu()
Studio.openModal("export")
return true
end
function Studio.fileUrl(path)
path = tostring(path):gsub("\\", "/")
if path:sub(1, 1) ~= "/" then path = "/" .. path end
local encoded = path:gsub("[^%w%-%._~/:]", function(c)
return string.format("%%%02X", string.byte(c))
end)
return "file://" .. encoded
end
function Studio.revealExport()
local path = Studio.lastExport
if not path then return false end
if not (love and love.filesystem and love.filesystem.getSaveDirectory) then
return false
end
local dir = love.filesystem.getSaveDirectory()
if not dir then return false end
if love.system and love.system.openURL then
pcall(love.system.openURL, Studio.fileUrl(dir))
end
setStatus("Saved in " .. dir .. "/" .. path)
return true
end
function Studio.play()
local skin = Studio.skin
if not skin then return end
Studio.save()
if Studio.dirty then return end
local opts = SaveData.loadOptions()
local block = type(opts.touchControls) == "table" and opts.touchControls or {}
block.enabled = true
block.skin = skin.id
opts.touchControls = block
SaveData.saveOptions(opts)
-- Handing off here would unload the studio inside its own draw pass and
-- leave the rest of this frame drawing against a dead skin; Studio.update
-- runs it on the next tick instead.
Studio.pendingPlay = true
setStatus("Starting the game with " .. skin.id .. "...")
end
-- ---------------------------------------------------------------- canvas
function Studio.canvasRect(x, y, w, h)
local canvas = Studio.canvas()
local aspect = canvas.w / canvas.h
if not aspect or aspect <= 0 then aspect = 1 end
local cw, ch = w, w / aspect
if ch > h then ch, cw = h, h * aspect end
local z = Studio.viewZoom or 1
if z < 0.999 then
cw, ch = cw * z, ch * z
end
return x + (w - cw) * 0.5, y + (h - ch) * 0.5, cw, ch
end
local function controlRect(page, ctl, r)
local cx, cy, halfW, halfH =
TouchSkin.controlGeometry(page, ctl, r.w, r.h, r.x, r.y)
return cx - halfW, cy - halfH, halfW * 2, halfH * 2
end
function Studio.snapLines(page, r, skipIndex)
local xs, ys = {}, {}
local px, py, pw, ph = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y)
xs[1], xs[2], xs[3] = px, px + pw * 0.5, px + pw
ys[1], ys[2], ys[3] = py, py + ph * 0.5, py + ph
for i, ctl in ipairs(page.controls or {}) do
if i ~= skipIndex then
local bx, by, bw, bh = controlRect(page, ctl, r)
xs[#xs + 1] = bx
xs[#xs + 1] = bx + bw * 0.5
xs[#xs + 1] = bx + bw
ys[#ys + 1] = by
ys[#ys + 1] = by + bh * 0.5
ys[#ys + 1] = by + bh
end
end
return xs, ys
end
local function viewportRect(page, r)
local x, y, w, h = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y)
if not x then return nil end
return x, y, w, h
end
local function handleRects(bx, by, bw, bh)
local out = {}
for _, h in ipairs(HANDLES) do
out[#out + 1] = {
id = h[1],
x = bx + bw * h[2] - HANDLE * Kit.scale,
y = by + bh * h[3] - HANDLE * Kit.scale,
w = HANDLE * 2 * Kit.scale, h = HANDLE * 2 * Kit.scale,
}
end
return out
end
local function applyResize(id, bx, by, bw, bh, dx, dy)
if id:find("w") then bx = bx + dx bw = bw - dx end
if id:find("e") then bw = bw + dx end
if id:find("n") then by = by + dy bh = bh - dy end
if id:find("s") then bh = bh + dy end
return bx, by, math.max(4, bw), math.max(4, bh)
end
function Studio.beginCanvasDrag(mx, my, r)
local page = Studio.page()
if not page then return end
Studio.guides = nil
local ctl = Studio.selectedControl()
if ctl then
local bx, by, bw, bh = controlRect(page, ctl, r)
for _, h in ipairs(handleRects(bx, by, bw, bh)) do
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
Studio.pushUndo()
Studio.drag = { kind = "control-resize", handle = h.id, mx = mx, my = my,
bx = bx, by = by, bw = bw, bh = bh }
return
end
end
end
local vx, vy, vw, vh = viewportRect(page, r)
if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then
for _, h in ipairs(handleRects(vx, vy, vw, vh)) do
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
Studio.pushUndo()
Studio.drag = { kind = "viewport-resize", handle = h.id, mx = mx, my = my,
bx = vx, by = vy, bw = vw, bh = vh }
Studio.selected = nil
return
end
end
end
for i = #page.controls, 1, -1 do
local c = page.controls[i]
local bx, by, bw, bh = controlRect(page, c, r)
if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then
Studio.selected = i
Studio.pushUndo()
Studio.drag = { kind = "control-move", mx = mx, my = my,
bx = bx, by = by, bw = bw, bh = bh }
return
end
end
if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder"
and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then
Studio.selected = nil
Studio.pushUndo()
Studio.drag = { kind = "viewport-move", mx = mx, my = my,
bx = vx, by = vy, bw = vw, bh = vh }
return
end
Studio.selected = nil
end
function Studio.updateDrag(mx, my, r)
local d = Studio.drag
local page = Studio.page()
if not d or not page then return end
local dx, dy = mx - d.mx, my - d.my
local bx, by, bw, bh = d.bx, d.by, d.bw, d.bh
if d.kind == "control-move" or d.kind == "viewport-move" then
bx, by = bx + dx, by + dy
else
bx, by, bw, bh = applyResize(d.handle, bx, by, bw, bh, dx, dy)
end
if d.kind:find("viewport") and Studio.aspectLock and d.handle then
-- Derive the axis the handle does not drive, or an edge handle fights the
-- lock and appears dead. A corner drives whichever way the pointer moved
-- furthest, so dragging it up/down resizes as readily as left/right.
local byHeight = (d.handle == "n" or d.handle == "s")
if not byHeight and #d.handle > 1 then
byHeight = math.abs(dy) > math.abs(dx)
end
if byHeight then
local newW = bh * GB_ASPECT
if d.handle:find("w") then bx = bx + (bw - newW)
elseif not d.handle:find("e") then bx = bx + (bw - newW) * 0.5 end
bw = newW
else
local newH = bw / GB_ASPECT
if d.handle:find("n") then by = by + (bh - newH)
elseif not d.handle:find("s") then by = by + (bh - newH) * 0.5 end
bh = newH
end
end
if d.kind == "control-move" then
local tol = Studio.SNAP_PX * Kit.scale
local xs, ys = Studio.snapLines(page, r, Studio.selected)
local offX, lineX = Studio.snapOffset({ bx, bx + bw * 0.5, bx + bw }, xs, tol)
local offY, lineY = Studio.snapOffset({ by, by + bh * 0.5, by + bh }, ys, tol)
bx, by = bx + offX, by + offY
Studio.guides = { x = lineX, y = lineY }
end
local px, py, pw, ph = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y)
if pw <= 0 or ph <= 0 then return end
if d.kind:find("control") then
local ctl = Studio.selectedControl()
if not ctl then return end
ctl.x = clamp01(((bx + bw * 0.5) - px) / pw)
ctl.y = clamp01(((by + bh * 0.5) - py) / ph)
ctl.rangeX = math.max(0.002, (bw * 0.5) / pw)
ctl.rangeY = math.max(0.002, (bh * 0.5) / ph)
else
-- Permit an anchor to live beyond every canvas edge. That is useful for
-- intentional off-centre compositions and mirrors the normal Screen Pos
-- behaviour at runtime; only a non-zero size is required.
page.viewport = {
x = (bx - px) / pw, y = (by - py) / ph,
w = math.max(0.02, bw / pw), h = math.max(0.02, bh / ph),
}
end
markDirty()
end
-- ----------------------------------------------------------------- draw
local function drawGameTest(r, page)
-- Test letterboxes a 160x144 picture inside the screen cutout, the same
-- way gameplay fits the Game Boy surface into the hole.
local vx, vy, vw, vh = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y)
if not vx then vx, vy, vw, vh = r.x, r.y, r.w, r.h end
local s = math.min(vw / 160, vh / 144)
local gw, gh = 160 * s, 144 * s
local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5
Theme.fill(r.x, r.y, r.w, r.h, { 8, 12, 8 }, 1)
Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1)
local tile = math.max(2, math.floor(16 * s))
for row = 0, 8 do
for col = 0, 9 do
if (row + col) % 2 == 0 then
Theme.fill(gx + col * tile, gy + row * tile, tile, tile,
{ 139, 172, 15 }, 0.45)
end
end
end
Theme.fill(gx + gw * 0.10, gy + gh * 0.15, gw * 0.22, gh * 0.18,
{ 48, 98, 48 }, 0.9)
Theme.fill(gx + gw * 0.58, gy + gh * 0.49, gw * 0.12, gh * 0.18,
{ 48, 98, 48 }, 0.9)
Theme.fill(gx + gw * 0.14, gy + gh * 0.70, gw * 0.72, gh * 0.16,
{ 224, 248, 208 }, 1)
Kit.text("small", "TEST BATTLE", gx + gw * 0.18, gy + gh * 0.74,
{ 24, 56, 24 })
end
local function drawCanvas(x, y, w, h)
Studio.canvasWorkspace = { x = x, y = y, w = w, h = h }
local page = Studio.page()
local r = { }
r.x, r.y, r.w, r.h = Studio.canvasRect(x, y, w, h)
Studio.lastCanvas = r
Theme.fill(x, y, w, h, { 10, 12, 18 }, 1)
if not page then return r end
Theme.fill(r.x, r.y, r.w, r.h, { 0, 0, 0 }, 1)
TouchSkin.setSurface(r.x, r.y, r.w, r.h)
syncActive()
local vx, vy, vw, vh = viewportRect(page, r)
if Studio.testing then
drawGameTest(r, page)
elseif vx then
local scale = math.min(vw / 160, vh / 144)
local gw, gh = 160 * scale, 144 * scale
local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5
Theme.fill(vx, vy, vw, vh, { 12, 12, 12 }, 1)
Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1)
Kit.textCenter("small", "160 x 144", gx, gy + gh * 0.5 - Kit.textHeight("small") * 0.5,
gw, { 20, 40, 20 })
end
TouchControls:draw()
TouchSkin.setSurface(nil)
Theme.strokeRounded(r.x, r.y, r.w, r.h, PAL.line, Theme.A.hairline, 1, 2)
if Studio.testing then return r end
if vx then
Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2)
Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue)
if not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then
local a = Studio.selectedControl() and 0.45 or 1
for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a)
end
end
end
for i, ctl in ipairs(page.controls) do
local bx, by, bw, bh = controlRect(page, ctl, r)
local selected = (i == Studio.selected)
local c = ctl.decorative and PAL.muted or (selected and PAL.green or PAL.line)
Theme.strokeRounded(bx, by, bw, bh, c, selected and 1 or 0.45,
selected and 2 or 1, ctl.shape == "radial" and bh * 0.5 or 2)
if Studio.showLabels and bw > 8 * Kit.scale then
Kit.textCenter("micro", Kit.ellipsize("micro", ctl.spec, bw), bx,
by + bh * 0.5 - Kit.textHeight("micro") * 0.5, bw,
selected and PAL.green or PAL.muted)
end
if selected then
for _, hd in ipairs(handleRects(bx, by, bw, bh)) do
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.green, 1)
end
Kit.text("small", ctl.spec, bx, by - Kit.textHeight("small") - 2 * Kit.scale,
PAL.green)
end
end
local guides = Studio.drag and Studio.guides
if guides then
if guides.x then
Theme.fill(guides.x, r.y, math.max(1, Kit.scale), r.h, PAL.yellow, 0.6)
end
if guides.y then
Theme.fill(r.x, guides.y, r.w, math.max(1, Kit.scale), PAL.yellow, 0.6)
end
end
return r
end
local function inspectorBody(x, y, w)
if not Studio.skin then return y end
local page = Studio.page()
local ctl = Studio.selectedControl()
local rowH = math.max(Kit.tapMin(), 30 * Kit.scale)
local gap = 6 * Kit.scale
local cy = y
Kit.caption(x, cy, "SKIN")
cy = cy + Kit.textHeight("small") + gap
Studio.skinIdField = Kit.textfield("skinid", x, cy, w, rowH,
Studio.skinIdField, "skin name")
cy = cy + rowH + gap
local third = (w - gap * 2) / 3
if Kit.button(x, cy, third, rowH, "Save", { id = "save" }) then Studio.save() end
if Kit.button(x + third + gap, cy, third, rowH, "Export \226\150\184",
{ id = "export" }) then
Studio.openExportMenu()
end
if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Play", { id = "play" }) then
Studio.play()
end
cy = cy + rowH + 2 * Kit.scale
Kit.text("small", "Format: " .. Studio.skinFormat(), x, cy, PAL.faint)
cy = cy + Kit.textHeight("small") + gap * 2
Kit.caption(x, cy, "OPEN")
cy = cy + Kit.textHeight("small") + gap
local half = (w - gap) / 2
if Kit.button(x, cy, half, rowH, "New", { id = "new" }) then
Studio.guard("Start a new skin and lose the unsaved changes?",
Studio.newSkin)
end
if Kit.button(x + half + gap, cy, half, rowH,
"Load " .. (#Studio.available > 0 and "\226\150\184" or "-"),
{ id = "load", enabled = #Studio.available > 0 }) then
Studio.openLoadPicker()
end
cy = cy + rowH + gap * 2
Kit.caption(x, cy, "PAGE " .. Studio.pageIndex .. " / " .. #(Studio.skin.pages or {}))
cy = cy + Kit.textHeight("small") + gap
if Kit.button(x, cy, half, rowH,
"Pages \226\150\184 " .. tostring(page and page.name or "-"),
{ id = "pagelist" }) then
Studio.openPageMenu()
end
if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then
Studio.addPage()
end
cy = cy + rowH + gap
local lock = TouchSkin.pageOrient(page) or "any"
if Kit.button(x, cy, half, rowH, Studio.ORIENT_LABEL[lock] or "Lock: Off",
{ id = "orient" }) then
Studio.cyclePageOrient(1)
end
local matchOn = Studio.matchOrient
Studio.matchOrient = Kit.checkbox(x + half + gap, cy, half, rowH,
Studio.matchOrient, "Match canvas", "matchorient")
if Studio.matchOrient and not matchOn then Studio.syncCanvasToPage() end
cy = cy + rowH + gap
if page then
local bezel = page.imagePath or page.rasterName or "(none)"
local pickW = 82 * Kit.scale
local cycleW = w - pickW - gap
if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then
Studio.openImagePicker("bezel")
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 or page.screenFit == "remainder")
and "Screen cutout: ON" or "Screen cutout: OFF"
if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp",
enabled = not Studio.canvas().lockViewport }) then
Studio.toggleViewport()
end
Studio.aspectLock = Kit.checkbox(x + half + gap, cy, half, rowH,
Studio.aspectLock, "10:9 lock", "aspect")
cy = cy + rowH + gap
if Kit.button(x, cy, w, rowH, "Detect screen from bezel", { id = "detect",
enabled = page.imagePath ~= nil and not Studio.canvas().lockViewport }) then
Studio.detectViewport()
end
cy = cy + rowH + gap * 2
end
Kit.caption(x, cy, "CONTROLS")
cy = cy + Kit.textHeight("small") + gap
if Kit.button(x, cy, third, rowH, "Add", { id = "add" }) then Studio.addControl() end
if Kit.button(x + third + gap, cy, third, rowH, "Dup",
{ id = "dup", enabled = ctl ~= nil }) then
Studio.duplicateControl()
end
if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Del",
{ id = "del", enabled = ctl ~= nil }) then
Studio.deleteControl()
end
cy = cy + rowH + gap
if not ctl then
Kit.textWrapped("small", page and #page.controls == 0
and "No controls yet. Add one, then drag it on the canvas."
or "Click a control on the canvas to edit it.", x, cy, w, PAL.muted, 3)
return cy + Kit.textHeight("small") * 3
end
local canvas = Studio.canvas()
local zW = 68 * Kit.scale
if Kit.button(x, cy, zW, rowH, "Back", { id = "zback",
enabled = (Studio.selected or 1) > 1 }) then
Studio.moveControlOrder(-1)
end
if Kit.button(x + zW + gap, cy, zW, rowH, "Front", { id = "zfront",
enabled = Studio.selected ~= nil and page ~= nil
and Studio.selected < #page.controls }) then
Studio.moveControlOrder(1)
end
Kit.text("small", ("%d / %d"):format(Studio.selected or 0,
page and #page.controls or 0), x + (zW + gap) * 2,
cy + (rowH - Kit.textHeight("small")) * 0.5, PAL.faint)
cy = cy + rowH + gap
if Kit.button(x, cy, w, rowH, "Bind: " .. ctl.spec, { id = "bind" }) then
Studio.openBindPicker()
end
cy = cy + rowH + 2 * Kit.scale
Kit.text("small", TouchSkin.describeBind(ctl.spec), x, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + gap
if Kit.button(x, cy, half, rowH, "Shape: " .. ctl.shape, { id = "shape" }) then
Studio.pushUndo()
ctl.shape = ctl.shape == "radial" and "rect" or "radial"
markDirty()
end
if Kit.button(x + half + gap, cy, half, rowH,
string.format("Reach x%.2f", ctl.rangeMod), { id = "rangemod" }) then
Studio.pushUndo()
ctl.rangeMod = ctl.rangeMod >= 2 and 0.5 or (ctl.rangeMod + 0.25)
markDirty()
end
cy = cy + rowH + gap
local quarter = (w - gap * 3) / 4
local fields = {
{ "X", round((ctl.x - ctl.rangeX) * canvas.w) },
{ "Y", round((ctl.y - ctl.rangeY) * canvas.h) },
{ "W", round(ctl.rangeX * 2 * canvas.w) },
{ "H", round(ctl.rangeY * 2 * canvas.h) },
}
for i, f in ipairs(fields) do
local fx = x + (quarter + gap) * (i - 1)
Kit.text("small", f[1], fx, cy, PAL.faint)
local id = "num" .. f[1]
local shown = Studio.editing == id and Studio.editBuf or tostring(f[2])
local typed = Kit.textfield(id, fx, cy + Kit.textHeight("small"),
quarter, rowH, shown, "0")
if Kit.focus == id then
Studio.editing, Studio.editBuf = id, typed
elseif Studio.editing == id then
Studio.commitField(id, Studio.editBuf)
Studio.editing, Studio.editBuf = nil, nil
end
end
cy = cy + Kit.textHeight("small") + rowH + gap
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, artW, rowH, "Idle art: " .. idle, { id = "img" }) then
Studio.openImagePicker("idle")
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, artW, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then
Studio.openImagePicker("pressed")
end
if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgppick" }) then
Studio.importImageFile("pressed")
end
return cy + rowH
end
local function drawInspector(x, y, w, h)
local pad = 10 * Kit.scale
Kit.card(x, y, w, h)
local scroll = Studio.inspectorScroll or 0
if Kit.hit(x, y, w, h) and (Kit.wheelY or 0) ~= 0 then
scroll = scroll - Kit.wheelY * 48 * Kit.scale
end
local maxScroll = math.max(0, (Studio.inspectorH or 0) - (h - pad * 2))
scroll = math.max(0, math.min(scroll, maxScroll))
Studio.inspectorScroll = scroll
Kit.pushClip(x, y, w, h)
local top = y + pad - scroll
local endY = inspectorBody(x + pad, top, w - pad * 2) or top
Studio.inspectorH = endY - top
Kit.popClip()
if maxScroll > 0 then
local frac = h / (h + maxScroll)
local barH = math.max(24 * Kit.scale, h * frac)
local barY = y + (h - barH) * (scroll / maxScroll)
Theme.fill(x + w - 4 * Kit.scale, barY, 3 * Kit.scale, barH, PAL.line, 0.4)
end
end
function Studio.commitField(id, text)
local ctl = Studio.selectedControl()
if not ctl then return end
local n = tonumber(text)
if not n then return end
Studio.pushUndo("field")
local canvas = Studio.canvas()
local left = (ctl.x - ctl.rangeX) * canvas.w
local top = (ctl.y - ctl.rangeY) * canvas.h
local wpx = ctl.rangeX * 2 * canvas.w
local hpx = ctl.rangeY * 2 * canvas.h
if id == "numX" then left = n
elseif id == "numY" then top = n
elseif id == "numW" then wpx = math.max(1, n)
elseif id == "numH" then hpx = math.max(1, n) end
ctl.rangeX = (wpx * 0.5) / canvas.w
ctl.rangeY = (hpx * 0.5) / canvas.h
ctl.x = clamp01((left + wpx * 0.5) / canvas.w)
ctl.y = clamp01((top + hpx * 0.5) / canvas.h)
markDirty()
end
local function modalFrame(W, H, title, wFrac, hFrac)
local ox, oy, sw, sh, winW, winH = 0, 0, W, H, W, H
local f = Studio._frame
if f then
ox, oy, sw, sh = f.x, f.y, f.w, f.h
winW, winH = f.W, f.H
end
Theme.fill(0, 0, winW, winH, PAL.bg, 0.85)
local pad = 14 * Kit.scale
local mw = math.min(sw - pad * 2, math.max(320 * Kit.scale, (wFrac or 0.6) * sw))
local mh = math.min(sh - pad * 2, math.max(220 * Kit.scale, (hFrac or 0.72) * sh))
local mx = math.floor(ox + (sw - mw) * 0.5)
local my = math.floor(oy + (sh - mh) * 0.5)
Kit.card(mx, my, mw, mh)
Kit.textBold("title", title, mx + pad, my + pad * 0.7, PAL.heading)
return mx, my, mw, mh, pad
end
local function modalFooter(mx, my, mw, mh, pad)
local bh = math.max(Kit.tapMin(), 32 * Kit.scale)
local by = my + mh - pad - bh
if Kit.button(mx + mw - pad - 110 * Kit.scale, by, 110 * Kit.scale, bh,
"Close", { id = "modal-close" }) then
Studio.closeModal()
end
return by, bh
end
local function modalScroll(modal, x, y, w, h, contentH)
local maxScroll = Kit.scrollExtent(contentH, h)
modal.scroll = select(1, Kit.scrollWheel(modal.scroll or 0, maxScroll,
x, y, w, h))
modal.scroll = Kit.scrollClamp(modal.scroll, maxScroll)
return modal.scroll, maxScroll
end
local function drawBindModal(W, H)
local modal = Studio.modal
local ctl = Studio.selectedControl()
local mx, my, mw, mh, pad = modalFrame(W, H, "Choose a bind", 0.66, 0.8)
local top = my + pad + Kit.textHeight("title") + pad * 0.5
local by = modalFooter(mx, my, mw, mh, pad)
local viewH = by - top - pad
local rowH = math.max(Kit.tapMin(), 30 * Kit.scale)
local gap = 6 * Kit.scale
local cols = math.max(2, math.floor((mw - pad * 2) / (150 * Kit.scale)))
local colW = (mw - pad * 2 - gap * (cols - 1)) / cols
local contentH = modal.contentH or viewH
local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH)
local cy = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at,
Kit.scrollExtent(contentH, viewH))
local startY = cy
Kit.caption(mx + pad, cy, "COMBINE")
cy = cy + Kit.textHeight("small") + gap
for i, part in ipairs(Studio.BIND_PARTS) do
local col = (i - 1) % cols
local px = mx + pad + col * (colW + gap)
local py = cy + math.floor((i - 1) / cols) * (rowH + gap)
local on = ctl and Studio.hasBindPart(ctl.spec, part)
if Kit.chip(px, py, colW, rowH, part, on, on and PAL.green or nil,
"bindpart-" .. part) then
Studio.toggleSelectedBindPart(part)
end
end
cy = cy + math.ceil(#Studio.BIND_PARTS / cols) * (rowH + gap) + gap
for _, group in ipairs(Studio.BIND_GROUPS) do
Kit.caption(mx + pad, cy, group.title)
cy = cy + Kit.textHeight("small") + gap
for i, spec in ipairs(group.specs) do
local col = (i - 1) % cols
local px = mx + pad + col * (colW + gap)
local py = cy + math.floor((i - 1) / cols) * (rowH + gap)
local active = ctl and ctl.spec == spec
if Kit.button(px, py, colW, rowH, spec,
{ id = "bind-" .. spec, kind = active and "accent" or nil,
font = "small" }) then
Studio.setBindSpec(spec)
Studio.closeModal()
end
end
cy = cy + math.ceil(#group.specs / cols) * (rowH + gap) + gap
end
modal.contentH = cy - startY
Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at,
Kit.scrollExtent(contentH, viewH))
if ctl then
Kit.text("small", Kit.ellipsize("small",
"Bind: " .. ctl.spec .. " -> " .. TouchSkin.describeBind(ctl.spec),
mw - pad * 2 - 120 * Kit.scale), mx + pad, by + Kit.scale * 8, PAL.muted)
end
end
local function drawImageModal(W, H)
local modal = Studio.modal
local mx, my, mw, mh, pad = modalFrame(W, H,
"Choose art for the " .. Studio.imageTargetLabel(), 0.7, 0.8)
local top = my + pad + Kit.textHeight("title") + pad * 0.5
local by, bh = modalFooter(mx, my, mw, mh, pad)
if Kit.button(mx + pad, by, 150 * Kit.scale, bh, "Import a file...",
{ id = "modal-import", kind = "accent",
enabled = FilePicker.available() }) then
local target = Studio.imageTarget
Studio.closeModal()
Studio.importImageFile(target)
end
local viewH = by - top - pad
local gap = 8 * Kit.scale
local tile = math.max(72 * Kit.scale, 96 * Kit.scale)
local cols = math.max(2, math.floor((mw - pad * 2) / (tile + gap)))
local tileW = (mw - pad * 2 - gap * (cols - 1)) / cols
local tileH = tileW * 0.75 + Kit.textHeight("micro") + 6 * Kit.scale
local list = { false }
for _, rel in ipairs(Studio.images or {}) do list[#list + 1] = rel end
local rows = math.ceil(#list / cols)
local contentH = rows * (tileH + gap)
local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH)
local maxScroll = Kit.scrollExtent(contentH, viewH)
local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll)
local current = Studio.currentImagePath()
for i, rel in ipairs(list) do
local col = (i - 1) % cols
local row = math.floor((i - 1) / cols)
local px = mx + pad + col * (tileW + gap)
local py = baseY + row * (tileH + gap)
local selected = (rel or nil) == current
local clicked = Kit.row(px, py, tileW, tileH, selected, "imgtile-" .. i)
local art = rel and Studio.thumb(rel) or nil
local artH = tileH - Kit.textHeight("micro") - 6 * Kit.scale
if art and art.getWidth then
local iw, ih = art:getWidth(), art:getHeight()
if iw > 0 and ih > 0 then
local scale = math.min((tileW - 8 * Kit.scale) / iw,
(artH - 8 * Kit.scale) / ih)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(art, px + (tileW - iw * scale) * 0.5,
py + (artH - ih * scale) * 0.5, 0, scale, scale)
end
elseif not rel then
Kit.textCenter("small", "(none)", px,
py + artH * 0.5 - Kit.textHeight("small") * 0.5, tileW, PAL.muted)
end
Kit.textCenter("micro", Kit.ellipsize("micro", rel or "no art", tileW),
px, py + tileH - Kit.textHeight("micro") - 2 * Kit.scale, tileW,
selected and PAL.green or PAL.detail)
if clicked then Studio.chooseImage(rel or nil) end
end
if #list == 1 then
Kit.text("small", "No images in this skin yet. Import one.", mx + pad,
baseY, PAL.muted)
end
Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll)
end
local function drawOpenModal(W, H)
local modal = Studio.modal
local mx, my, mw, mh, pad = modalFrame(W, H, "Open a skin", 0.62, 0.76)
local top = my + pad + Kit.textHeight("title") + pad * 0.5
local by = modalFooter(mx, my, mw, mh, pad)
local viewH = by - top - pad
local rowH = math.max(Kit.tapMin(), 46 * Kit.scale)
local gap = 4 * Kit.scale
local list = Studio.available or {}
-- A visible Off choice is more intentional than making people infer that
-- an empty selection disables the overlay. It also works from the skin
-- grid on phones, where the normal pad editor is not the destination.
local offH = rowH + gap
local contentH = offH + #list * (rowH + gap)
local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH)
local maxScroll = Kit.scrollExtent(contentH, viewH)
local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll)
local offClicked = Kit.row(mx + pad, baseY, mw - pad * 2, rowH, false,
"open-off")
Kit.text("mono", "Off", mx + pad * 2, baseY + 6 * Kit.scale, PAL.red)
Kit.text("small", "Hide all on-screen controls.", mx + pad * 2,
baseY + 6 * Kit.scale + Kit.textHeight("mono"), PAL.muted)
if offClicked then Studio.disableTouchControls() end
for i, entry in ipairs(list) do
local py = baseY + offH + (i - 1) * (rowH + gap)
local selected = Studio.skin and Studio.skin.id == entry.id
local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected,
"open-" .. entry.id)
Kit.text("mono", Kit.ellipsize("mono", entry.id, mw - pad * 4),
mx + pad * 2, py + 6 * Kit.scale, selected and PAL.green or PAL.heading)
Kit.text("small", Kit.ellipsize("small", Studio.skinSummary(entry),
mw - pad * 4), mx + pad * 2, py + 6 * Kit.scale + Kit.textHeight("mono"),
PAL.muted)
if clicked then Studio.loadEntry(entry.id) end
end
if #list == 0 then
Kit.text("small", "No skins installed yet.", mx + pad, baseY, PAL.muted)
end
Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll)
end
local function drawPageModal(W, H)
local modal = Studio.modal
local skin = Studio.skin
local mx, my, mw, mh, pad = modalFrame(W, H, "Pages", 0.58, 0.72)
local top = my + pad + Kit.textHeight("title") + pad * 0.5
local by, bh = modalFooter(mx, my, mw, mh, pad)
local gap = 6 * Kit.scale
-- Keep the page name easy to edit: one full-width field, then its actions
-- below it, with Close retaining its own footer row.
local fieldY = by - bh * 2 - gap * 2
local actionY = by - bh - gap
local innerW = mw - pad * 2
local actionW = (innerW - gap) * 0.5
Studio.pageNameField = Kit.textfield("pagename", mx + pad, fieldY, innerW, bh,
Studio.pageNameField or "", "page name")
if Kit.button(mx + pad, actionY, actionW, bh, "Rename",
{ id = "page-rename" }) then
Studio.renamePage(Studio.pageNameField)
end
if Kit.button(mx + pad + actionW + gap, actionY, actionW,
bh, "Delete", { id = "page-del", kind = "danger",
enabled = skin and #skin.pages > 1 }) then
local index = Studio.pageIndex
Studio.closeModal()
Studio.ask("Delete this page and everything on it?",
function() Studio.deletePage(index) end, "Delete")
end
local viewH = fieldY - top - pad
local rowH = math.max(Kit.tapMin(), 44 * Kit.scale)
local pages = (skin and skin.pages) or {}
local contentH = #pages * (rowH + gap)
local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH)
local maxScroll = Kit.scrollExtent(contentH, viewH)
local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll)
for i = 1, #pages do
local py = baseY + (i - 1) * (rowH + gap)
local selected = i == Studio.pageIndex
local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected,
"page-" .. i)
local name, detail = Studio.pageLabel(i)
Kit.text("mono", Kit.ellipsize("mono", i .. ". " .. name, mw - pad * 4),
mx + pad * 2, py + 5 * Kit.scale, selected and PAL.green or PAL.heading)
Kit.text("small", Kit.ellipsize("small", detail, mw - pad * 4),
mx + pad * 2, py + 5 * Kit.scale + Kit.textHeight("mono"), PAL.muted)
if clicked then
Studio.setPage(i)
Studio.pageNameField = name
end
end
Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll)
end
local function drawExportModal(W, H)
local mx, my, mw, mh, pad = modalFrame(W, H, "Export this skin", 0.56, 0.6)
local cy = my + pad + Kit.textHeight("title") + pad
local rowH = math.max(Kit.tapMin(), 34 * Kit.scale)
local gap = 6 * Kit.scale
local by, bh = modalFooter(mx, my, mw, mh, pad)
for _, spec in ipairs(Studio.EXPORTS) do
if Kit.button(mx + pad, cy, mw - pad * 2, rowH, spec.label,
{ id = "export-" .. spec.id, kind = "accent" }) then
Studio.exportAs(spec.id)
Studio.closeModal()
end
cy = cy + rowH + 2 * Kit.scale
Kit.text("small", Kit.ellipsize("small", spec.hint, mw - pad * 2),
mx + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + gap
end
if Studio.lastExport then
if Kit.button(mx + pad, by, 150 * Kit.scale, bh, "Show the file",
{ id = "export-reveal" }) then
Studio.revealExport()
end
end
Kit.textWrapped("small",
"Exports land in the skins folder of your save directory.",
mx + pad, cy, mw - pad * 2, PAL.faint, 2)
end
local function drawScreenModal(W, H)
local modal = Studio.modal
local mx, my, mw, mh, pad = modalFrame(W, H, "Screen & canvas", 0.62, 0.82)
local top = my + pad + Kit.textHeight("title") + pad * 0.5
local by, bh = modalFooter(mx, my, mw, mh, pad)
local gap = 6 * Kit.scale
local rowH = math.max(Kit.tapMin(), 34 * Kit.scale)
local innerW = mw - pad * 2
local half = (innerW - gap) * 0.5
local page = Studio.page()
local locked = Studio.canvas().lockViewport
local vpOn = page and (page.viewport or page.screenFit == "remainder")
if Kit.button(mx + pad, top, half, rowH,
vpOn and "Cutout: ON" or "Cutout: OFF",
{ id = "screen-cutout", active = vpOn == true,
enabled = not locked }) then
Studio.toggleViewport()
end
if Kit.button(mx + pad + half + gap, top, half, rowH,
Studio.aspectLock and "Shape: 10:9" or "Shape: Free",
{ id = "screen-shape", active = Studio.aspectLock }) then
Studio.aspectLock = not Studio.aspectLock
end
top = top + rowH + gap
if Kit.button(mx + pad, top, innerW, rowH, "Detect screen from bezel",
{ id = "screen-bezel",
enabled = page ~= nil and page.imagePath ~= nil and not locked }) then
Studio.detectViewport()
end
top = top + rowH + gap
local live = Studio.canvas()
local detectLabel = ("Detect this screen (%dx%d)"):format(live.w, live.h)
if live.id ~= "this_device" then
local w, h = Studio.deviceSize()
detectLabel = ("Detect this screen (%dx%d)"):format(w, h)
end
if Kit.button(mx + pad, top, innerW, rowH, detectLabel,
{ id = "screen-device", kind = "accent" }) then
Studio.detectDeviceCanvas()
end
top = top + rowH + gap * 1.5
Kit.caption(mx + pad, top, "CANVAS")
top = top + Kit.textHeight("small") + gap
local viewH = by - top - pad
local canvases = Studio.CANVASES
local contentH = #canvases * (rowH + gap)
local at = modalScroll(modal, mx + pad, top, innerW, viewH, contentH)
local maxScroll = Kit.scrollExtent(contentH, viewH)
local baseY = Kit.scrollBegin(mx + pad, top, innerW, viewH, at, maxScroll)
for i, spec in ipairs(canvases) do
local py = baseY + (i - 1) * (rowH + gap)
local selected = i == Studio.canvasIndex
local clicked = Kit.row(mx + pad, py, innerW, rowH, selected,
"canvas-" .. spec.id)
local label = spec.label
local detail
if spec.live then
local w, h = Studio.deviceSize()
detail = ("%dx%d · this window"):format(w, h)
else
detail = ("%dx%d"):format(spec.w, spec.h)
end
Kit.text("mono", Kit.ellipsize("mono", label, innerW - pad),
mx + pad * 2, py + 4 * Kit.scale, selected and PAL.green or PAL.heading)
Kit.text("small", Kit.ellipsize("small", detail, innerW - pad),
mx + pad * 2, py + 4 * Kit.scale + Kit.textHeight("mono"), PAL.muted)
if clicked then Studio.setCanvas(i) end
end
Kit.scrollEnd(mx + pad, top, innerW, viewH, at, maxScroll)
end
local function drawConfirm(W, H)
local c = Studio.confirm
local mx, my, mw, mh, pad = modalFrame(W, H, "Unsaved changes", 0.42, 0.3)
Kit.textWrapped("button", c.text, mx + pad,
my + pad + Kit.textHeight("title") + pad, mw - pad * 2, PAL.text, 3)
local bh = math.max(Kit.tapMin(), 32 * Kit.scale)
local by = my + mh - pad - bh
local bw = (mw - pad * 2 - 12 * Kit.scale) / 3
if Kit.button(mx + pad, by, bw, bh, "Save first",
{ id = "confirm-save", kind = "accent" }) then
Studio.save()
if not Studio.dirty then Studio.confirmYes() end
end
if Kit.button(mx + pad + bw + 6 * Kit.scale, by, bw, bh, c.yesLabel,
{ id = "confirm-yes", kind = "danger" }) then
Studio.confirmYes()
end
if Kit.button(mx + pad + (bw + 6 * Kit.scale) * 2, by, bw, bh, "Cancel",
{ id = "confirm-no" }) then
Studio.confirmNo()
end
end
function Studio.drawOverlay(W, H)
if Studio.confirm then
drawConfirm(W, H)
return true
end
local modal = Studio.modal
if not modal then return false end
if modal.kind == "bind" then drawBindModal(W, H)
elseif modal.kind == "image" then drawImageModal(W, H)
elseif modal.kind == "open" then drawOpenModal(W, H)
elseif modal.kind == "page" then drawPageModal(W, H)
elseif modal.kind == "export" then drawExportModal(W, H)
elseif modal.kind == "screen" then drawScreenModal(W, H)
else Studio.closeModal() end
return true
end
local function drawLegacyStudio()
local winW, winH, ox, oy, W, H = Studio.safeFrame()
Kit.layout(W, H)
local mx, my = love.mouse.getPosition()
if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end
Kit.beginFrame(mx - ox, my - oy, Studio.clicked, Studio.wheel)
Studio.clicked, Studio.wheel = false, 0
Theme.fill(0, 0, winW, winH, PAL.bg, 1)
Studio.expireStatus()
Kit.blockClicks = Studio.modalUp()
love.graphics.push()
love.graphics.translate(ox, oy)
local pad = 14 * Kit.scale
local mobile = Studio.isMobile()
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
-- A phone gets an intentional two-row toolbar. The desktop's single row
-- is excellent at a wide monitor, but it would run its canvas/test/history
-- controls beneath Close on a portrait display.
local barH = mobile and (btnH * 2 + pad * 1.5) or (btnH + pad)
Kit.textBold("title", "Skin Studio", pad, pad * 0.6, PAL.heading)
local titleW = Kit.textWidth("title", "Skin Studio") + pad * 2
local toolY = mobile and (pad * 0.5 + btnH + pad * 0.25) or (pad * 0.5)
local bx = mobile and pad or titleW
local canvas = Studio.canvas()
local canvasW = mobile and math.min(200 * Kit.scale, W * 0.4) or 200 * Kit.scale
if Kit.button(bx, toolY, canvasW, btnH,
canvas.label, { id = "canvas" }) then
Studio.setCanvas(Studio.canvasIndex + 1)
end
bx = bx + canvasW + 8 * Kit.scale
local testW = mobile and math.min(110 * Kit.scale, W * 0.21) or 110 * Kit.scale
if Kit.button(bx, toolY, testW, btnH,
Studio.testing and "Test: ON" or "Test: OFF",
{ id = "test", active = Studio.testing }) then
Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing)
TouchControls:reset()
end
bx = bx + testW + 8 * Kit.scale
local smallW = 74 * Kit.scale
if Kit.button(bx, toolY, smallW, btnH, "Undo",
{ id = "undo", font = "small", enabled = Studio.canUndo() }) then
Studio.undo()
end
bx = bx + smallW + 6 * Kit.scale
if Kit.button(bx, toolY, smallW, btnH, "Redo",
{ id = "redo", font = "small", enabled = Studio.canRedo() }) then
Studio.redo()
end
bx = bx + smallW + 6 * Kit.scale
if not mobile then
if Kit.button(bx, toolY, smallW + 20 * Kit.scale, btnH,
Studio.showLabels and "Labels: ON" or "Labels: OFF",
{ id = "labels", font = "small", active = Studio.showLabels }) then
Studio.showLabels = not Studio.showLabels
end
bx = bx + smallW + 26 * Kit.scale
end
if Studio.dirty then
Kit.text("small", "unsaved", bx, toolY + btnH * 0.3, PAL.yellow)
end
local closeW = 100 * Kit.scale
local offW = 74 * Kit.scale
if Kit.button(W - pad - closeW - offW - 6 * Kit.scale, pad * 0.5, offW, btnH,
"Off", { id = "off", kind = "danger" }) then
Studio.disableTouchControls()
end
local closed = false
if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close",
{ id = "close" }) then
Studio.guard("Close the studio and lose the unsaved changes?", function()
closed = true
if Studio.onClose then Studio.onClose() end
end)
end
if closed then
Kit.blockClicks = false
love.graphics.pop()
Kit.endFrame()
return
end
local bodyY = barH + pad * 0.5
local bodyH = H - bodyY - pad
local r, cx, cw, footY
if Studio.isMobile() then
-- On a phone the canvas stays wide and the inspector becomes the lower
-- sheet. Both remain on screen, so a tapped control can be adjusted
-- without swapping modes or hiding the preview.
cx, cw = pad, W - pad * 2
local canvasH = math.max(180 * Kit.scale, (bodyH - 46 * Kit.scale) * 0.46)
r = drawCanvas(cx, bodyY, cw, canvasH)
local inspectorY = bodyY + canvasH + 8 * Kit.scale
local inspectorH = math.max(0, H - inspectorY - pad - 30 * Kit.scale)
drawInspector(pad, inspectorY, W - pad * 2, inspectorH)
footY = H - pad - 24 * Kit.scale
else
local panelW = math.min(360 * Kit.scale, W * 0.34)
drawInspector(pad, bodyY, panelW, bodyH)
cx = pad * 2 + panelW
cw = W - cx - pad
r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale)
footY = bodyY + bodyH - 30 * Kit.scale
end
local msg = Studio.status
if not msg and Studio.testing then
local held = {}
for btn in pairs(TouchControls.held or {}) do held[#held + 1] = btn end
table.sort(held)
msg = "TEST: click the pad. Held: "
.. (held[1] and table.concat(held, ", ") or "(none)")
end
if not msg then
msg = "Drag to move, corner handles to resize, blue box is the game screen."
end
Kit.text("small", Kit.ellipsize("small", msg, cw), cx, footY,
Studio.statusErr and PAL.red or PAL.detail)
Kit.blockClicks = false
love.graphics.pop()
if r then r.x, r.y = r.x + ox, r.y + oy end
if Studio.lastCanvas then
Studio.lastCanvas.x = Studio.lastCanvas.x + ox
Studio.lastCanvas.y = Studio.lastCanvas.y + oy
end
if Studio.canvasWorkspace then
Studio.canvasWorkspace.x = Studio.canvasWorkspace.x + ox
Studio.canvasWorkspace.y = Studio.canvasWorkspace.y + oy
end
Studio.drawOverlay(W, H)
Kit.endFrame()
Studio.canvasArea = r
end
-- ------------------------------------------------------ mobile-first studio
-- Kept as an opt-in diagnostic renderer while downstream tools migrate; the
-- active Studio path below is the library/editor flow.
Studio.drawLegacy = drawLegacyStudio
local function studioCard(x, y, w, h, id, active)
local focused = Kit.focusable(id, x, y, w, h)
Kit.card(x, y, w, h, active and "selected"
or (focused or Kit.hover(x, y, w, h)))
return Kit.press(x, y, w, h) or Kit._activateId == id
end
local function drawSkinArtwork(image, x, y, w, h)
Theme.fillRounded(x, y, w, h, PAL.bg, 1, Theme.cardRadius() * 0.65)
if image and image.getDimensions then
local iw, ih = image:getDimensions()
if iw > 0 and ih > 0 then
local s = math.min((w - 14 * Kit.scale) / iw, (h - 14 * Kit.scale) / ih)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(image, x + (w - iw * s) * 0.5,
y + (h - ih * s) * 0.5, 0, s, s)
return
end
end
-- A neutral mock reads as a touch skin even before it has artwork.
Theme.strokeRounded(x + w * 0.19, y + h * 0.10, w * 0.62, h * 0.44,
PAL.line, Theme.A.hairline, 1, 3)
Theme.fillRounded(x + w * 0.13, y + h * 0.64, w * 0.26, h * 0.18,
PAL.steel, 0.8, 3)
Theme.fillRounded(x + w * 0.60, y + h * 0.59, w * 0.13, h * 0.24,
PAL.steel, 0.8, h * 0.12)
Theme.fillRounded(x + w * 0.77, y + h * 0.54, w * 0.13, h * 0.24,
PAL.steel, 0.8, h * 0.12)
end
local function drawLibrary()
local f = Studio._frame
local ox, oy, W, H = f.x, f.y, f.w, f.h
local pad = 14 * Kit.scale
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
local titleY = oy + pad * 0.55
Kit.textBold("title", "My Skins", ox + pad, titleY, PAL.heading)
local closeW = math.min(112 * Kit.scale, W * 0.24)
if Kit.button(ox + W - pad - closeW, oy + pad * 0.5, closeW, btnH, "Close",
{ id = "library-close" }) then
if Studio.onClose then Studio.onClose() end
return
end
local y = oy + pad * 0.5 + btnH + pad
local gap = 8 * Kit.scale
local half = (W - pad * 2 - gap) * 0.5
if Kit.button(ox + pad, y, half, btnH * 1.12, "+ New skin",
{ id = "library-new", kind = "accent" }) then
Studio.newSkin()
Studio.enterEditor()
return
end
if Kit.button(ox + pad + half + gap, y, half, btnH * 1.12, "Import skin",
{ id = "library-import" }) then
Studio.importSkinFile()
return
end
y = y + btnH * 1.12 + pad
Kit.caption(ox + pad, y, "YOUR SKINS")
y = y + Kit.textHeight("small") + gap
local entries = Studio.available or {}
local cols = W >= 520 * Kit.scale and 3 or 2
local cardGap = 10 * Kit.scale
local cardW = (W - pad * 2 - cardGap * (cols - 1)) / cols
local cardH = math.max(182 * Kit.scale, cardW * 1.42)
local saved = SaveData.loadOptions()
local tc = type(saved.touchControls) == "table" and saved.touchControls or {}
local activeId = tc.enabled == false and nil or tc.skin
local total = #entries
local maxRows = math.max(1, math.floor((oy + H - y - pad - btnH - gap)
/ (cardH + cardGap)))
local perPage = maxRows * cols
local pages = math.max(1, math.ceil(total / perPage))
Studio.libraryPage = math.max(1, math.min(Studio.libraryPage or 1, pages))
local first = (Studio.libraryPage - 1) * perPage + 1
local last = math.min(total, first + perPage - 1)
for slot = first, last do
local index = slot - first
local cx = ox + pad + (index % cols) * (cardW + cardGap)
local cy = y + math.floor(index / cols) * (cardH + cardGap)
do
local entry = entries[slot]
local selected = entry.id == activeId
Kit.card(cx, cy, cardW, cardH, selected and "selected" or nil)
local artH = cardH * 0.45
drawSkinArtwork(Studio.libraryThumb(entry), cx + 7 * Kit.scale,
cy + 7 * Kit.scale, cardW - 14 * Kit.scale, artH)
Kit.text("mono", Kit.ellipsize("mono", entry.id, cardW - 20 * Kit.scale),
cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale,
selected and PAL.green or PAL.heading)
local meta = Studio.skinSummary(entry)
Kit.text("small", Kit.ellipsize("small", meta, cardW - 20 * Kit.scale),
cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale + Kit.textHeight("mono"),
PAL.muted)
local actionGap = 4 * Kit.scale
local actionW = (cardW - 20 * Kit.scale - actionGap) * 0.5
local actionY = cy + cardH - btnH * 2 - actionGap - 8 * Kit.scale
if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH,
selected and "Selected" or "Select",
{ id = "skin-select-" .. entry.id,
kind = selected and "good" or "accent" }) then
Studio.selectEntry(entry.id)
end
if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH,
"Edit", { id = "skin-edit-" .. entry.id, kind = "accent" }) then
Studio.loadEntry(entry.id)
Studio.enterEditor()
return
end
actionY = actionY + btnH + actionGap
if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH,
"Export", { id = "skin-export-" .. entry.id }) then
Studio.exportEntry(entry.id)
end
if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH,
"Delete", { id = "skin-delete-" .. entry.id,
kind = "danger", enabled = entry.source == "user" }) then
Studio.deleteEntry(entry.id)
end
end
end
if pages > 1 then
local pagerY = oy + H - pad - btnH
local prevW, nextW = (W - pad * 2 - gap) * 0.5, (W - pad * 2 - gap) * 0.5
if Kit.button(ox + pad, pagerY, prevW, btnH, "Previous",
{ id = "library-prev", enabled = Studio.libraryPage > 1 }) then
Studio.libraryPage = Studio.libraryPage - 1
end
if Kit.button(ox + pad + prevW + gap, pagerY, nextW, btnH, "Next",
{ id = "library-next", enabled = Studio.libraryPage < pages }) then
Studio.libraryPage = Studio.libraryPage + 1
end
end
if #entries == 0 then
Kit.text("small", "Create a blank skin or import one to get started.",
ox + pad, y + cardH + gap, PAL.muted)
end
end
local function drawEditor()
local f = Studio._frame
local ox, oy, W, H = f.x, f.y, f.w, f.h
local pad = 14 * Kit.scale
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
local gap = 7 * Kit.scale
local backW = math.min(120 * Kit.scale, W * 0.23)
if Kit.button(ox + pad, oy + pad * 0.5, backW, btnH, "My Skins",
{ id = "editor-back" }) then
Studio.backToLibrary()
end
local closeW = math.min(90 * Kit.scale, W * 0.17)
local saveW = math.min(90 * Kit.scale, W * 0.17)
local testW = math.min(100 * Kit.scale, W * 0.18)
local right = ox + W - pad
if Kit.button(right - closeW, oy + pad * 0.5, closeW, btnH, "Close",
{ id = "editor-close" }) then
Studio.guard("Close the studio and lose the unsaved changes?", function()
if Studio.onClose then Studio.onClose() end
end)
end
right = right - closeW - gap
if Kit.button(right - saveW, oy + pad * 0.5, saveW, btnH, "Save",
{ id = "editor-save", kind = "accent" }) then Studio.save() end
right = right - saveW - gap
if Kit.button(right - testW, oy + pad * 0.5, testW, btnH,
Studio.testing and "Test: ON" or "Test",
{ id = "editor-test", active = Studio.testing }) then
Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing)
TouchControls:reset()
end
local titleX = ox + pad + backW + gap
local titleW = math.max(0, right - testW - gap - titleX)
Kit.textBold("button", Kit.ellipsize("button",
(Studio.skin and Studio.skin.name) or "Untitled skin", titleW),
titleX, oy + pad * 0.5 + (btnH - Kit.textHeight("button")) * 0.5, PAL.heading)
local zH = math.max(Kit.tapMin(), 28 * Kit.scale)
local trayH = btnH * 2 + zH + gap * 4 + Kit.textHeight("small") + gap
local bodyY = oy + pad * 0.5 + btnH + pad
local trayY = oy + H - pad - trayH
local canvasH = math.max(120 * Kit.scale, trayY - bodyY - pad)
drawCanvas(ox + pad, bodyY, W - pad * 2, canvasH)
Kit.card(ox + pad, trayY, W - pad * 2, trayH)
local innerX, innerW = ox + pad + gap, W - pad * 2 - gap * 2
local actionW = (innerW - gap * 3) / 4
local ctl = Studio.selectedControl()
if Kit.button(innerX, trayY + gap, actionW, btnH, "+ Control",
{ id = "tray-add", kind = "accent" }) then
Studio.addControl()
Studio.openBindPicker()
end
if Kit.button(innerX + (actionW + gap), trayY + gap, actionW, btnH,
ctl and ("Bind: " .. ctl.spec) or "Bind",
{ id = "tray-bind", enabled = ctl ~= nil }) then
Studio.openBindPicker()
end
if Kit.button(innerX + (actionW + gap) * 2, trayY + gap, actionW, btnH,
"Button art", { id = "tray-art", enabled = ctl ~= nil }) then
Studio.openImagePicker("idle")
end
if Kit.button(innerX + (actionW + gap) * 3, trayY + gap, actionW, btnH,
"Bezel art", { id = "tray-bezel" }) then
Studio.openImagePicker("bezel")
end
local row2 = trayY + gap * 2 + btnH
if Kit.button(innerX, row2, actionW, btnH, "Pages",
{ id = "tray-pages" }) then Studio.openPageMenu() end
if Kit.button(innerX + (actionW + gap), row2, actionW, btnH,
"Screen", { id = "tray-screen" }) then Studio.openScreenMenu() end
if Kit.button(innerX + (actionW + gap) * 2, row2, actionW, btnH,
Studio.aspectLock and "Shape: 10:9" or "Shape: Free",
{ id = "tray-shape", active = Studio.aspectLock }) then
Studio.aspectLock = not Studio.aspectLock
end
if Kit.button(innerX + (actionW + gap) * 3, row2, actionW, btnH,
"Delete", { id = "tray-delete", kind = "danger", enabled = ctl ~= nil }) then
Studio.deleteControl()
end
local row3 = row2 + btnH + gap
local zoomAt = Studio.zoomIndex()
if Kit.button(innerX, row3, actionW, zH, "Detect screen",
{ id = "tray-detect" }) then
Studio.detectDeviceCanvas()
end
if Kit.button(innerX + (actionW + gap), row3, actionW, zH, "Zoom ",
{ id = "zoom-out", enabled = zoomAt < #Studio.ZOOM_LEVELS }) then
Studio.zoomOut()
end
local pct = math.floor((Studio.viewZoom or 1) * 100 + 0.5) .. "%"
if Kit.button(innerX + (actionW + gap) * 2, row3, actionW, zH, "Fit " .. pct,
{ id = "zoom-fit", active = zoomAt == 1 }) then
Studio.zoomFit()
end
if Kit.button(innerX + (actionW + gap) * 3, row3, actionW, zH, "Zoom +",
{ id = "zoom-in", enabled = zoomAt > 1 }) then
Studio.zoomIn()
end
local hint = ctl and ("Selected: " .. TouchSkin.describeBind(ctl.spec)
.. " — drag to move; use blue handles to resize.")
or "Tap a control to select it. Drag to move; use blue handles to resize."
Kit.text("small", Kit.ellipsize("small", Studio.status or hint, innerW),
innerX, trayY + trayH - Kit.textHeight("small") - gap,
Studio.statusErr and PAL.red or PAL.muted)
end
function Studio.draw()
local W, H = Studio.safeFrame()
local f = Studio._frame
Kit.layout(f.w, f.h)
local mx, my = love.mouse.getPosition()
local px, py, padActive = PadCursor.pointer()
if padActive then
mx, my = px, py
elseif Studio.pointerX ~= nil then
mx, my = Studio.pointerX, Studio.pointerY
end
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
Studio.clicked, Studio.wheel = false, 0
Theme.fill(0, 0, W, H, PAL.bg, 1)
Studio.expireStatus()
Kit.blockClicks = Studio.modalUp()
if Studio.mode == "library" then drawLibrary()
else drawEditor() end
Kit.blockClicks = false
Studio.drawOverlay(f.w, f.h)
Kit.endFrame()
PadCursor.draw()
end
-- ---------------------------------------------------------------- input
function Studio.update(dt)
PadCursor.update(dt or 0)
local x, y, active = PadCursor.pointer()
if active and Studio.pointerDown then Studio.mousemoved(x, y) end
local wheel = PadCursor.takeWheel()
if wheel ~= 0 then Studio.wheelmoved(0, wheel) end
if not Studio.pendingPlay then return end
Studio.pendingPlay = false
local onPlay, version, canvas = Studio.onPlay, Studio.version, Studio.canvas()
if onPlay then onPlay(version, canvas) end
end
function Studio.mousepressed(x, y, button, fromPad)
if button ~= 1 then return end
if not fromPad then PadCursor.yieldToPointer() end
Studio.pointerX, Studio.pointerY = x, y
Studio.pointerDown = true
Studio.clicked = true
if Studio.modalUp() then return end
if Studio.mode == "library" then return end
local r = Studio.lastCanvas
if not r then return end
if Studio.testing then
if x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h then
TouchControls:touchpressed("studio", x, y)
end
return
end
local slop = HANDLE * 2 * Kit.scale
local work = Studio.canvasWorkspace
local insideDevice = x >= r.x - slop and x <= r.x + r.w + slop
and y >= r.y - slop and y <= r.y + r.h + slop
local insideWork = work and x >= work.x and x <= work.x + work.w
and y >= work.y and y <= work.y + work.h
if not insideDevice and not insideWork then
return
end
Studio.beginCanvasDrag(x, y, r)
end
function Studio.mousemoved(x, y)
Studio.pointerX, Studio.pointerY = x, y
if Studio.modalUp() then return end
if Studio.mode == "library" then return end
if Studio.testing then
TouchControls:touchmoved("studio", x, y)
return
end
if Studio.drag and (Studio.pointerDown or love.mouse.isDown(1)) and Studio.lastCanvas then
Studio.updateDrag(x, y, Studio.lastCanvas)
end
end
function Studio.mousereleased(x, y, button)
if button ~= 1 then return end
Studio.pointerX, Studio.pointerY = x, y
Studio.pointerDown = false
if Studio.testing then
TouchControls:touchreleased("studio", x, y)
return
end
Studio.drag = nil
Studio.guides = nil
end
function Studio.touchpressed(id, x, y)
if Studio.touchId and Studio.touchId ~= id then return end
Studio.touchId = id
return Studio.mousepressed(x, y, 1)
end
local function closeFromPad()
if Studio.confirm then
Studio.confirmNo()
elseif Studio.modal then
Studio.closeModal()
elseif Studio.mode == "editor" then
Studio.backToLibrary()
elseif Studio.onClose then
Studio.onClose()
end
end
local function handlePadAction(action)
if action == "a" then
local x, y = PadCursor.pointer()
Studio.mousepressed(x, y, 1, true)
elseif action == "b" then
closeFromPad()
end
end
function Studio.gamepadpressed(joystick, button)
handlePadAction(PadCursor.gamepadpressed(joystick, button))
end
function Studio.gamepadreleased(joystick, button)
PadCursor.gamepadreleased(joystick, button)
if GamepadMap.mapGamepadButton(button) == "a" then
local x, y = PadCursor.pointer()
Studio.mousereleased(x, y, 1)
end
end
function Studio.gamepadaxis(joystick, axis, value)
PadCursor.gamepadaxis(joystick, axis, value)
end
function Studio.joystickpressed(joystick, button)
handlePadAction(PadCursor.joystickpressed(joystick, button))
end
function Studio.joystickreleased(joystick, button)
PadCursor.joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton and GamepadMap.mapGamepadButton(padButton) == "a" then
local x, y = PadCursor.pointer()
Studio.mousereleased(x, y, 1)
end
end
function Studio.joystickaxis(joystick, axis, value)
PadCursor.joystickaxis(joystick, axis, value)
end
function Studio.joystickhat(joystick, hat, direction)
PadCursor.joystickhat(joystick, hat, direction)
end
function Studio.touchmoved(id, x, y)
if Studio.touchId ~= id then return end
return Studio.mousemoved(x, y)
end
function Studio.touchreleased(id, x, y)
if Studio.touchId ~= id then return end
Studio.touchId = nil
return Studio.mousereleased(x, y, 1)
end
function Studio.wheelmoved(_, dy)
if Studio.mode == "editor" and not Studio.modalUp() and dy and dy ~= 0 then
local work = Studio.canvasWorkspace
local mx, my, active = PadCursor.pointer()
if not active then mx, my = Studio.pointerX, Studio.pointerY end
if (not mx or not my) and love and love.mouse and love.mouse.getPosition then
mx, my = love.mouse.getPosition()
end
if work and mx and my
and mx >= work.x and mx <= work.x + work.w
and my >= work.y and my <= work.y + work.h then
if dy > 0 then Studio.zoomIn() else Studio.zoomOut() end
return
end
end
Studio.wheel = dy
end
function Studio.focus()
Studio.drag = nil
Studio.clicked = false
Studio.pointerDown, Studio.touchId = false, nil
if Studio.testing then TouchControls:reset() end
end
function Studio.visible()
Studio.focus()
end
function Studio.textinput(text)
Kit.textinput(text)
end
local function heldCtrl()
if not (love and love.keyboard and love.keyboard.isDown) then return false end
local ok, down = pcall(love.keyboard.isDown, "lctrl", "rctrl", "lgui", "rgui")
return ok and down == true
end
local function heldShift()
if not (love and love.keyboard and love.keyboard.isDown) then return false end
local ok, down = pcall(love.keyboard.isDown, "lshift", "rshift")
return ok and down == true
end
Studio.NUDGES = {
up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 },
}
function Studio.keypressed(key)
if Kit.focus then
Kit.keypressed(key)
return
end
if Studio.confirm then
if key == "escape" then Studio.confirmNo()
elseif key == "return" or key == "kpenter" then Studio.confirmYes()
else Kit.keypressed(key) end
return
end
if Studio.modal then
if key == "escape" then Studio.closeModal() else Kit.keypressed(key) end
return
end
local nudge = Studio.NUDGES[key]
if nudge then
Studio.nudge(nudge[1], nudge[2], heldShift())
return
end
if (key == "y" and heldCtrl())
or (key == "z" and heldShift() and heldCtrl()) then
Studio.redo()
elseif key == "z" and heldCtrl() then
Studio.undo()
elseif key == "u" then
if heldShift() then Studio.redo() else Studio.undo() end
elseif key == "l" then
Studio.showLabels = not Studio.showLabels
elseif key == "escape" then
Studio.guard("Close the studio and lose the unsaved changes?", function()
if Studio.onClose then Studio.onClose() end
end)
elseif key == "delete" or key == "backspace" then
Studio.deleteControl()
elseif key == "n" then
Studio.addControl()
elseif key == "d" then
Studio.duplicateControl()
elseif key == "t" then
Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing)
TouchControls:reset()
elseif key == "-" or key == "kp-" then
Studio.zoomOut()
elseif key == "=" or key == "+" or key == "kp+" then
Studio.zoomIn()
elseif key == "0" or key == "kp0" then
Studio.zoomFit()
elseif key == "s" then
Studio.save()
elseif key == "tab" then
local page = Studio.page()
if page and #page.controls > 0 then
Studio.selected = ((Studio.selected or 0) % #page.controls) + 1
end
else
Kit.keypressed(key)
end
end
function Studio.available_desktop()
-- Kept as the compatibility name for launcher integrations. The Studio
-- now has a touch layout and is available on every supported platform.
return true
end
return Studio