-- Save/load worlds. Layout, palette and nine slice art are
-- lifted from VoxelEditorPlus's ModelBrowser; the drawing is
-- plain 2D with manual hit testing rather than VEP's UI.Panel
-- classes, because Voxel Playground already has a different
-- ui library loaded and running two of them invites conflicts.
--
-- Gear sits in the top right and is live in "waiting" and
-- "playing". While the browser is open every touch is
-- swallowed, so nothing reaches the navpad, the hotbar or
-- VoxelTouch.

WorldBrowser = {}

WorldBrowser.visible = false
WorldBrowser.page = 1
WorldBrowser.pageCount = 1
WorldBrowser.models = {}
WorldBrowser.deleteMode = false
WorldBrowser.naming = false
WorldBrowser.nameDefault = ""
WorldBrowser.pendingLoad = nil
WorldBrowser.pendingSave = nil
WorldBrowser.confirm = nil
WorldBrowser.choice = nil
WorldBrowser.notice = nil
WorldBrowser.rows = 4
WorldBrowser.cols = 3

local SET_ROW_H = 32
local SET_GAP = 6
local SET_RESTART_H = 36
local SET_PAD = 10
local SET_W_LAND = 264
local SET_BOX = 26

-- Four toggle rows now, a gap, then the restart button,
-- plus padding.
local SET_ROWS = 4
local SET_H = SET_ROW_H * SET_ROWS + SET_GAP * (SET_ROWS - 1)
+ SET_GAP + SET_RESTART_H + SET_PAD * 2

local MIN_CELL = 60

-- chrome opacity, from VEP
local CHROME_A = 55
local SLOT_A = 88
local SLOT_A_ON = 160
local TEXT_A = 165
local BTN_A_NORMAL = 150
local BTN_A_ACTIVE = 215

local SLOT_TINT = color(75, 24, 226)
local BTN_TINT = color(115, 95, 137)

-- White and bold by default. The shadow stays dark rather
-- than tinted, so white text keeps its contrast over both
-- the bright terrain and the dark panel wash.
local HINT_FONT = "HelveticaNeue-Bold"
local HINT_COL = color(255, 255, 255)
local HINT_SHADOW = color(0, 0, 0, 200)

local GEAR = "⚙️"
local SETTINGS_IMG

local function ensureSettingsImg()
  if SETTINGS_IMG ~= nil then return end
  local ok, img = pcall(function() return readImage(asset.builtin.UI.Blue_Panel) end)
  SETTINGS_IMG = ok and img or false
end

local function drawBluePanel(x, y, w, h, a)
  ensureSettingsImg()
  pushStyle()
  if SETTINGS_IMG then
    spriteMode(CORNER)
    tint(255, a)
    sprite(SETTINGS_IMG, x, y, w, h, IMAGE_STRETCH)
    noTint()
  else
    noStroke()
    fill(60, 90, 150, a)
    rect(x, y, w, h)
  end
  popStyle()
end

----------------------------------------------------------------
-- nine slice, ported from VEP
----------------------------------------------------------------

local function sliceUp(src, c)
  if not src then return nil end
  local w, h = src.width, src.height
  c = math.min(c, math.floor(w / 3), math.floor(h / 3))
  local mw, mh = w - c * 2, h - c * 2
  return {
    c = c,
    bl = src:copy(1, 1, c, c),
    br = src:copy(w - c + 1, 1, c, c),
    tl = src:copy(1, h - c + 1, c, c),
    tr = src:copy(w - c + 1, h - c + 1, c, c),
    b = src:copy(c + 1, 1, mw, c),
    t = src:copy(c + 1, h - c + 1, mw, c),
    l = src:copy(1, c + 1, c, mh),
    r = src:copy(w - c + 1, c + 1, c, mh),
    m = src:copy(c + 1, c + 1, mw, mh)
  }
end

local function drawSlice(ns, x, y, w, h)
  if not ns then return end
  local c = ns.c
  local iw, ih = w - c * 2, h - c * 2
  pushStyle()
  spriteMode(CORNER)
  sprite(ns.bl, x, y, c, c)
  sprite(ns.br, x + w - c, y, c, c)
  sprite(ns.tl, x, y + h - c, c, c)
  sprite(ns.tr, x + w - c, y + h - c, c, c)
  if iw > 0 then
    sprite(ns.b, x + c, y, iw, c)
    sprite(ns.t, x + c, y + h - c, iw, c)
  end
  if ih > 0 then
    sprite(ns.l, x, y + c, c, ih)
    sprite(ns.r, x + w - c, y + c, c, ih)
  end
  if iw > 0 and ih > 0 then
    sprite(ns.m, x + c, y + c, iw, ih)
  end
  popStyle()
