skin studio fix

This commit is contained in:
bryanthaboi
2026-08-21 14:38:54 -04:00
parent 0e407dca7a
commit 06e06e305b
5 changed files with 433 additions and 58 deletions
+14 -1
View File
@@ -209,9 +209,21 @@ New/Load workspace controls are deliberately not duplicated inside that editor.
The editor keeps its canvas unobstructed and puts the contextual actions in a The editor keeps its canvas unobstructed and puts the contextual actions in a
compact lower tray: add/control binding, button and bezel artwork, pages, compact lower tray: add/control binding, button and bezel artwork, pages,
screen placement, freeform/10:9 screen shape and deletion. Touches select, drag and resize the screen placement, freeform/10:9 screen shape and deletion. **Screen** opens
cutout, bezel-hole detect, **Detect this screen**, and the canvas presets.
**Detect this screen** (also on the tray) sets the mock device to the live
window size so a phone skin is authored at that phone's form factor rather
than a generic 1080x1920 16:9. **Zoom ** shrinks the mock device inside the
workspace so the screen hole can be dragged larger than the bezel while the
handles stay grabable; **Fit** restores contain. The mouse wheel over the
canvas, and `-` / `=` / `0` on a keyboard, do the same. Touches select, drag and resize the
same controls that a mouse edits on desktop. same controls that a mouse edits on desktop.
My Skins and the editor chrome sit inside the platform safe area (notch,
status bar, home indicator), the same inset the launcher uses. The mock
device still represents the full window, because a skin covers the whole
screen at play time.
The launchers **Turn skins off** button clears the selected skin and disables The launchers **Turn skins off** button clears the selected skin and disables
skin use. With no skin enabled, mobile falls back to the built-in pad; that pad skin use. With no skin enabled, mobile falls back to the built-in pad; that pad
is not itself a skin card. is not itself a skin card.
@@ -222,6 +234,7 @@ phone proportions on a desktop monitor.
| Preset | Size | | Preset | Size |
| --- | --- | | --- | --- |
| Phone portrait / landscape | 1080x1920, 1920x1080 | | Phone portrait / landscape | 1080x1920, 1920x1080 |
| This screen | the live window, so a phone is authored at its own height |
| Tablet portrait / landscape | 1536x2048, 2048x1536 | | Tablet portrait / landscape | 1536x2048, 2048x1536 |
| Steam Deck | 1280x800 | | Steam Deck | 1280x800 |
| Desktop 1080p | 1920x1080 | | Desktop 1080p | 1920x1080 |
+326 -53
View File
@@ -5,12 +5,14 @@ local TouchSkin = require("src.core.TouchSkin")
local TouchControls = require("src.core.TouchControls") local TouchControls = require("src.core.TouchControls")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local FilePicker = require("src.core.FilePicker") local FilePicker = require("src.core.FilePicker")
local SafeArea = require("src.core.SafeArea")
local Studio = {} local Studio = {}
Studio.CANVASES = { Studio.CANVASES = {
{ id = "phone_portrait", label = "Phone portrait", w = 1080, h = 1920 }, { id = "phone_portrait", label = "Phone portrait", w = 1080, h = 1920 },
{ id = "phone_landscape", label = "Phone landscape", w = 1920, h = 1080 }, { 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_portrait", label = "Tablet portrait", w = 1536, h = 2048 },
{ id = "tablet_landscape", label = "Tablet landscape", w = 2048, h = 1536 }, { id = "tablet_landscape", label = "Tablet landscape", w = 2048, h = 1536 },
{ id = "steamdeck", label = "Steam Deck", w = 1280, h = 800 }, { id = "steamdeck", label = "Steam Deck", w = 1280, h = 800 },
@@ -20,6 +22,17 @@ Studio.CANVASES = {
lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 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 -- 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 -- by default so a portrait/landscape overlay pair does not need two
-- separate clicks to preview the right way up. #1503 -- separate clicks to preview the right way up. #1503
@@ -100,8 +113,92 @@ end
local function round(v) return math.floor(v + 0.5) 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() function Studio.canvas()
return Studio.CANVASES[Studio.canvasIndex] or Studio.CANVASES[1] 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 end
function Studio.page() function Studio.page()
@@ -256,11 +353,19 @@ end
local function canvasOrientation(canvas) local function canvasOrientation(canvas)
canvas = canvas or Studio.canvas() canvas = canvas or Studio.canvas()
if not canvas then return nil end if not canvas then return nil end
return canvas.w > canvas.h and "landscape" or "portrait" 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 end
local function pickCanvasIndex(want) local function pickCanvasIndex(want)
local cur = Studio.canvas() 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 canvasOrientation(cur) == want then return Studio.canvasIndex end
if cur and cur.id then if cur and cur.id then
local hint = cur.id:gsub("portrait", want):gsub("landscape", want) local hint = cur.id:gsub("portrait", want):gsub("landscape", want)
@@ -269,7 +374,10 @@ local function pickCanvasIndex(want)
end end
end end
for i, c in ipairs(Studio.CANVASES) do for i, c in ipairs(Studio.CANVASES) do
if canvasOrientation(c) == want and not c.lockViewport then return i end if not c.live and not c.lockViewport
and canvasOrientation(c) == want then
return i
end
end end
return nil return nil
end end
@@ -368,6 +476,14 @@ function Studio.load(opts)
Studio.drag = nil Studio.drag = nil
Studio.pendingPlay = false Studio.pendingPlay = false
Studio.canvasIndex = 1 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, -- A free-form screen is the default. 10:9 is an optional convenience,
-- never a restriction on imported or custom skins. -- never a restriction on imported or custom skins.
Studio.aspectLock = false Studio.aspectLock = false
@@ -1065,6 +1181,11 @@ function Studio.openPageMenu()
return true return true
end end
function Studio.openScreenMenu()
Studio.openModal("screen")
return true
end
function Studio.renamePage(name) function Studio.renamePage(name)
local page = Studio.page() local page = Studio.page()
if not page then return false end if not page then return false end
@@ -1222,8 +1343,13 @@ end
function Studio.canvasRect(x, y, w, h) function Studio.canvasRect(x, y, w, h)
local canvas = Studio.canvas() local canvas = Studio.canvas()
local aspect = canvas.w / canvas.h local aspect = canvas.w / canvas.h
if not aspect or aspect <= 0 then aspect = 1 end
local cw, ch = w, w / aspect local cw, ch = w, w / aspect
if ch > h then ch, cw = h, h * aspect end 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 return x + (w - cw) * 0.5, y + (h - ch) * 0.5, cw, ch
end end
@@ -1430,10 +1556,12 @@ local function drawGameTest(r, page)
end end
local function drawCanvas(x, y, w, h) local function drawCanvas(x, y, w, h)
Studio.canvasWorkspace = { x = x, y = y, w = w, h = h }
local page = Studio.page() local page = Studio.page()
local r = { } local r = { }
r.x, r.y, r.w, r.h = Studio.canvasRect(x, y, w, h) r.x, r.y, r.w, r.h = Studio.canvasRect(x, y, w, h)
Studio.lastCanvas = r Studio.lastCanvas = r
Theme.fill(x, y, w, h, { 10, 12, 18 }, 1)
if not page then return r end if not page then return r end
Theme.fill(r.x, r.y, r.w, r.h, { 0, 0, 0 }, 1) Theme.fill(r.x, r.y, r.w, r.h, { 0, 0, 0 }, 1)
@@ -1745,12 +1873,18 @@ function Studio.commitField(id, text)
end end
local function modalFrame(W, H, title, wFrac, hFrac) local function modalFrame(W, H, title, wFrac, hFrac)
Theme.fill(0, 0, W, H, PAL.bg, 0.85) 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 pad = 14 * Kit.scale
local mw = math.min(W - pad * 2, math.max(320 * Kit.scale, (wFrac or 0.6) * W)) local mw = math.min(sw - pad * 2, math.max(320 * Kit.scale, (wFrac or 0.6) * sw))
local mh = math.min(H - pad * 2, math.max(220 * Kit.scale, (hFrac or 0.72) * H)) local mh = math.min(sh - pad * 2, math.max(220 * Kit.scale, (hFrac or 0.72) * sh))
local mx = math.floor((W - mw) * 0.5) local mx = math.floor(ox + (sw - mw) * 0.5)
local my = math.floor((H - mh) * 0.5) local my = math.floor(oy + (sh - mh) * 0.5)
Kit.card(mx, my, mw, mh) Kit.card(mx, my, mw, mh)
Kit.textBold("title", title, mx + pad, my + pad * 0.7, PAL.heading) Kit.textBold("title", title, mx + pad, my + pad * 0.7, PAL.heading)
return mx, my, mw, mh, pad return mx, my, mw, mh, pad
@@ -2022,6 +2156,79 @@ local function drawExportModal(W, H)
mx + pad, cy, mw - pad * 2, PAL.faint, 2) mx + pad, cy, mw - pad * 2, PAL.faint, 2)
end 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 function drawConfirm(W, H)
local c = Studio.confirm local c = Studio.confirm
local mx, my, mw, mh, pad = modalFrame(W, H, "Unsaved changes", 0.42, 0.3) local mx, my, mw, mh, pad = modalFrame(W, H, "Unsaved changes", 0.42, 0.3)
@@ -2057,21 +2264,24 @@ function Studio.drawOverlay(W, H)
elseif modal.kind == "open" then drawOpenModal(W, H) elseif modal.kind == "open" then drawOpenModal(W, H)
elseif modal.kind == "page" then drawPageModal(W, H) elseif modal.kind == "page" then drawPageModal(W, H)
elseif modal.kind == "export" then drawExportModal(W, H) elseif modal.kind == "export" then drawExportModal(W, H)
elseif modal.kind == "screen" then drawScreenModal(W, H)
else Studio.closeModal() end else Studio.closeModal() end
return true return true
end end
local function drawLegacyStudio() local function drawLegacyStudio()
local W, H = love.graphics.getDimensions() local winW, winH, ox, oy, W, H = Studio.safeFrame()
Kit.layout(W, H) Kit.layout(W, H)
local mx, my = love.mouse.getPosition() local mx, my = love.mouse.getPosition()
if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel) Kit.beginFrame(mx - ox, my - oy, Studio.clicked, Studio.wheel)
Studio.clicked, Studio.wheel = false, 0 Studio.clicked, Studio.wheel = false, 0
Theme.fill(0, 0, W, H, PAL.bg, 1) Theme.fill(0, 0, winW, winH, PAL.bg, 1)
Studio.expireStatus() Studio.expireStatus()
Kit.blockClicks = Studio.modalUp() Kit.blockClicks = Studio.modalUp()
love.graphics.push()
love.graphics.translate(ox, oy)
local pad = 14 * Kit.scale local pad = 14 * Kit.scale
local mobile = Studio.isMobile() local mobile = Studio.isMobile()
@@ -2142,6 +2352,7 @@ local function drawLegacyStudio()
end end
if closed then if closed then
Kit.blockClicks = false Kit.blockClicks = false
love.graphics.pop()
Kit.endFrame() Kit.endFrame()
return return
end end
@@ -2183,6 +2394,16 @@ local function drawLegacyStudio()
Studio.statusErr and PAL.red or PAL.detail) Studio.statusErr and PAL.red or PAL.detail)
Kit.blockClicks = false 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) Studio.drawOverlay(W, H)
Kit.endFrame() Kit.endFrame()
@@ -2225,34 +2446,37 @@ local function drawSkinArtwork(image, x, y, w, h)
PAL.steel, 0.8, h * 0.12) PAL.steel, 0.8, h * 0.12)
end end
local function drawLibrary(W, H, pad) 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 btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
local titleY = pad * 0.55 local titleY = oy + pad * 0.55
Kit.textBold("title", "My Skins", pad, titleY, PAL.heading) Kit.textBold("title", "My Skins", ox + pad, titleY, PAL.heading)
local closeW = math.min(112 * Kit.scale, W * 0.24) local closeW = math.min(112 * Kit.scale, W * 0.24)
if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close", if Kit.button(ox + W - pad - closeW, oy + pad * 0.5, closeW, btnH, "Close",
{ id = "library-close" }) then { id = "library-close" }) then
if Studio.onClose then Studio.onClose() end if Studio.onClose then Studio.onClose() end
return return
end end
local y = pad * 0.5 + btnH + pad local y = oy + pad * 0.5 + btnH + pad
local gap = 8 * Kit.scale local gap = 8 * Kit.scale
local half = (W - pad * 2 - gap) * 0.5 local half = (W - pad * 2 - gap) * 0.5
if Kit.button(pad, y, half, btnH * 1.12, "+ New skin", if Kit.button(ox + pad, y, half, btnH * 1.12, "+ New skin",
{ id = "library-new", kind = "accent" }) then { id = "library-new", kind = "accent" }) then
Studio.newSkin() Studio.newSkin()
Studio.enterEditor() Studio.enterEditor()
return return
end end
if Kit.button(pad + half + gap, y, half, btnH * 1.12, "Import skin", if Kit.button(ox + pad + half + gap, y, half, btnH * 1.12, "Import skin",
{ id = "library-import" }) then { id = "library-import" }) then
Studio.importSkinFile() Studio.importSkinFile()
return return
end end
y = y + btnH * 1.12 + pad y = y + btnH * 1.12 + pad
Kit.caption(pad, y, "YOUR SKINS") Kit.caption(ox + pad, y, "YOUR SKINS")
y = y + Kit.textHeight("small") + gap y = y + Kit.textHeight("small") + gap
local entries = Studio.available or {} local entries = Studio.available or {}
@@ -2264,7 +2488,7 @@ local function drawLibrary(W, H, pad)
local tc = type(saved.touchControls) == "table" and saved.touchControls or {} local tc = type(saved.touchControls) == "table" and saved.touchControls or {}
local activeId = tc.enabled == false and nil or tc.skin local activeId = tc.enabled == false and nil or tc.skin
local total = #entries local total = #entries
local maxRows = math.max(1, math.floor((H - y - pad - btnH - gap) local maxRows = math.max(1, math.floor((oy + H - y - pad - btnH - gap)
/ (cardH + cardGap))) / (cardH + cardGap)))
local perPage = maxRows * cols local perPage = maxRows * cols
local pages = math.max(1, math.ceil(total / perPage)) local pages = math.max(1, math.ceil(total / perPage))
@@ -2274,7 +2498,7 @@ local function drawLibrary(W, H, pad)
for slot = first, last do for slot = first, last do
local index = slot - first local index = slot - first
local cx = pad + (index % cols) * (cardW + cardGap) local cx = ox + pad + (index % cols) * (cardW + cardGap)
local cy = y + math.floor(index / cols) * (cardH + cardGap) local cy = y + math.floor(index / cols) * (cardH + cardGap)
do do
local entry = entries[slot] local entry = entries[slot]
@@ -2319,67 +2543,71 @@ local function drawLibrary(W, H, pad)
end end
if pages > 1 then if pages > 1 then
local pagerY = H - pad - btnH local pagerY = oy + H - pad - btnH
local prevW, nextW = (W - pad * 2 - gap) * 0.5, (W - pad * 2 - gap) * 0.5 local prevW, nextW = (W - pad * 2 - gap) * 0.5, (W - pad * 2 - gap) * 0.5
if Kit.button(pad, pagerY, prevW, btnH, "Previous", if Kit.button(ox + pad, pagerY, prevW, btnH, "Previous",
{ id = "library-prev", enabled = Studio.libraryPage > 1 }) then { id = "library-prev", enabled = Studio.libraryPage > 1 }) then
Studio.libraryPage = Studio.libraryPage - 1 Studio.libraryPage = Studio.libraryPage - 1
end end
if Kit.button(pad + prevW + gap, pagerY, nextW, btnH, "Next", if Kit.button(ox + pad + prevW + gap, pagerY, nextW, btnH, "Next",
{ id = "library-next", enabled = Studio.libraryPage < pages }) then { id = "library-next", enabled = Studio.libraryPage < pages }) then
Studio.libraryPage = Studio.libraryPage + 1 Studio.libraryPage = Studio.libraryPage + 1
end end
end end
if #entries == 0 then if #entries == 0 then
Kit.text("small", "Create a blank skin or import one to get started.", pad, Kit.text("small", "Create a blank skin or import one to get started.",
y + cardH + gap, PAL.muted) ox + pad, y + cardH + gap, PAL.muted)
end end
end end
local function drawEditor(W, H, pad) 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 btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
local gap = 7 * Kit.scale local gap = 7 * Kit.scale
local backW = math.min(120 * Kit.scale, W * 0.23) local backW = math.min(120 * Kit.scale, W * 0.23)
if Kit.button(pad, pad * 0.5, backW, btnH, "My Skins", if Kit.button(ox + pad, oy + pad * 0.5, backW, btnH, "My Skins",
{ id = "editor-back" }) then { id = "editor-back" }) then
Studio.backToLibrary() Studio.backToLibrary()
end end
local closeW = math.min(90 * Kit.scale, W * 0.17) local closeW = math.min(90 * Kit.scale, W * 0.17)
local saveW = 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 testW = math.min(100 * Kit.scale, W * 0.18)
local right = W - pad local right = ox + W - pad
if Kit.button(right - closeW, pad * 0.5, closeW, btnH, "Close", if Kit.button(right - closeW, oy + pad * 0.5, closeW, btnH, "Close",
{ id = "editor-close" }) then { id = "editor-close" }) then
Studio.guard("Close the studio and lose the unsaved changes?", function() Studio.guard("Close the studio and lose the unsaved changes?", function()
if Studio.onClose then Studio.onClose() end if Studio.onClose then Studio.onClose() end
end) end)
end end
right = right - closeW - gap right = right - closeW - gap
if Kit.button(right - saveW, pad * 0.5, saveW, btnH, "Save", if Kit.button(right - saveW, oy + pad * 0.5, saveW, btnH, "Save",
{ id = "editor-save", kind = "accent" }) then Studio.save() end { id = "editor-save", kind = "accent" }) then Studio.save() end
right = right - saveW - gap right = right - saveW - gap
if Kit.button(right - testW, pad * 0.5, testW, btnH, if Kit.button(right - testW, oy + pad * 0.5, testW, btnH,
Studio.testing and "Test: ON" or "Test", Studio.testing and "Test: ON" or "Test",
{ id = "editor-test", active = Studio.testing }) then { id = "editor-test", active = Studio.testing }) then
Studio.testing = not Studio.testing Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing) TouchControls:setPreview(not Studio.testing)
TouchControls:reset() TouchControls:reset()
end end
local titleX = pad + backW + gap local titleX = ox + pad + backW + gap
local titleW = math.max(0, right - testW - gap - titleX) local titleW = math.max(0, right - testW - gap - titleX)
Kit.textBold("button", Kit.ellipsize("button", Kit.textBold("button", Kit.ellipsize("button",
(Studio.skin and Studio.skin.name) or "Untitled skin", titleW), (Studio.skin and Studio.skin.name) or "Untitled skin", titleW),
titleX, pad * 0.5 + (btnH - Kit.textHeight("button")) * 0.5, PAL.heading) titleX, oy + pad * 0.5 + (btnH - Kit.textHeight("button")) * 0.5, PAL.heading)
local trayH = btnH * 2 + gap * 3 + 30 * Kit.scale local zH = math.max(Kit.tapMin(), 28 * Kit.scale)
local bodyY = pad * 0.5 + btnH + pad local trayH = btnH * 2 + zH + gap * 4 + Kit.textHeight("small") + gap
local trayY = H - pad - trayH local bodyY = oy + pad * 0.5 + btnH + pad
local trayY = oy + H - pad - trayH
local canvasH = math.max(120 * Kit.scale, trayY - bodyY - pad) local canvasH = math.max(120 * Kit.scale, trayY - bodyY - pad)
drawCanvas(pad, bodyY, W - pad * 2, canvasH) drawCanvas(ox + pad, bodyY, W - pad * 2, canvasH)
Kit.card(pad, trayY, W - pad * 2, trayH) Kit.card(ox + pad, trayY, W - pad * 2, trayH)
local innerX, innerW = pad + gap, W - pad * 2 - gap * 2 local innerX, innerW = ox + pad + gap, W - pad * 2 - gap * 2
local actionW = (innerW - gap * 3) / 4 local actionW = (innerW - gap * 3) / 4
local ctl = Studio.selectedControl() local ctl = Studio.selectedControl()
if Kit.button(innerX, trayY + gap, actionW, btnH, "+ Control", if Kit.button(innerX, trayY + gap, actionW, btnH, "+ Control",
@@ -2401,19 +2629,41 @@ local function drawEditor(W, H, pad)
Studio.openImagePicker("bezel") Studio.openImagePicker("bezel")
end end
if Kit.button(innerX, trayY + gap * 2 + btnH, actionW, btnH, "Pages", local row2 = trayY + gap * 2 + btnH
if Kit.button(innerX, row2, actionW, btnH, "Pages",
{ id = "tray-pages" }) then Studio.openPageMenu() end { id = "tray-pages" }) then Studio.openPageMenu() end
if Kit.button(innerX + (actionW + gap), trayY + gap * 2 + btnH, actionW, btnH, if Kit.button(innerX + (actionW + gap), row2, actionW, btnH,
"Screen", { id = "tray-screen" }) then Studio.toggleViewport() end "Screen", { id = "tray-screen" }) then Studio.openScreenMenu() end
if Kit.button(innerX + (actionW + gap) * 2, trayY + gap * 2 + btnH, actionW, btnH, if Kit.button(innerX + (actionW + gap) * 2, row2, actionW, btnH,
Studio.aspectLock and "Shape: 10:9" or "Shape: Free", Studio.aspectLock and "Shape: 10:9" or "Shape: Free",
{ id = "tray-shape", active = Studio.aspectLock }) then { id = "tray-shape", active = Studio.aspectLock }) then
Studio.aspectLock = not Studio.aspectLock Studio.aspectLock = not Studio.aspectLock
end end
if Kit.button(innerX + (actionW + gap) * 3, trayY + gap * 2 + btnH, actionW, btnH, if Kit.button(innerX + (actionW + gap) * 3, row2, actionW, btnH,
"Delete", { id = "tray-delete", kind = "danger", enabled = ctl ~= nil }) then "Delete", { id = "tray-delete", kind = "danger", enabled = ctl ~= nil }) then
Studio.deleteControl() Studio.deleteControl()
end 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) local hint = ctl and ("Selected: " .. TouchSkin.describeBind(ctl.spec)
.. " — drag to move; use blue handles to resize.") .. " — drag to move; use blue handles to resize.")
or "Tap a control to select it. Drag to move; use blue handles to resize." or "Tap a control to select it. Drag to move; use blue handles to resize."
@@ -2423,8 +2673,9 @@ local function drawEditor(W, H, pad)
end end
function Studio.draw() function Studio.draw()
local W, H = love.graphics.getDimensions() local W, H = Studio.safeFrame()
Kit.layout(W, H) local f = Studio._frame
Kit.layout(f.w, f.h)
local mx, my = love.mouse.getPosition() local mx, my = love.mouse.getPosition()
if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel) Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
@@ -2433,12 +2684,11 @@ function Studio.draw()
Studio.expireStatus() Studio.expireStatus()
Kit.blockClicks = Studio.modalUp() Kit.blockClicks = Studio.modalUp()
local pad = 14 * Kit.scale if Studio.mode == "library" then drawLibrary()
if Studio.mode == "library" then drawLibrary(W, H, pad) else drawEditor() end
else drawEditor(W, H, pad) end
Kit.blockClicks = false Kit.blockClicks = false
Studio.drawOverlay(W, H) Studio.drawOverlay(f.w, f.h)
Kit.endFrame() Kit.endFrame()
end end
@@ -2467,8 +2717,12 @@ function Studio.mousepressed(x, y, button)
return return
end end
local slop = HANDLE * 2 * Kit.scale local slop = HANDLE * 2 * Kit.scale
if x < r.x - slop or x > r.x + r.w + slop local work = Studio.canvasWorkspace
or y < r.y - slop or y > r.y + r.h + slop then 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 return
end end
Studio.beginCanvasDrag(x, y, r) Studio.beginCanvasDrag(x, y, r)
@@ -2517,6 +2771,19 @@ function Studio.touchreleased(id, x, y)
end end
function Studio.wheelmoved(_, dy) 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 = Studio.pointerX, Studio.pointerY
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 Studio.wheel = dy
end end
@@ -2594,6 +2861,12 @@ function Studio.keypressed(key)
Studio.testing = not Studio.testing Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing) TouchControls:setPreview(not Studio.testing)
TouchControls:reset() 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 elseif key == "s" then
Studio.save() Studio.save()
elseif key == "tab" then elseif key == "tab" then
+13
View File
@@ -58,6 +58,19 @@ return function(game)
U.log("selected:", ctl and ctl.spec, "x", ctl and ctl.x, "y", ctl and ctl.y) U.log("selected:", ctl and ctl.spec, "x", ctl and ctl.x, "y", ctl and ctl.y)
shot("studio_selected.png") shot("studio_selected.png")
Studio.zoomOut()
U.wait(2)
shot("studio_zoom_out.png")
Studio.zoomFit()
Studio.openScreenMenu()
U.wait(2)
shot("studio_screen.png")
Studio.closeModal()
Studio.detectDeviceCanvas()
U.wait(2)
shot("studio_this_screen.png")
-- drag the selected control and confirm the model moved -- drag the selected control and confirm the model moved
local r = Studio.lastCanvas local r = Studio.lastCanvas
if r and ctl then if r and ctl then
+36 -2
View File
@@ -23,6 +23,7 @@ local function session()
Studio.pageIndex = 1 Studio.pageIndex = 1
Studio.selected = nil Studio.selected = nil
Studio.canvasIndex = 1 Studio.canvasIndex = 1
Studio.viewZoom = 1
Studio.aspectLock = true Studio.aspectLock = true
Studio.drag = nil Studio.drag = nil
Studio.dirty = false Studio.dirty = false
@@ -73,11 +74,16 @@ eq(Studio.selected, nil, "and clears the selection")
local ids = {} local ids = {}
for _, c in ipairs(Studio.CANVASES) do for _, c in ipairs(Studio.CANVASES) do
if c.live then
check(c.id == "this_device", "the live preset is this screen")
else
check(c.w > 0 and c.h > 0, c.id .. " has real pixel dimensions") check(c.w > 0 and c.h > 0, c.id .. " has real pixel dimensions")
end
check(not ids[c.id], c.id .. " appears once") check(not ids[c.id], c.id .. " appears once")
ids[c.id] = true ids[c.id] = true
end end
check(ids.phone_portrait, "a phone portrait preset exists") check(ids.phone_portrait, "a phone portrait preset exists")
check(ids.this_device, "a this-screen live preset exists")
check(ids.desktop_1080, "a desktop preset exists") check(ids.desktop_1080, "a desktop preset exists")
check(ids.ultrawide, "an ultrawide preset exists") check(ids.ultrawide, "an ultrawide preset exists")
check(ids.sgb_border, "a Super Game Boy border preset exists") check(ids.sgb_border, "a Super Game Boy border preset exists")
@@ -105,9 +111,8 @@ check(Studio.page().viewport ~= nil, "the SGB cutout cannot be toggled off")
Studio.setCanvas(#Studio.CANVASES + 1) Studio.setCanvas(#Studio.CANVASES + 1)
eq(Studio.canvasIndex, 1, "canvas selection wraps") eq(Studio.canvasIndex, 1, "canvas selection wraps")
-- ------------------------------------------------------------ canvas fit
Studio.setCanvas(1) Studio.setCanvas(1)
Studio.viewZoom = 1
local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600) local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600)
near(cw / ch, Studio.canvas().w / Studio.canvas().h, 1e-6, near(cw / ch, Studio.canvas().w / Studio.canvas().h, 1e-6,
"the mock device keeps its aspect") "the mock device keeps its aspect")
@@ -115,6 +120,35 @@ check(cw <= 800 + 1e-6 and ch <= 600 + 1e-6, "and fits inside the workspace")
near(cx + cw / 2, 400, 1e-6, "centred horizontally") near(cx + cw / 2, 400, 1e-6, "centred horizontally")
near(cy + ch / 2, 300, 1e-6, "centred vertically") near(cy + ch / 2, 300, 1e-6, "centred vertically")
eq(Studio.zoomOut(), 0.75, "zoom out steps to 75%")
local zx, zy, zw, zh = Studio.canvasRect(0, 0, 800, 600)
near(zw, cw * 0.75, 1e-6, "zoom out shrinks the mock device")
near(zh, ch * 0.75, 1e-6, "on both axes")
near(zx + zw / 2, 400, 1e-6, "and stays centred")
near(zw / zh, cw / ch, 1e-6, "keeping the same aspect")
check(zw < cw, "so there is margin around the device for a larger screen hole")
eq(Studio.zoomIn(), 1, "zoom in restores fit")
eq(Studio.zoomOut(), 0.75)
eq(Studio.zoomOut(), 0.5)
eq(Studio.zoomOut(), 0.35)
eq(Studio.zoomOut(), 0.35, "zoom out stops at the smallest level")
eq(Studio.zoomFit(), 1, "fit snaps back to contain")
local oldDims = love.graphics.getDimensions
love.graphics.getDimensions = function() return 1170, 2532 end
session()
local live = Studio.detectDeviceCanvas()
eq(live.id, "this_device", "detect this screen selects the live canvas")
eq(live.w, 1170, "at the window width")
eq(live.h, 2532, "and the window height")
eq(Studio.canvas().w / Studio.canvas().h, 1170 / 2532,
"so the mock device is this screen's form factor")
Studio.matchOrient = true
Studio.syncCanvasToPage()
eq(Studio.canvas().id, "this_device",
"Match canvas leaves the live screen selected")
love.graphics.getDimensions = oldDims
-- ----------------------------------------------------------- touch bridge -- ----------------------------------------------------------- touch bridge
session() session()
+43 -1
View File
@@ -18,6 +18,7 @@ local function session()
Studio.pageIndex = 1 Studio.pageIndex = 1
Studio.selected = nil Studio.selected = nil
Studio.canvasIndex = 1 Studio.canvasIndex = 1
Studio.viewZoom = 1
Studio.aspectLock = true Studio.aspectLock = true
Studio.drag = nil Studio.drag = nil
Studio.dirty = false Studio.dirty = false
@@ -325,11 +326,52 @@ love.graphics.getDimensions = love.graphics.getDimensions
or function() return 1280, 720 end or function() return 1280, 720 end
session() session()
Studio.addControl() Studio.addControl()
for _, kind in ipairs({ "bind", "image", "open", "page", "export" }) do for _, kind in ipairs({ "bind", "image", "open", "page", "export", "screen" }) do
Studio.openModal(kind) Studio.openModal(kind)
check(pcall(Studio.draw), "the studio draws with the " .. kind .. " modal up") check(pcall(Studio.draw), "the studio draws with the " .. kind .. " modal up")
end end
Studio.closeModal() Studio.closeModal()
check(Studio.openScreenMenu(), "Screen opens the canvas/screen modal")
eq(Studio.modal.kind, "screen", "as the screen modal")
Studio.closeModal()
-- chrome stays inside the platform safe area (notch / home indicator)
local Kit = require("src.ui.kit.Kit")
local oldDims = love.graphics.getDimensions
local oldSafe = love.window.getSafeArea
love.graphics.getDimensions = function() return 400, 800 end
love.window.getSafeArea = function() return 12, 48, 376, 704 end
session()
Studio.mode = "library"
Studio.available = {}
Kit.audit = {}
check(pcall(Studio.draw), "My Skins draws inside an inset safe area")
local function insideSafe(rects, ox, oy, sw, sh, where)
for _, r in ipairs(rects or {}) do
check(r.x >= ox - 0.5,
where .. " control '" .. r.label .. "' is right of the left inset")
check(r.y >= oy - 0.5,
where .. " control '" .. r.label .. "' is below the notch")
check(r.x + r.w <= ox + sw + 0.5,
where .. " control '" .. r.label .. "' stays left of the right inset")
check(r.y + r.h <= oy + sh + 0.5,
where .. " control '" .. r.label .. "' stays above the home indicator")
end
end
insideSafe(Kit.audit, 12, 48, 376, 704, "library")
Studio.mode = "editor"
Studio.addControl()
Kit.audit = {}
check(pcall(Studio.draw), "the editor draws inside an inset safe area")
insideSafe(Kit.audit, 12, 48, 376, 704, "editor")
check(Studio.lastCanvas ~= nil, "the editor still has a mock device")
check(Studio.lastCanvas.y >= 48 - 0.5, "the mock device is below the notch")
check(Studio.lastCanvas.y + Studio.lastCanvas.h <= 48 + 704 + 0.5,
"and above the home indicator")
Kit.audit = nil
love.graphics.getDimensions = oldDims
love.window.getSafeArea = oldSafe
Studio.closeModal()
Studio.ask("sure?", function() end) Studio.ask("sure?", function() end)
check(pcall(Studio.draw), "and with the confirm prompt up") check(pcall(Studio.draw), "and with the confirm prompt up")
Studio.confirmNo() Studio.confirmNo()