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