end

local PANEL_NS, SLOT_NS, PICK_NS

local function ensureSlices()
  if PANEL_NS ~= nil then return end
  local function grab(a)
    local ok, img = pcall(function() return readImage(a) end)
    if ok then return img end
    return nil
  end
  PANEL_NS = sliceUp(grab(asset.builtin.UI.Grey_Panel), 16) or false
  SLOT_NS = sliceUp(grab(asset.builtin.UI.Green_Panel), 16) or false
  PICK_NS = sliceUp(grab(asset.builtin.UI.Blue_Panel), 16) or false
end

local function hintText(txt, x, y, a)
  a = a or 255
  pushStyle()
  fill(HINT_SHADOW.r, HINT_SHADOW.g, HINT_SHADOW.b, a)
  text(txt, x + 1, y - 2)
  fill(HINT_COL.r, HINT_COL.g, HINT_COL.b, a)
  text(txt, x, y)
  popStyle()
end

local function hit(r, x, y)
  return r and x >= r.x and x <= r.x + r.w
  and y >= r.y and y <= r.y + r.h
end

local function rectOf(x, y, w, h)
  return {x = x, y = y, w = w, h = h}
end

local function panelBack(x, y, w, h, a)
  pushStyle()
  if PANEL_NS then
    tint(255, a)
    drawSlice(PANEL_NS, x, y, w, h)
    noTint()
  else
    noStroke()
    fill(70, 70, 70, a)
    rect(x, y, w, h)
  end
  popStyle()
end

----------------------------------------------------------------
-- layout
----------------------------------------------------------------

-- The close X keeps its own larger size; it and the gear
-- never appear at the same time.
function WorldBrowser.cornerRect(d)
  local top, right = 14, 14
  pcall(function()
    if layout.safeArea.top > 0 then top = 24 right = 24 end
  end)
  return rectOf(WIDTH - right - d, HEIGHT - top - d, d, d)
end

function WorldBrowser.gearRect()
  -- 2/3 of the original 52
  return WorldBrowser.cornerRect(35)
end

-- A world's palette stores block NAMES, so any world loads
-- under any pack. What cannot change mid-session is WHICH
-- pack is registered: blocks are registered once in setup and
-- their ids are fixed from then on.
--
-- So a same-pack load happens now. A different-pack load
-- writes the slot and the pack choice to storage and asks for
-- a restart -- viewer.restart() re-runs the project from
-- setup, which is exactly the fresh registration this needs.
-- setup then finds PendingSlot and loads that world instead
-- of the live autosave.
function WorldBrowser.loadWith(id, wantAlt)
  local cur = WorldState.num(readLocalData("BDCraft", 0))
  
  if wantAlt == cur then
    WorldBrowser.hide()
    pendingLoadSlot = id
    return
  end
  
  saveLocalData("BDCraft", wantAlt)
  saveLocalData("PendingSlot", id)
  WorldBrowser.hide()
  pendingRestart = true
end

function WorldBrowser.layoutSettings(x, y, w)
  WorldBrowser.setBlock = rectOf(x, y, w, SET_H)
  
  local rx = x + SET_PAD
  local rw = w - SET_PAD * 2
  local top = y + SET_H - SET_PAD - SET_ROW_H
  local step = SET_ROW_H + SET_GAP
  
  WorldBrowser.rowJoy = rectOf(rx, top, rw, SET_ROW_H)
  WorldBrowser.rowAlt = rectOf(rx, top - step, rw, SET_ROW_H)
  WorldBrowser.rowLeaves = rectOf(rx, top - step * 2, rw, SET_ROW_H)
  WorldBrowser.rowBuilder = rectOf(rx, top - step * 3, rw, SET_ROW_H)
  
  WorldBrowser.restartBtn = rectOf(
  x + w * 0.5 - 70, y + SET_PAD, 140, SET_RESTART_H)
end

