diff --git a/docs/skin-studio.md b/docs/skin-studio.md index ecae75a7..b92a2cd1 100644 --- a/docs/skin-studio.md +++ b/docs/skin-studio.md @@ -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 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. +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 launcher’s **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 is not itself a skin card. @@ -222,6 +234,7 @@ phone proportions on a desktop monitor. | Preset | Size | | --- | --- | | Phone portrait / landscape | 1080x1920, 1920x1080 | +| This screen | the live window, so a phone is authored at its own height | | Tablet portrait / landscape | 1536x2048, 2048x1536 | | Steam Deck | 1280x800 | | Desktop 1080p | 1920x1080 | diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index bdf790aa..dcdfb17b 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -5,12 +5,14 @@ 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 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 }, @@ -20,6 +22,17 @@ Studio.CANVASES = { 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 @@ -100,8 +113,92 @@ 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() - 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 function Studio.page() @@ -256,11 +353,19 @@ end local function canvasOrientation(canvas) canvas = canvas or Studio.canvas() 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 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) @@ -269,7 +374,10 @@ local function pickCanvasIndex(want) end end 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 return nil end @@ -368,6 +476,14 @@ function Studio.load(opts) 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 @@ -1065,6 +1181,11 @@ function Studio.openPageMenu() 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 @@ -1222,8 +1343,13 @@ end 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 @@ -1430,10 +1556,12 @@ local function drawGameTest(r, page) 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) @@ -1745,12 +1873,18 @@ function Studio.commitField(id, text) end 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 mw = math.min(W - pad * 2, math.max(320 * Kit.scale, (wFrac or 0.6) * W)) - local mh = math.min(H - pad * 2, math.max(220 * Kit.scale, (hFrac or 0.72) * H)) - local mx = math.floor((W - mw) * 0.5) - local my = math.floor((H - mh) * 0.5) + 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 @@ -2022,6 +2156,79 @@ local function drawExportModal(W, H) 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) @@ -2057,21 +2264,24 @@ function Studio.drawOverlay(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 W, H = love.graphics.getDimensions() + 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, my, Studio.clicked, Studio.wheel) + Kit.beginFrame(mx - ox, my - oy, Studio.clicked, Studio.wheel) 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() Kit.blockClicks = Studio.modalUp() + love.graphics.push() + love.graphics.translate(ox, oy) local pad = 14 * Kit.scale local mobile = Studio.isMobile() @@ -2142,6 +2352,7 @@ local function drawLegacyStudio() end if closed then Kit.blockClicks = false + love.graphics.pop() Kit.endFrame() return end @@ -2183,6 +2394,16 @@ local function drawLegacyStudio() 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() @@ -2225,34 +2446,37 @@ local function drawSkinArtwork(image, x, y, w, h) PAL.steel, 0.8, h * 0.12) 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 titleY = pad * 0.55 - Kit.textBold("title", "My Skins", pad, titleY, PAL.heading) + 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(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 if Studio.onClose then Studio.onClose() end return end - local y = pad * 0.5 + btnH + pad + local y = oy + pad * 0.5 + btnH + pad local gap = 8 * Kit.scale 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 Studio.newSkin() Studio.enterEditor() return 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 Studio.importSkinFile() return end 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 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 activeId = tc.enabled == false and nil or tc.skin 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))) local perPage = maxRows * cols local pages = math.max(1, math.ceil(total / perPage)) @@ -2274,7 +2498,7 @@ local function drawLibrary(W, H, pad) for slot = first, last do 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) do local entry = entries[slot] @@ -2319,67 +2543,71 @@ local function drawLibrary(W, H, pad) end 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 - 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 Studio.libraryPage = Studio.libraryPage - 1 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 Studio.libraryPage = Studio.libraryPage + 1 end end if #entries == 0 then - Kit.text("small", "Create a blank skin or import one to get started.", pad, - y + cardH + gap, PAL.muted) + 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(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 gap = 7 * Kit.scale 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 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 = W - pad - if Kit.button(right - closeW, pad * 0.5, closeW, btnH, "Close", + 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, 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 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", { id = "editor-test", active = Studio.testing }) then Studio.testing = not Studio.testing TouchControls:setPreview(not Studio.testing) TouchControls:reset() end - local titleX = pad + backW + gap + 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, 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 bodyY = pad * 0.5 + btnH + pad - local trayY = H - pad - trayH + 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(pad, bodyY, W - pad * 2, canvasH) + drawCanvas(ox + pad, bodyY, W - pad * 2, canvasH) - Kit.card(pad, trayY, W - pad * 2, trayH) - local innerX, innerW = pad + gap, W - pad * 2 - gap * 2 + 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", @@ -2401,19 +2629,41 @@ local function drawEditor(W, H, pad) Studio.openImagePicker("bezel") 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 - if Kit.button(innerX + (actionW + gap), trayY + gap * 2 + btnH, actionW, btnH, - "Screen", { id = "tray-screen" }) then Studio.toggleViewport() end - if Kit.button(innerX + (actionW + gap) * 2, trayY + gap * 2 + btnH, actionW, btnH, + 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, 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 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." @@ -2423,8 +2673,9 @@ local function drawEditor(W, H, pad) end function Studio.draw() - local W, H = love.graphics.getDimensions() - Kit.layout(W, H) + local W, H = Studio.safeFrame() + local f = Studio._frame + Kit.layout(f.w, f.h) local mx, my = love.mouse.getPosition() if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel) @@ -2433,12 +2684,11 @@ function Studio.draw() Studio.expireStatus() Kit.blockClicks = Studio.modalUp() - local pad = 14 * Kit.scale - if Studio.mode == "library" then drawLibrary(W, H, pad) - else drawEditor(W, H, pad) end + if Studio.mode == "library" then drawLibrary() + else drawEditor() end Kit.blockClicks = false - Studio.drawOverlay(W, H) + Studio.drawOverlay(f.w, f.h) Kit.endFrame() end @@ -2467,8 +2717,12 @@ function Studio.mousepressed(x, y, button) return end local slop = HANDLE * 2 * Kit.scale - if x < r.x - slop or x > r.x + r.w + slop - or y < r.y - slop or y > r.y + r.h + slop then + 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) @@ -2517,6 +2771,19 @@ function Studio.touchreleased(id, x, y) 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 = 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 end @@ -2594,6 +2861,12 @@ function Studio.keypressed(key) 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 diff --git a/tests/drivers/skin_studio_shot.lua b/tests/drivers/skin_studio_shot.lua index d0820a7e..5fbe475e 100644 --- a/tests/drivers/skin_studio_shot.lua +++ b/tests/drivers/skin_studio_shot.lua @@ -58,6 +58,19 @@ return function(game) U.log("selected:", ctl and ctl.spec, "x", ctl and ctl.x, "y", ctl and ctl.y) 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 local r = Studio.lastCanvas if r and ctl then diff --git a/tests/engine/skin_studio_test.lua b/tests/engine/skin_studio_test.lua index b80cbb65..a95391ee 100644 --- a/tests/engine/skin_studio_test.lua +++ b/tests/engine/skin_studio_test.lua @@ -23,6 +23,7 @@ local function session() Studio.pageIndex = 1 Studio.selected = nil Studio.canvasIndex = 1 + Studio.viewZoom = 1 Studio.aspectLock = true Studio.drag = nil Studio.dirty = false @@ -73,11 +74,16 @@ eq(Studio.selected, nil, "and clears the selection") local ids = {} for _, c in ipairs(Studio.CANVASES) do - check(c.w > 0 and c.h > 0, c.id .. " has real pixel dimensions") + 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") + end check(not ids[c.id], c.id .. " appears once") ids[c.id] = true end 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.ultrawide, "an ultrawide 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) eq(Studio.canvasIndex, 1, "canvas selection wraps") --- ------------------------------------------------------------ canvas fit - Studio.setCanvas(1) +Studio.viewZoom = 1 local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600) near(cw / ch, Studio.canvas().w / Studio.canvas().h, 1e-6, "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(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 session() diff --git a/tests/engine/skin_studio_ux.lua b/tests/engine/skin_studio_ux.lua index 201ede07..ede725cd 100644 --- a/tests/engine/skin_studio_ux.lua +++ b/tests/engine/skin_studio_ux.lua @@ -18,6 +18,7 @@ local function session() Studio.pageIndex = 1 Studio.selected = nil Studio.canvasIndex = 1 + Studio.viewZoom = 1 Studio.aspectLock = true Studio.drag = nil Studio.dirty = false @@ -325,11 +326,52 @@ love.graphics.getDimensions = love.graphics.getDimensions or function() return 1280, 720 end session() 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) check(pcall(Studio.draw), "the studio draws with the " .. kind .. " modal up") end 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) check(pcall(Studio.draw), "and with the confirm prompt up") Studio.confirmNo()