function WorldBrowser.drawSettings()
  local blk = WorldBrowser.setBlock
  if not blk then return end
  drawBluePanel(blk.x, blk.y, blk.w, blk.h, 170)
  
  local buildOn = WorldState.num(readLocalData("BuilderMode", 0)) == 1
  local altOn = WorldState.num(readLocalData("BDCraft", 0)) == 1
  local leavesOn = WorldState.num(readLocalData("LeavesSolid", 0)) == 1
  local joyLeft = WorldState.num(readLocalData("JoyLeft", 0)) == 1
  
  -- Pending, not current. The label reflects what is in
  -- storage, and storage is what the next launch reads, so
  -- ticking the box while standing in the world you are
  -- about to leave says exactly what it is going to do.
  WorldBrowser.drawToggleRow(WorldBrowser.rowJoy,
  "Joystick On Left", joyLeft)
  WorldBrowser.drawToggleRow(WorldBrowser.rowBuilder,
  "Builder Mode (restart)", buildOn)
  WorldBrowser.drawToggleRow(WorldBrowser.rowAlt,
  "BDCraft Blocks Pack (restart)", altOn)
  WorldBrowser.drawToggleRow(WorldBrowser.rowLeaves,
  "Solid Leaves (restart)", leavesOn)
  
  drawBtn(WorldBrowser.restartBtn, "Restart Now", false, 16)
end

function WorldBrowser.touchedSettings(t)
  
  if hit(WorldBrowser.rowJoy, t.x, t.y) then
    local v = WorldState.num(readLocalData("JoyLeft", 0))
    saveLocalData("JoyLeft", (v == 1) and 0 or 1)
    if player and player.joystick then player.joystick:layout() end
    return true
  end
  
  if hit(WorldBrowser.rowBuilder, t.x, t.y) then
    WorldState.save()
    local v = WorldState.num(readLocalData("BuilderMode", 0))
    saveLocalData("BuilderMode", (v == 1) and 0 or 1)
    return true
  end
  
  if hit(WorldBrowser.rowAlt, t.x, t.y) then
    local v = WorldState.num(readLocalData("BDCraft", 0))
    saveLocalData("BDCraft", (v == 1) and 0 or 1)
    return true
  end
  
  if hit(WorldBrowser.rowLeaves, t.x, t.y) then
    local v = WorldState.num(readLocalData("LeavesSolid", 0))
    saveLocalData("LeavesSolid", (v == 1) and 0 or 1)
    return true
  end
  
  if hit(WorldBrowser.restartBtn, t.x, t.y) then
    WorldState.save()
    WorldBrowser.hide()
    pendingRestart = true
    return true
  end
  
  return false
end

function WorldBrowser.layoutGrid()
  ensureSlices()
  
  local shortest = math.min(WIDTH, HEIGHT)
  local margin = 12
  local s = shortest * 0.014
  local b = shortest * 0.014
  
  WorldBrowser.spacing = s
  WorldBrowser.border = b
  
  local landscape = WIDTH > HEIGHT
  if landscape then
    WorldBrowser.rows, WorldBrowser.cols = 3, 4
  else
    WorldBrowser.rows, WorldBrowser.cols = 4, 3
  end
  local cols, rows = WorldBrowser.cols, WorldBrowser.rows
  
  local nb, bh, gap = 48, 48, 12
  local bw = 104
  
  -- Cell size is whichever constraint bites first. The
  -- settings block comes out of the same budget the grid
  -- draws from -- that subtraction IS "shrink the previews
  -- to make room", expressed as arithmetic.
  local gByW, gByH
  
  if landscape then
    local availW = WIDTH - SET_W_LAND - 14 - (bw + 14) - margin * 2
    gByW = (availW - b * 2 - (cols - 1) * s) / cols
    local availH = HEIGHT - nb - gap - margin * 2
    gByH = (availH - b * 2 - (rows - 1) * s) / rows
  else
    local availW = WIDTH - margin * 2
    gByW = (availW - b * 2 - (cols - 1) * s) / cols
    local availH = HEIGHT - SET_H - gap - nb - gap - bh - 14 - margin * 2
    gByH = (availH - b * 2 - (rows - 1) * s) / rows
  end
  
  local g = math.min(gByW, gByH)
  if g < MIN_CELL then g = MIN_CELL end
  WorldBrowser.gridSize = g
  
  local panelW = cols * g + b * 2 + (cols - 1) * s
  local panelH = rows * g + b * 2 + (rows - 1) * s
  
  if landscape then
    -- settings left, grid centre, buttons right
    local blockW = SET_W_LAND + 14 + panelW + 14 + bw
    local x0 = WIDTH * 0.5 - blockW * 0.5
    local midY = HEIGHT * 0.5 + (nb + gap) * 0.5
    
    WorldBrowser.panel = rectOf(x0 + SET_W_LAND + 14,
    midY - panelH * 0.5, panelW, panelH)
    
    WorldBrowser.layoutSettings(x0,
    WorldBrowser.panel.y + panelH - SET_H, SET_W_LAND)
    
    local bx = WorldBrowser.panel.x + panelW + 14
    local byTop = WorldBrowser.panel.y + panelH - bh
    WorldBrowser.newBtn = rectOf(bx, byTop, bw, bh)
    WorldBrowser.saveAs = rectOf(bx, byTop - (bh + gap), bw, bh)
    WorldBrowser.del = rectOf(bx, byTop - (bh + gap) * 2, bw, bh)
  else
    -- settings on top, then grid, then nav, then buttons
    local totalH = SET_H + gap + panelH + gap + nb + gap + bh
    local top = HEIGHT * 0.5 + totalH * 0.5
    
    local setW = math.min(360, WIDTH - margin * 2)
    WorldBrowser.layoutSettings(WIDTH * 0.5 - setW * 0.5,
    top - SET_H, setW)
    
    WorldBrowser.panel = rectOf(WIDTH * 0.5 - panelW * 0.5,
    top - SET_H - gap - panelH, panelW, panelH)
    
    local totalW = bw * 3 + gap * 2
    local sx = WIDTH * 0.5 - totalW * 0.5
    local by = WorldBrowser.panel.y - gap - nb - gap - bh
    WorldBrowser.newBtn = rectOf(sx, by, bw, bh)
    WorldBrowser.saveAs = rectOf(sx + bw + gap, by, bw, bh)
    WorldBrowser.del = rectOf(sx + (bw + gap) * 2, by, bw, bh)
  end
  
  -- slot rects, top row first
  WorldBrowser.cells = {}
  local i = 0
  for y = 1, rows do
    for x = 1, cols do
      i = i + 1
      local row = rows + 1 - y
      WorldBrowser.cells[i] = rectOf(
      WorldBrowser.panel.x + b + (g + s) * (x - 1),
      WorldBrowser.panel.y + b + (g + s) * (row - 1),
      g, g)
    end
  end
  
  -- nav row, centred just below the grid in both orientations
  local p = WorldBrowser.panel
  local navY = p.y - gap - nb
  WorldBrowser.prev = rectOf(p.x + p.w * 0.5 - nb - 8, navY, nb, nb)
  WorldBrowser.next = rectOf(p.x + p.w * 0.5 + 8, navY, nb, nb)
  
  WorldBrowser.close = WorldBrowser.cornerRect(44)
  
  -- dialog buttons, centred
  local dw = 132
  local dy = HEIGHT * 0.5 - bh * 0.5
  WorldBrowser.okBtn = rectOf(WIDTH * 0.5 - dw - 8, dy, dw, bh)
  WorldBrowser.noBtn = rectOf(WIDTH * 0.5 + 8, dy, dw, bh)
  WorldBrowser.cancelBtn = rectOf(WIDTH * 0.5 - dw * 0.5,
  dy - bh - 12, dw, bh)
  
  WorldBrowser.laidOutW, WorldBrowser.laidOutH = WIDTH, HEIGHT
end

----------------------------------------------------------------
-- open / close
----------------------------------------------------------------

function WorldBrowser.show()
  WorldBrowser.visible = true
  WorldBrowser.page = 1
  WorldBrowser.deleteMode = false
  WorldBrowser.naming = false
  WorldBrowser.confirm = nil
  WorldBrowser.choice = nil
  WorldBrowser.notice = nil
  WorldBrowser.pendingLoad = nil
  WorldBrowser.refresh()
  WorldBrowser.layoutGrid()
end

function WorldBrowser.hide()
  WorldBrowser.visible = false
  WorldBrowser.deleteMode = false
  WorldBrowser.confirm = nil
  WorldBrowser.choice = nil
  WorldBrowser.notice = nil
  WorldBrowser.pendingLoad = nil
  if WorldBrowser.naming then
    WorldBrowser.naming = false
    pcall(hideKeyboard)
  end
end

function WorldBrowser.refresh()
  WorldBrowser.models = WorldSlots.readIndex()
  for _, m in ipairs(WorldBrowser.models) do
    if m.icon == nil then m.icon = WorldSlots.thumb(m.id) or false end
  end
  local perPage = WorldBrowser.rows * WorldBrowser.cols
  WorldBrowser.pageCount = math.max(1,
  math.ceil(#WorldBrowser.models / perPage))
  if WorldBrowser.page > WorldBrowser.pageCount then
    WorldBrowser.page = WorldBrowser.pageCount
  end
end

function WorldBrowser.modelAt(i)
  local perPage = WorldBrowser.rows * WorldBrowser.cols
  local start = (WorldBrowser.page - 1) * perPage
  return WorldBrowser.models[start + i]
end

----------------------------------------------------------------
-- naming
----------------------------------------------------------------

function WorldBrowser.openSaveDialog()
  WorldBrowser.naming = true
  -- Seconds, not minutes. WorldSlots.save treats a matching
  -- name as a deliberate overwrite, and typedName() falls
  -- back to this default whenever the keyboard gave nothing.
  -- At minute resolution two saves moments apart got the same
  -- name and the second silently replaced the first.
  WorldBrowser.nameDefault = os.date("%b %d, %H:%M:%S")
  pcall(showKeyboard)
end

function WorldBrowser.typedName()
  local buf = ""
  pcall(function() buf = tostring(keyboardBuffer() or "") end)
  buf = buf:gsub("%c", " "):match("^%s*(.-)%s*$")
  if buf == "" then return WorldBrowser.nameDefault end
  return buf
end

function WorldBrowser.commitSave()
  local name = WorldBrowser.typedName()
  WorldBrowser.naming = false
  pcall(hideKeyboard)
  -- deferred: the actual write needs a thumbnail, and that
  -- has to be captured from draw()
  WorldBrowser.pendingSave = name
end

function WorldBrowser.cancelSave()
  WorldBrowser.naming = false
  pcall(hideKeyboard)
end

-- Called once per frame from draw, after scene:draw, so the
-- capture and the write both land on the normal frame.
function WorldBrowser.processPending()
  local name = WorldBrowser.pendingSave
  if not name then return end
  WorldBrowser.pendingSave = nil
  
  local thumb = WorldSlots.capture(256)
  local id, err = WorldSlots.save(name, thumb)
  if not id then
    print("save failed: "..tostring(err))
  end
  WorldBrowser.refresh()
end

----------------------------------------------------------------
-- draw
----------------------------------------------------------------

function WorldBrowser.drawGear()
  local r = WorldBrowser.gearRect()
  pushStyle()
  noStroke()
  fill(120, 120, 128, 165)
  ellipse(r.x + r.w * 0.5, r.y + r.h * 0.5, r.w)
  fill(255, 240)
  font(HINT_FONT)
  fontSize(r.w * 0.55)
  textMode(CENTER)
  textAlign(CENTER)
  text(GEAR, r.x + r.w * 0.5, r.y + r.h * 0.5)
  popStyle()
end

function drawBtn(r, label, active, size)
  if not r then return end
  local ns = active and PICK_NS or PANEL_NS
  pushStyle()
  if ns then
    tint(BTN_TINT.r, BTN_TINT.g, BTN_TINT.b,
    active and BTN_A_ACTIVE or BTN_A_NORMAL)
    drawSlice(ns, r.x, r.y, r.w, r.h)
    noTint()
  else
    noStroke()
    fill(150, 150, 150, active and BTN_A_ACTIVE or BTN_A_NORMAL)
    rect(r.x, r.y, r.w, r.h)
  end
  font(HINT_FONT)
  fontSize(size or 20)
  textMode(CENTER)
  textAlign(CENTER)
  hintText(label, r.x + r.w * 0.5, r.y + r.h * 0.5, 255)
  popStyle()
end

function WorldBrowser.draw()
  if not WorldBrowser.visible then
    if gameState ~= "building" then
      WorldBrowser.drawGear()
    end
    return
  end
  
  if WorldBrowser.laidOutW ~= WIDTH
  or WorldBrowser.laidOutH ~= HEIGHT then
    WorldBrowser.layoutGrid()
  end
  
  local p = WorldBrowser.panel
  panelBack(p.x, p.y, p.w, p.h, CHROME_A)
  
  -- slots
  local perPage = WorldBrowser.rows * WorldBrowser.cols
  for i = 1, perPage do
    local r = WorldBrowser.cells[i]
    local m = WorldBrowser.modelAt(i)
    if r and m then
      WorldBrowser.drawSlot(r, m)
    end
  end
  
  local modal = WorldBrowser.naming or WorldBrowser.confirm
  or WorldBrowser.choice or WorldBrowser.notice
  
  if not modal then
    WorldBrowser.drawSettings()
    drawBtn(WorldBrowser.newBtn, "New World", false, 17)
    drawBtn(WorldBrowser.saveAs, "Save As...", false, 17)
    drawBtn(WorldBrowser.del, "Delete", WorldBrowser.deleteMode, 17)
    if WorldBrowser.pageCount > 1 then
      drawBtn(WorldBrowser.prev, "<", false, 26)
      drawBtn(WorldBrowser.next, ">", false, 26)
    end
    WorldBrowser.drawClose()
  end
  
  if #WorldBrowser.models == 0 then
    pushStyle()
    font(HINT_FONT)
    fontSize(22)
    textMode(CENTER)
    textAlign(CENTER)
    hintText("No saved worlds yet", WIDTH * 0.5,
    p.y + p.h * 0.5, 220)
    popStyle()
  end
  
  if WorldBrowser.naming then
    WorldBrowser.drawNaming()
  elseif WorldBrowser.confirm then
    WorldBrowser.drawConfirm()
  elseif WorldBrowser.choice then
    WorldBrowser.drawChoice()
  elseif WorldBrowser.notice then
    WorldBrowser.drawNotice()
  end
end

function WorldBrowser.drawNotice()
  pushStyle()
  noStroke()
  fill(0, 200)
  rect(0, 0, WIDTH, HEIGHT)
  font(HINT_FONT)
  fontSize(22)
  textMode(CENTER)
  textAlign(CENTER)
  textWrapWidth(WIDTH * 0.8)
  hintText(WorldBrowser.notice.msg, WIDTH * 0.5,
  HEIGHT * 0.5 + 80, 255)
  popStyle()
  drawBtn(WorldBrowser.okBtn, "OK", false)
end

function WorldBrowser.drawChoice()
  local c = WorldBrowser.choice
  pushStyle()
  noStroke()
  fill(0, 190)
  rect(0, 0, WIDTH, HEIGHT)
  font(HINT_FONT)
  fontSize(24)
  textMode(CENTER)
  textAlign(CENTER)
  hintText(c.msg, WIDTH * 0.5, HEIGHT * 0.5 + 70, 255)
  popStyle()
  drawBtn(WorldBrowser.okBtn, c.a.label, false, 18)
  drawBtn(WorldBrowser.noBtn, c.b.label, false, 18)
  drawBtn(WorldBrowser.cancelBtn, "Cancel", false)
end

function WorldBrowser.drawClose()
  local r = WorldBrowser.close
  local cx, cy = r.x + r.w * 0.5, r.y + r.h * 0.5
  local rad = r.w * 0.5
  pushStyle()
  noStroke()
  fill(120, 120, 128, 175)
  ellipse(cx, cy, rad * 2)
  stroke(255, 255, 255, 240)
  strokeWidth(math.max(2, rad * 0.16))
  lineCapMode(ROUND)
  local k = rad * 0.34
  line(cx - k, cy - k, cx + k, cy + k)
  line(cx - k, cy + k, cx + k, cy - k)
  popStyle()
end

function WorldBrowser.drawCheckbox(x, y, size, on)
  pushStyle()
  noStroke()
  
  local r = size * 0.22
  if on then
    fill(70, 170, 235, 255)
  else
    fill(40, 40, 48, 220)
  end
  rect(x + r, y, size - r * 2, size)
  rect(x, y + r, size, size - r * 2)
  ellipse(x + r, y + r, r * 2)
  ellipse(x + size - r, y + r, r * 2)
  ellipse(x + r, y + size - r, r * 2)
  ellipse(x + size - r, y + size - r, r * 2)
  
  -- A thin outline drawn as four straight segments, not two
  -- overlapping rect() strokes. rect() strokes all four sides
  -- of each rectangle, so two full outlines crossing in the
  -- middle drew a waffle grid across the whole box -- that
  -- was the "white rects" artifact, nothing to do with
  -- nine-slice or sprites.
  stroke(255, 255, 255, on and 200 or 110)
  strokeWidth(2)
  lineCapMode(ROUND)
  line(x + r, y + 1, x + size - r, y + 1)
  line(x + r, y + size - 1, x + size - r, y + size - 1)
  line(x + 1, y + r, x + 1, y + size - r)
  line(x + size - 1, y + r, x + size - 1, y + size - r)
  
  if on then
    stroke(255, 255, 255, 255)
    strokeWidth(math.max(2.5, size * 0.13))
    lineCapMode(ROUND)
    line(x + size * 0.24, y + size * 0.52,
    x + size * 0.43, y + size * 0.30)
    line(x + size * 0.43, y + size * 0.30,
    x + size * 0.78, y + size * 0.72)
  end
  popStyle()
end

function WorldBrowser.drawToggleRow(r, label, on)
  WorldBrowser.drawCheckbox(r.x,
  r.y + r.h * 0.5 - SET_BOX * 0.5, SET_BOX, on)
  
  pushStyle()
  font(HINT_FONT)
  fontSize(16)
  textMode(CORNER)
  textAlign(LEFT)
  textWrapWidth(0)
  hintText(label, r.x + SET_BOX + 12, r.y + r.h * 0.5 - 8, 245)
  popStyle()
end

function WorldBrowser.drawSlot(r, m)
  pushStyle()
  if SLOT_NS then
    tint(SLOT_TINT.r, SLOT_TINT.g, SLOT_TINT.b, SLOT_A)
    drawSlice(SLOT_NS, r.x, r.y, r.w, r.h)
    noTint()
  else
    noStroke()
    fill(SLOT_TINT.r, SLOT_TINT.g, SLOT_TINT.b, SLOT_A)
    rect(r.x, r.y, r.w, r.h)
  end
  
  if m.icon then
    spriteMode(CORNER)
    tint(255, 255)
    sprite(m.icon, r.x + 5, r.y + 5, r.w - 10, r.h - 10)
    noTint()
  end
  
  font(HINT_FONT)
  fontSize(math.max(16, r.h * 0.10))
  textMode(CORNER)
  textWrapWidth(r.w - 8)
  local tw = textSize(m.name)
  hintText(m.name, r.x + r.w * 0.5 - tw * 0.5, r.y + 4, TEXT_A)
  
  if WorldBrowser.deleteMode then
    local br = math.max(11, r.w * 0.13)
    local bx, by = r.x + br + 5, r.y + r.h - br - 5
    noStroke()
    fill(230, 40, 40, 235)
    ellipse(bx, by, br * 2)
    stroke(255, 255, 255, 240)
    strokeWidth(math.max(2, br * 0.22))
    lineCapMode(ROUND)
    local k = br * 0.4
    line(bx - k, by - k, bx + k, by + k)
    line(bx - k, by + k, bx + k, by - k)
  end
  popStyle()
end

function WorldBrowser.drawNaming()
  pushStyle()
  noStroke()
  fill(0, 0, 0, 175)
  rect(0, 0, WIDTH, HEIGHT)
  
  local fw = math.min(360, WIDTH * 0.8)
  local fh = 44
  local fx = WIDTH * 0.5 - fw * 0.5
  local fy = WorldBrowser.okBtn.y + WorldBrowser.okBtn.h + 16
  
  fill(240, 240, 240, 235)
  rect(fx, fy, fw, fh)
  
  font(HINT_FONT)
  fontSize(20)
  textMode(CENTER)
  textAlign(CENTER)
  hintText("Name this world", WIDTH * 0.5, fy + fh + 22, 235)
  
  fill(20, 20, 20, 255)
  textMode(CORNER)
  textAlign(LEFT)
  textWrapWidth(fw - 16)
  text(WorldBrowser.typedName(), fx + 8, fy + fh * 0.5 - 10)
  popStyle()
  
  drawBtn(WorldBrowser.okBtn, "Save", false)
  drawBtn(WorldBrowser.noBtn, "Cancel", false)
end

function WorldBrowser.drawConfirm()
  pushStyle()
  noStroke()
  fill(0, 190)
  rect(0, 0, WIDTH, HEIGHT)
  font(HINT_FONT)
  fontSize(24)
  textMode(CENTER)
  textAlign(CENTER)
  hintText(WorldBrowser.confirm.msg, WIDTH * 0.5,
  HEIGHT * 0.5 + 70, 255)
  popStyle()
  drawBtn(WorldBrowser.okBtn, WorldBrowser.confirm.ok, false, 18)
  drawBtn(WorldBrowser.noBtn, "Cancel", false)
end

function WorldBrowser.touched(t)
  if not WorldBrowser.visible then
    if gameState == "building" then
      return false
    end
    if hit(WorldBrowser.gearRect(), t.x, t.y) then
      if t.state == ENDED then WorldBrowser.show() end
      return true
    end
    return false
  end
  
  if t.state ~= ENDED then return true end
  
  -- modal layers first
  if WorldBrowser.notice then
    if hit(WorldBrowser.okBtn, t.x, t.y) then
      WorldBrowser.notice = nil
      WorldBrowser.hide()
    end
    return true
  end
  
  if WorldBrowser.naming then
    if hit(WorldBrowser.okBtn, t.x, t.y) then
      WorldBrowser.commitSave()
    elseif hit(WorldBrowser.noBtn, t.x, t.y) then
      WorldBrowser.cancelSave()
    end
    return true
  end
  
  if WorldBrowser.choice then
    local c = WorldBrowser.choice
    if hit(WorldBrowser.okBtn, t.x, t.y) then
      WorldBrowser.choice = nil
      c.a.fn()
    elseif hit(WorldBrowser.noBtn, t.x, t.y) then
      WorldBrowser.choice = nil
      c.b.fn()
    elseif hit(WorldBrowser.cancelBtn, t.x, t.y) then
      WorldBrowser.choice = nil
    end
    return true
  end
  
  if WorldBrowser.confirm then
    if hit(WorldBrowser.okBtn, t.x, t.y) then
      local fn = WorldBrowser.confirm.fn
      WorldBrowser.confirm = nil
      if fn then fn() end
    elseif hit(WorldBrowser.noBtn, t.x, t.y) then
      WorldBrowser.confirm = nil
    end
    return true
  end
  
  if hit(WorldBrowser.close, t.x, t.y) then
    WorldBrowser.hide()
    return true
  end
  
  if WorldBrowser.touchedSettings(t) then return true end
  
  if hit(WorldBrowser.newBtn, t.x, t.y) then
    WorldBrowser.deleteMode = false
    WorldBrowser.choice = {
      msg = "New world --\ncurrent world is replaced",
      -- Deferred, not called here. Everything downstream
      -- destroys entities, unparents the camera, strips
      -- rigidbody components and resizes the volume, and
      -- doing that from inside a touch callback rather than
      -- the normal frame is a native crash waiting to happen.
      a = {label = "Flat Plain",
        fn = function()
          WorldBrowser.hide()
          pendingNewWorld = "flat"
        end},
      b = {label = "Homestead",
        fn = function()
          WorldBrowser.hide()
          pendingNewWorld = "homestead"
        end}
    }
    return true
  end
  
  if hit(WorldBrowser.saveAs, t.x, t.y) then
    if WorldBrowser.deleteMode then
      WorldBrowser.deleteMode = false
    else
      WorldBrowser.openSaveDialog()
    end
    return true
  end
  
  if hit(WorldBrowser.del, t.x, t.y) then
    WorldBrowser.deleteMode = not WorldBrowser.deleteMode
    return true
  end
  
  if WorldBrowser.pageCount > 1 then
    if hit(WorldBrowser.prev, t.x, t.y) then
      WorldBrowser.page = WorldBrowser.page - 1
      if WorldBrowser.page < 1 then
        WorldBrowser.page = WorldBrowser.pageCount
      end
      return true
    end
    if hit(WorldBrowser.next, t.x, t.y) then
      WorldBrowser.page = WorldBrowser.page + 1
      if WorldBrowser.page > WorldBrowser.pageCount then
        WorldBrowser.page = 1
      end
      return true
    end
  end
  
  local perPage = WorldBrowser.rows * WorldBrowser.cols
  for i = 1, perPage do
    local r = WorldBrowser.cells[i]
    local m = WorldBrowser.modelAt(i)
    if r and m and hit(r, t.x, t.y) then
      if WorldBrowser.deleteMode then
        WorldBrowser.confirm = {
          msg = 'Delete "'..m.name..'"\npermanently?',
          ok = "Delete",
          fn = function()
            WorldSlots.delete(m.id)
            WorldBrowser.deleteMode = false
            WorldBrowser.refresh()
          end
        }
      else
        WorldBrowser.choice = {
          msg = "Load \""..m.name.."\"\nwith which pack?",
          a = {label = "Default Pack",
            fn = function() WorldBrowser.loadWith(m.id, 0) end},
          b = {label = "Alt Pack",
            fn = function() WorldBrowser.loadWith(m.id, 1) end}
        }
      end
      return true
    end
  end
  
  return true
end
