--[[
This script demonstrates Glu's canvas functions.
Authors: Andrew Trevorrow (andrew@trevorrow.com) and Chris Rowett (crowett@gmail.com).
--]]

local glu = glu()
local cv = canvas()
-- require "gplus.strict"
local gp = require "gplus"
local split = gp.split
local int = gp.int
local cp = require "cplus"
local maketext = cp.maketext
local pastetext = cp.pastetext

-- minor optimizations
local rand = math.random
local floor = math.floor
local sin = math.sin
local cos = math.cos
local abs = math.abs

local wd, ht            -- canvas's current width and height
local toggle = 0        -- for toggling alpha blending
local align = "right"   -- for text alignment
local transbg = 0       -- for text transparent background
local shadow = 0        -- for text shadow

local demofont = "default-bold"
local demofontsize = 11

-- buttons for main menu (created in create_buttons)
local blend_button
local copy_button
local cursor_button
local line_button
local set_button
local fill_button
local mouse_button
local multiline_button
local pos_button
local render_button
local replace_button
local save_button
local scale_button
local sound_button
local text_button
local transition_button
local exit_button

local return_to_main_menu = false

-- timing
local tstart = gp.timerstart
local tsave = gp.timersave
local tvalueall = gp.timervalueall

--------------------------------------------------------------------------------

local function create_canvas()
    cv.create(1000, 1000)
    -- main_menu() will resize the canvas to just fit buttons and text
end

--------------------------------------------------------------------------------

local function demotext(x, y, text)
    local oldfont, oldsize = cv.font(demofont, demofontsize)
    local oldblend = cv.blend(1)
    maketext(text)
    pastetext(x, y)
    cv.blend(oldblend)
    cv.font(oldfont, oldsize)
end

--------------------------------------------------------------------------------

local function repeat_test(extratext, palebg)
    extratext = extratext or ""
    palebg = palebg or false

    -- explain how to repeat test or return to the main menu
    local text = "Hit the space bar to repeat this test"..extratext..".\n"
    if glu.os() == "Mac" then
        text = text.."Click or hit the return key to return to the main menu."
    else
        text = text.."Click or hit the enter key to return to the main menu."
    end
    local oldfont, oldsize = cv.font(demofont, demofontsize)
    local oldblend = cv.blend(1)
    if palebg then
        -- draw black text
        cv.rgba(cp.black)
        local _, h = maketext(text)
        pastetext(10, ht - 10 - h)
    else
        -- draw white text with a black shadow
        local _, h = maketext(text, nil, cp.white, 2, 2)
        pastetext(10, ht - 10 - h)
    end
    cv.blend(oldblend)
    cv.font(oldfont, oldsize)

    glu.update()

    while true do
        local event = glu.getevent()
        if event:find("^cclick") or event == "key return none" then
            -- return to main menu rather than repeat test
            return_to_main_menu = true
            return false
        elseif event == "key space none" then
            -- repeat current test
            return true
        else
            -- might be a keyboard shortcut
            glu.doevent(event)
        end
    end
end

--------------------------------------------------------------------------------

local function ms(t)
    return string.format("%.2fms", t)
end

--------------------------------------------------------------------------------

local day = 1

local function test_transitions()
    -- create a clip from the menu screen
    local oldblend = cv.blend(0)
    cv.rgba(cp.black)
    cv.line(0, 0, (wd - 1), 0, (wd - 1), (ht - 1), 0, (ht - 1), 0, 0)
    cv.copy(0, 0, wd, ht, "bg")

    -- create the background clip
    cv.rgba(cp.blue)
    cv.fill()
    local oldfont, oldsize = cv.font("mono", 100)
    cv.rgba(cp.yellow)
    local w,h = maketext("Glu")
    cv.blend(1)
    pastetext(floor((wd - w) / 2), floor((ht - h) / 2))
    cv.copy(0, 0, wd, ht, "fg")
    cv.blend(0)
    local pause = 0

    ::restart::
    cv.paste("bg", 0, 0)
    cv.update()
    local t = glu.millisecs()
    while glu.millisecs() - t < pause do end

    -- monday: exit stage left
    if day == 1 then
        for x = 0, wd, 10 do
            t = glu.millisecs()
            cv.paste("fg", 0, 0)
            cv.paste("bg", -x, 0)
            cv.update()
            while glu.millisecs() - t < 15 do end
        end

    -- tuesday: duck and cover
    elseif day == 2 then
        cv.rgba(cp.white)
        for y = 0, ht, 10 do
            t = glu.millisecs()
            cv.paste("fg", 0, 0)
            cv.paste("bg", 0, y)
            cv.update()
            while glu.millisecs() - t < 15 do end
        end

    -- wednesday: slide to the right
    elseif day == 3 then
        for y = 0, ht, 8 do
            cv.copy(0, y, wd, 8, "bg"..y)
        end
        local d
        local p
        for x = 0, wd * 2, 20 do
            t = glu.millisecs()
            cv.paste("fg", 0, 0)
            d = 0
            for y = 0, ht, 8 do
                 p = x + 10 * d - wd
                 if p < 0 then p = 0 end
                 cv.paste("bg"..y, p, y)
                 d = d + 1
            end
            cv.update()
            while glu.millisecs() - t < 15 do end
        end
        for y = 0, ht, 8 do
            cv.delete("bg"..y)
        end

    -- thursday: as if by magic
    elseif day == 4 then
        cv.paste("fg", 0, 0)
        cv.copy(0, 0, wd, ht, "blend")
        for a = 0, 255, 5 do
            t = glu.millisecs()
            cv.blend(0)
            cv.paste("bg", 0, 0)
            cv.blend(1)
            cv.rgba(0, 0, 0, a)
            cv.target("blend")
            cv.replace("*r *g *b *")
            cv.target()
            cv.paste("blend", 0, 0)
            cv.update()
            while glu.millisecs() - t < 15 do end
        end
        cv.delete("blend")

    -- friday: you spin me round
    elseif day == 5 then
        local x, y, r
        local deg2rad = 57.3
        for a = 0, 360, 6 do
            t = glu.millisecs()
            r = wd / 360 * a
            x = floor(r * sin(a / deg2rad))
            y = floor(r * cos(a / deg2rad))
            cv.paste("fg", 0, 0)
            cv.paste("bg", x, y)
            cv.update()
            while glu.millisecs() - t < 15 do end
       end

    -- saturday: through the square window
    elseif day == 6 then
        for x = 1, wd / 2, 4 do
            t = glu.millisecs()
            local y = x * (ht / wd)
            cv.blend(0)
            cv.paste("bg", 0, 0)
            cv.rgba(0, 0, 0, 0)
            cv.fill((wd / 2 - x)//1, (ht / 2 - y)//1, (x * 2)//1, (y * 2)//1)
            cv.copy(0, 0, wd, ht, "trans")
            cv.paste("fg", 0, 0)
            cv.blend(1)
            cv.paste("trans", 0, 0)
            cv.update()
            while glu.millisecs() - t < 15 do end
        end
        cv.delete("trans")

    -- sunday: people in glass houses
    elseif day == 7 then
        local box = {}
        local n = 1
        local tx, ty
        for y = 0, ht, 16 do
            for x = 0, wd, 16 do
                tx = x + rand(0, floor(wd / 8)) - wd / 16
                ty = ht + rand(0, floor(ht / 2))
                local entry = {}
                entry[1] = x
                entry[2] = y
                entry[3] = tx
                entry[4] = ty
                box[n] = entry
                cv.copy(x, y, 16, 16, "sprite"..n)
                n = n + 1
            end
        end
        for i = 0, 100 do
            t = glu.millisecs()
            local a = i / 100
            local x, y
            cv.paste("fg", 0, 0)
            for j = 1, #box do
                x = box[j][1]
                y = box[j][2]
                tx = box[j][3]
                ty = box[j][4]
                cv.paste("sprite"..j, (x * (1 - a) + tx * a)//1,
                                      (y * (1 - a) + ty * a)//1)
            end
            cv.update()
            while glu.millisecs() - t < 15 do end
        end
        for i = 1, #box do
            cv.delete("sprite"..i)
        end

    -- bonus day: objects in the mirror are closer than they appear
    elseif day == 8 then
        for x = 1, 100 do
            t = glu.millisecs()
            cv.paste("bg", 0, 0)
            cv.scale("fg", (wd / 2 - ((wd / 2) * x / 100))//1,
                           (ht / 2 - ((ht / 2) * x / 100))//1,
                           (wd * x / 100)//1,
                           (ht * x / 100)//1, "best")
            cv.update()
            while glu.millisecs() - t < 15 do end
        end
    end

    -- next day
    day = day + 1
    if day == 9 then
        day = 1
    end

    cv.paste("fg", 0, 0)
    cv.update()
    pause = 300
    if repeat_test(" using a different transition", false) then goto restart end

    -- restore settings
    cv.blend(oldblend)
    cv.font(oldfont, oldsize)
    cv.delete("fg")
    cv.delete("bg")
end

--------------------------------------------------------------------------------

local curs = 0

local function test_cursors()
    ::restart::

    local c
    curs = curs + 1
    if curs == 1 then c = "pencil" end
    if curs == 2 then c = "pick" end
    if curs == 3 then c = "cross" end
    if curs == 4 then c = "hand" end
    if curs == 5 then c = "zoomin" end
    if curs == 6 then c = "zoomout" end
    if curs == 7 then c = "arrow" end
    if curs == 8 then c = "wait" end
    if curs == 9 then c = "hidden" curs = 0 end
    cv.cursor(c)

    cv.rgba(cp.white)
    cv.fill()
    -- create a transparent hole
    cv.rgba(0, 0, 0, 0)
    cv.fill(100, 100, 100, 100)

    cv.rgba(cp.black)
    demotext(10, 10,
        "cursor "..c.."\n\n"..
        "The canvas cursor will change to Glu's usual arrow cursor\n"..
        "if it moves outside the canvas or over a transparent pixel:"
    )

    if repeat_test(" using a different cursor", true) then goto restart end
    curs = 0
end

--------------------------------------------------------------------------------

local pos = 0

local function test_positions()
    ::restart::

    pos = pos + 1
    if pos == 1 then cv.position("topleft") end
    if pos == 2 then cv.position("topright") end
    if pos == 3 then cv.position("bottomright") end
    if pos == 4 then cv.position("bottomleft") end
    if pos == 5 then cv.position("middle") end
    if pos == 6 then cv.position("middletop") end
    if pos == 7 then cv.position("middleright") end
    if pos == 8 then cv.position("middlebottom") end
    if pos == 9 then cv.position("middleleft") pos = 0 end

    cv.rgba(cp.white)
    cv.fill()
    cv.rgba(0, 0, 255, 128)
    cv.fill(1, 1, -2, -2)

    local text =
[[The canvas can be positioned in the middle
of the viewport, or at any corner, or in the
middle of any edge.]]
    local oldfont, oldsize = cv.font(demofont, demofontsize)
    local oldblend = cv.blend(1)
    local w, h
    local fontsize = 30
    cv.rgba(cp.white)
    -- reduce fontsize until text nearly fills canvas width
    repeat
        fontsize = fontsize - 1
        cv.font("", fontsize)
        w, h = maketext(text)
    until w <= wd - 10
    cv.rgba(cp.black)
    maketext(text, "shadow")
    local x = int((wd - w) / 2)
    local y = int((ht - h) / 2)
    pastetext(x+2, y+2, cp.identity, "shadow")
    pastetext(x, y)
    cv.blend(oldblend)
    cv.font(oldfont, oldsize)

    if repeat_test(" using a different position") then goto restart end
    pos = 0
end

--------------------------------------------------------------------------------

local replace = 1
local replacements = {
    [1] = { col = {255, 255, 0, 255}, cmd = "255 0 0 255", desc = "replace red pixels with yellow" },
    [2] = { col = {255, 255, 0, 128}, cmd = "0 0 0 255", desc = "replace black with semi-transparent yellow" },
    [3] = { col = {255, 255, 0, 255}, cmd = "!255 0 0 255", desc = "replace non-red pixels with yellow" },
    [4] = { col = {}, cmd = "*g *r *# *#", desc = "swap red and green components" },
    [5] = { col = {0, 0, 0, 128}, cmd = "*# *# *# *", desc = "make all pixels semi-transparent" },
    [6] = { col = {}, cmd = "*r- *g- *b- *#", desc = "invert clip r g b components" },
    [7] = { col = {255, 255, 0, 255}, cmd = "255 0 0 255-64", desc = "replace red pixels with rgba -64 alpha" },
    [8] = { col = {}, cmd = "*# *# *# *#-", desc = "make transparent pixels opaque and vice versa" },
    [9] = { col = {255, 255, 0, 255}, cmd = "* * * !255", desc = "replace non-opaque pixels with yellow" },
    [10] = { col = {255, 255, 255, 255}, cmd = "*a *a *a *", desc = "convert alpha to grayscale" },
    [11] = { col = {}, cmd = "*a *b *g *r", desc = "convert RGBA to ABGR" },
    [12] = { col = {255, 255, 0, 255}, cmd = "0 255 0 !255", desc = "replace non-opaque green with yellow" },
    [13] = { col = {255, 255, 0, 255}, cmd = "* * * *", desc = "fill (replace any pixel with yellow)" },
    [14] = { col = {}, cmd = "*# *# *# *#", desc = "no-op (replace pixels with clip pixels)" },
    [15] = { col = {0, 0, 0, 128}, cmd = "*# *# *# *", desc = "make whole canvas semi-transparent", canvas = true },
    [16] = { col = {}, cmd = "*#+64 *#+64 *#+64 *#", desc = "make pixels brighter" },
    [17] = { col = {}, cmd = "*# *# *# *#-64", desc = "make pixels more transparent" },
    [18] = { col = {}, cmd = "*#++ *#++ *#++ *#", desc = "fade to white using increment", canvas = true, loop = true },
    [19] = { col = {}, cmd = "*#-4 *#-4 *#-4 *#", desc = "fast fade to black", canvas = true, loop = true }
}

local function test_replace()
    ::restart::

    -- create clip
    local _
    local oldblend = cv.blend(0)
    cv.rgba(0, 0, 0, 0)
    cv.fill()
    cv.blend(1)
    cv.rgba(cp.black)
    cv.fill(20, 20, 192, 256)
    cv.rgba(cp.red)
    cv.fill(84, 84, 128, 128)
    cv.rgba(cp.blue)
    cv.fill(148, 148, 64, 64)
    cv.rgba(0, 255, 0, 128)
    cv.fill(64, 64, 104, 104)
    cv.rgba(255, 255, 255, 64)
    cv.fill(212, 20, 64, 64)
    cv.rgba(255, 255, 255, 128)
    cv.fill(212, 84, 64, 64)
    cv.rgba(255, 255, 255, 192)
    cv.fill(212, 148, 64, 64)
    cv.rgba(255, 255, 255, 255)
    cv.fill(212, 212, 64, 64)
    cv.blend(0)
    cv.rgba(255, 255, 0, 0)
    cv.fill(84, 212, 64, 64)
    cv.rgba(0, 255, 0, 128)
    cv.fill(20, 212, 64, 64)
    cv.blend(1)
    cv.rgba(cp.white)
    cv.line(20, 20, 275, 20, 275, 275, 20, 275, 20, 20)
    cv.copy(20, 20, 256, 256, "clip")

    -- create the background with some text
    local oldfont, oldsize = cv.font("mono", 24)
    cv.rgba(0, 0, 192, 255)
    cv.fill()
    cv.rgba(192, 192, 192, 255)
    local w, h = maketext("Glu")
    cv.blend(1)
    for y = 0, ht - 70, h do
        for x = 0, wd, w do
            pastetext(x, y)
        end
    end

    -- draw the clip
    cv.paste("clip", 20, 20)

    -- replace clip
    local drawcol = replacements[replace].col
    local replacecmd = replacements[replace].cmd
    if #drawcol ~= 0 then
        -- set RGBA color
        cv.rgba(drawcol)
    end
    -- execute replace and draw clip
    local replaced
    local t1 = glu.millisecs()
    if replacements[replace].canvas ~= true then
        cv.target("clip")
    end
    if replacements[replace].loop == true then
        -- copy canvas to bigclip
        cv.copy(0, 0, 0, 0, "bigclip")
        replaced = 1
        local count = 0
        while replaced > 0 do
            local t = glu.millisecs()
            count = count + 1
            -- replace pixels in bigclip
            cv.target("bigclip")
            replaced = cv.replace(replacecmd)
            -- paste bigclip to the canvas
            cv.target()
            cv.paste("bigclip", 0, 0)
            -- draw test number over the bigclip
            cv.blend(1)
            cv.rgba(0, 0, 0, 192)
            cv.fill(0, 300, wd, 144)

            -- draw test name
            cv.font("mono", 14)
            cv.rgba(cp.white)
            local testname = "Test "..replace..": "..replacements[replace].desc
            w, _ = maketext(testname, nil, nil, 2, 2)
            pastetext(floor((wd - w) / 2), 310)
            cv.font("mono", 22)
            if #drawcol ~= 0 then
                cv.rgba(cp.yellow)
                w, _ = maketext("rgba "..table.concat(drawcol, " "), nil, nil, 2, 2)
                pastetext(floor((wd - w) / 2), 340)
            end
            cv.rgba(cp.yellow)
            w, _ = maketext("replace "..replacecmd, nil, nil, 2, 2)
            pastetext(floor((wd - w) / 2), 390)

            -- update display
            glu.show("Test "..replace..": pixels replaced in step "..count..": "..replaced)
            cv.update()
            while glu.millisecs() - t < 15 do end
        end
    else
        replaced = cv.replace(replacecmd)
    end
    t1 = glu.millisecs() - t1
    cv.target()
    if replacements[replace].canvas ~= true then
        cv.paste("clip", (wd - 276), 20)
    end

    -- draw replacement text background
    if replacements[replace].loop ~= true then
        cv.blend(1)
        cv.rgba(0, 0, 0, 192)
        cv.fill(0, 300, wd, 144)

        -- draw test name
        cv.font("mono", 14)
        cv.rgba(cp.white)
        local testname = "Test "..replace..": "..replacements[replace].desc
        w, _ = maketext(testname, nil, nil, 2, 2)
        pastetext(floor((wd - w) / 2), 310)

        -- draw test commands
        cv.font("mono", 22)
        if #drawcol ~= 0 then
            cv.rgba(cp.yellow)
            w, _ = maketext("rgba "..table.concat(drawcol, " "), nil, nil, 2, 2)
            pastetext(floor((wd - w) / 2), 340)
        end
        cv.rgba(cp.yellow)
        w, _ = maketext("replace "..replacecmd, nil, nil, 2, 2)
        pastetext(floor((wd - w) / 2), 390)
    end

    -- restore settings
    cv.blend(oldblend)
    cv.font(oldfont, oldsize)

    -- display elapsed time
    if replacements[replace].loop ~= true then
        glu.show("Time to replace: "..ms(t1).."  Pixels replaced: "..replaced)
    else
        glu.show("Time to replace: "..ms(t1))
    end

    -- next replacement
    replace = replace + 1
    if replace > #replacements then
        replace = 1
    end

    if repeat_test(" with different options") then goto restart end
    cv.target()
end

--------------------------------------------------------------------------------

local function test_copy_paste()
    ::restart::

    local t1 = glu.millisecs()

    -- tile the canvas with a checkerboard pattern
    local sqsize = rand(5, 300)
    local tilesize = sqsize * 2

    -- create the 1st tile (2x2 squares) in the top left corner
    cv.rgba(cp.white)
    cv.fill(0, 0, tilesize, tilesize)
    cv.rgba(cp.red)
    cv.fill(1, 1, (sqsize-1), (sqsize-1))
    cv.fill((sqsize+1), (sqsize+1), (sqsize-1), (sqsize-1))
    cv.rgba(cp.black)
    cv.fill((sqsize+1), 1, (sqsize-1), (sqsize-1))
    cv.fill(1, (sqsize+1), (sqsize-1), (sqsize-1))
    cv.copy(0, 0, tilesize, tilesize, "tile")

    -- tile the top row
    for x = tilesize, wd, tilesize do
        cv.paste("tile", x, 0)
    end

    -- copy the top row and use it to tile the remaining rows
    cv.copy(0, 0, wd, tilesize, "row")
    for y = tilesize, ht, tilesize do
        cv.paste("row", 0, y)
    end

    cv.delete("tile")
    cv.delete("row")

    glu.show("Time to test copy and paste: "..ms(glu.millisecs()-t1))

    if repeat_test(" with different sized tiles") then goto restart end
end

--------------------------------------------------------------------------------

local function bezierx(t, x0, x1, x2, x3)
    local cX = 3 * (x1 - x0)
    local bX = 3 * (x2 - x1) - cX
    local aX = x3 - x0 - cX - bX

    -- compute x position
    local x = (aX * math.pow(t, 3)) + (bX * math.pow(t, 2)) + (cX * t) + x0
    return x
end

--------------------------------------------------------------------------------

local loaddir = "images/"

local function test_scale()
    local loaded = false
    local firsttime = true
    local iw, ih
    local imgclip = "img"
    local quality = "best"
    local minscale = 0.1
    local maxscale = 8.0
    local scale = 1.0         -- no scaling

    ::restart::

    cv.rgba(cp.yellow)
    cv.fill()

    local text = "Hit the space bar to load another image.\n"
    if glu.os() == "Mac" then
        text = text.."Click or hit the return key to return to the main menu."
    else
        text = text.."Click or hit the enter key to return to the main menu."
    end
    local oldfont, oldsize = cv.font(demofont, demofontsize)
    local oldblend = cv.blend(1)
    cv.rgba(cp.black)
    local _, h = maketext(text)
    pastetext(10, ht - 10 - h)
    maketext("Hit [ to scale down, ] to scale up, 1 to reset scale to 1.0, Q to toggle quality.")
    pastetext(10, 10)
    cv.font(oldfont, oldsize)

    if not loaded then
        -- prompt user to load a BMP/GIF/PNG/TIFF file
        glu.update()
        local filetypes = "Image files (*.bmp;*.gif;*.png;*.tiff)|*.bmp;*.gif;*.png;*.tiff"
        local filepath
        if firsttime then
            filepath = "images/joker.png"
            firsttime = false
        else
            filepath = glu.open("Load an image file", filetypes, loaddir, "")
        end
        if #filepath > 0 then
            -- update loaddir by stripping off the file name
            local pathsep = glu.getdir("app"):sub(-1)
            loaddir = filepath:gsub("[^"..pathsep.."]+$","")

            -- load image into a clip
            iw, ih = cv.load(filepath, imgclip)
            loaded = true
        end
    end

    if loaded then
        -- draw image at current scale
        local scaledw = int(iw*scale+0.5)
        local scaledh = int(ih*scale+0.5)
        local x = int((wd-scaledw)/2+0.5)
        local y = int((ht-scaledh)/2+0.5)
        local t1 = glu.millisecs()
        cv.scale(imgclip, x, y, scaledw, scaledh, quality)
        glu.show("Image width and height: "..scaledw.." "..scaledh..
               "  scale: "..scale.."  quality: "..quality.."  time: "..ms(glu.millisecs()-t1))
    end

    cv.blend(oldblend)
    glu.update()

    while true do
        local event = glu.getevent()
        if event == "key space none" then
            loaded = false
            scale = 1.0
            goto restart
        elseif event == "key [ none" or event:find("^czoomout") then
            if scale > minscale then
                scale = scale - 0.1
                if scale < minscale then scale = minscale end
                goto restart
            end
        elseif event == "key ] none" or event:find("^czoomin") then
            if scale < maxscale then
                scale = scale + 0.1
                if scale > maxscale then scale = maxscale end
                goto restart
            end
        elseif event == "key 1 none" then
            scale = 1.0
            goto restart
        elseif event == "key q none" then
            if quality == "fast" then quality = "best" else quality = "fast" end
            goto restart
        elseif event:find("^cclick") or event == "key return none" then
            return_to_main_menu = true
            if loaded then cv.delete(imgclip) end
            return
        elseif #event > 0 then
            glu.doevent(event)
        end
    end
end

--------------------------------------------------------------------------------

local savedir = glu.getdir("data")

local function test_save()
    ::restart::

    -- create gradient from one random pale color to another
    local r1, g1, b1, r2, g2, b2
    repeat
        r1 = rand(128,255)
        g1 = rand(128,255)
        b1 = rand(128,255)
        r2 = rand(128,255)
        g2 = rand(128,255)
        b2 = rand(128,255)
    until abs(r1-r2) + abs(g1-g2) + abs(b1-b2) > 128
    local rfrac = (r2 - r1) / ht
    local gfrac = (g2 - g1) / ht
    local bfrac = (b2 - b1) / ht
    for y = 0, ht-1 do
        local rval = int(r1 + y * rfrac + 0.5)
        local gval = int(g1 + y * gfrac + 0.5)
        local bval = int(b1 + y * bfrac + 0.5)
        cv.rgba(rval, gval, bval, 255)
        cv.line(0, y, wd, y)
    end

    -- create a transparent hole in the middle
    cv.rgba(0, 0, 0, 0)
    cv.fill(int((wd-100)/2), int((ht-100)/2), 100, 100)

    glu.update()

    -- prompt for file name and location
    local pngpath = glu.save("Save canvas as PNG file", "PNG (*.png)|*.png", savedir, "canvas.png")
    if #pngpath > 0 then
        -- save canvas in given file
        cv.save(0, 0, wd, ht, pngpath)
        glu.show("Canvas was saved in "..pngpath)

        -- update savedir by stripping off the file name
        local pathsep = glu.getdir("app"):sub(-1)
        savedir = pngpath:gsub("[^"..pathsep.."]+$","")
    end

    if repeat_test(" and save a different canvas", true) then goto restart end
end

--------------------------------------------------------------------------------

local function test_setpixel()
    local maxx = wd - 1
    local maxy = ht - 1
    local flakes = 10000

    -- create the exit message
    local oldfont, oldsize = cv.font(demofont, demofontsize)
    local text
    if glu.os() == "Mac" then
        text = "Click or hit the return key to return to the main menu."
    else
        text = "Click or hit the enter key to return to the main menu."
    end
    local w, h = maketext(text, nil, cp.white, 2, 2)

    -- create the Glu text in yellow (which has no blue component for r g b)
    cv.font("mono", 100)
    cv.rgba(cp.yellow)
    local gw, gh = maketext("Glu", "Gluclip")

    -- fill the background with graduated blue to black
    local oldblend = cv.blend(0)
    local c
    for i = 0, ht - 1 do
        c = 128 * i // ht
        cv.rgba(0, 0, c, 255)
        cv.line(0, i, (wd - 1), i)
    end

    -- draw Glu text
    cv.blend(1)
    local texty = ht - gh - h
    pastetext((wd - gw) // 2, texty, cp.identity, "Gluclip")

    -- create the background clip
    cv.copy(0, 0, wd, ht, "bg")
    cv.update()

    -- read the screen
    local fullrow = {}
    for j = 0, wd -1 do
        fullrow[j] = true
    end

    local screen = {}
    for i = 0, ht - 1 do
        local row = {}
        if i < texty or i > texty + gh then
            -- ignore rows outside Glu text clip rows
            row = {table.unpack(fullrow)}
        else
            for j = 0, wd - 1 do
                -- ignore pixels that don't have a red component
                row[j] = (cv.getpixel(j, i) == 0)
            end
        end
        screen[i] = row
    end

    -- initialize flake positions
    local yrange = 20 * maxy
    local maxpos = -yrange
    local x  = {}
    local y  = {}
    local dy = {}
    for i = 1, flakes do
        x[i] = rand(0, maxx)
        local yval = 0
        for _ = 1, 10 do
            yval = yval + rand(0, yrange)
        end
        y[i] = -(yval // 10)
        if y[i] > maxpos then
            maxpos = y[i]
        end
        dy[i] = rand() / 5 + 0.8
    end
    for i = 1, flakes do
        y[i] = y[i] - maxpos
    end

    -- initialize the pixel coordinates
    local xy = {}

    -- set white for flake colour
    cv.rgba(cp.white)

    -- loop until key pressed or mouse clicked
    while not return_to_main_menu do
        tstart("frame")
        local event = glu.getevent()
        if event:find("^cclick") or event == "key return none" then
            -- return to main menu
            return_to_main_menu = true
        end

        -- draw the background
        local t1 = glu.millisecs()
        tstart("background")
        cv.blend(0)
        cv.paste("bg", 0, 0)
        tsave("background")

        -- update the pixel positions
        tstart("update")
        local lastx, lasty, newx, newy, diry

        -- index into xy coordinates
        local m = 1
        -- no need to clear the coordinate list each frame since pixels are
        -- only ever added so it can be safely reused

        for i = 1, flakes do
            lastx = x[i]
            lasty = y[i]
            diry = dy[i]
            newx = lastx
            newy = lasty
            if lasty >= 0 and lasty < ht - 1 then
                if lasty // 1 == (lasty + diry) // 1 then
                    newy = lasty + diry
                else
                    if screen[(lasty + diry) // 1][lastx] then
                        newy = lasty + diry
                    else
                        local r = rand()
                        if r < 0.05 then
                            local dx = 1
                            if r < 0.025 then
                                dx = -1
                            end
                            if lastx + dx >= 0 and lastx + dx < wd then
                                if screen[(lasty + diry) // 1][lastx + dx] then
                                   newx = lastx + dx
                                   newy = lasty + diry
                                end
                            end
                        end
                    end
                    screen[lasty // 1][lastx] = true
                    screen[newy // 1][newx] = false
                end
            elseif lasty < 0 then
                newy = lasty + diry
            end
            x[i] = newx
            y[i] = newy
            if newy >= 0 and newy < ht then
                -- add pixel coordinates to list for drawing
                xy[m] = newx//1
                xy[m + 1] = newy//1
                m = m + 2
            end
        end
        tsave("update")

        -- check if there are pixels to draw
        if m > 1 then
            tstart("draw")
            for n = 1, m-1, 2 do
                cv.setpixel(xy[n], xy[n+1])
            end
            tsave("draw")
        end

        -- draw the exit message
        cv.blend(1)
        pastetext((wd - w) // 2, 10)

        -- display elapsed time
        tsave("frame")
        local drawn = (m - 1) // 2
        glu.show("Time for "..drawn.." pixels: "..tvalueall(2))

        -- wait for frame time
        while glu.millisecs() - t1 < 15 do
            glu.sleep(1)
        end

        -- update display
        cv.update()
    end

    -- free clips and restore settings
    cv.delete("Gluclip")
    cv.delete("bg")
    cv.blend(oldblend)
    cv.font(oldfont, oldsize)
end

--------------------------------------------------------------------------------

local function dot(x, y)
    local oldrgba = cv.rgba(cp.green)
    cv.setpixel(x, y)
    cv.rgba(oldrgba)
end

--------------------------------------------------------------------------------

local function draw_rect(x0, y0, x1, y1)
    local oldrgba = cv.rgba(cp.red)
    local oldwidth = cv.linewidth(1)
    cv.line(x0, y0, x1, y0, x1, y1, x0, y1, x0, y0)
    cv.rgba(oldrgba)
    cv.linewidth(oldwidth)
end

--------------------------------------------------------------------------------

local function draw_ellipse(x, y, w, h)
    cv.ellipse(x, y, w, h)
    -- enable next call to check that ellipse is inside given rectangle
    --draw_rect(x, y, x+w-1, y+h-1)
end

--------------------------------------------------------------------------------

local function radial_lines(x0, y0, length)
    cv.line(x0, y0, x0+length, y0)
    cv.line(x0, y0, x0, y0+length)
    cv.line(x0, y0, x0, y0-length)
    cv.line(x0, y0, x0-length, y0)
    for angle = 15, 75, 15 do
        local rad = angle * math.pi/180
        local xd = int(length * cos(rad))
        local yd = int(length * sin(rad))
        cv.line(x0, y0, x0+xd, y0+yd)
        cv.line(x0, y0, x0+xd, y0-yd)
        cv.line(x0, y0, x0-xd, y0+yd)
        cv.line(x0, y0, x0-xd, y0-yd)
    end
end

--------------------------------------------------------------------------------

local function vertical_lines(x, y)
    local oldwidth = cv.linewidth(1)
    local len = 30
    local gap = 15
    for w = 1, 7 do
        cv.linewidth(w)
        cv.line(x, y, x, y+len)  dot(x,y)  dot(x,y+len)  x = x+gap
    end
    cv.linewidth(oldwidth)
end

--------------------------------------------------------------------------------

local function diagonal_lines(x, y)
    local oldwidth = cv.linewidth(1)
    local len = 30
    local gap = 15
    for w = 1, 7 do
        cv.linewidth(w)
        cv.line(x, y, x+len, y+len)  dot(x,y)  dot(x+len,y+len)  x = x+gap
    end
    cv.linewidth(oldwidth)
end

--------------------------------------------------------------------------------

local function nested_ellipses(x, y)
    -- start with a circle
    local w = 91
    local h = 91
    draw_ellipse(x, y, w, h)
    for i = 1, 3 do
        draw_ellipse(x-i*10, y-i*15, w+i*20, h+i*30)
        draw_ellipse(x+i*10, y+i*15, w-i*20, h-i*30)
    end
end

--------------------------------------------------------------------------------

local function show_magnified_pixels(x, y)
    local radius = 5
    local numcols = radius*2+1
    local numrows = numcols
    local magsize = 14
    local boxsize = (1+magsize)*numcols+1

    -- get pixel colors
    local color = {}
    for i = 1, numrows do
        color[i] = {}
        for j = 1, numcols do
            color[i][j] = { cv.getpixel(x-radius-1+j, y-radius-1+i) }
        end
    end

    -- create clip big enough to draw the magnifying glass
    local magclip = "magbox"
    local outersize = int(math.sqrt(boxsize*boxsize+boxsize*boxsize) + 0.5)
    cv.create(outersize, outersize, magclip)
    cv.target(magclip)
    local oldrgba = cv.rgba(0,0,0,0)
    local oldblend = cv.blend(0)
    cv.fill(0, 0, outersize, outersize)

    -- draw gray background (ie. grid lines around pixels)
    cv.rgba(cp.gray)
    local xpos = int((outersize-boxsize)/2)
    local ypos = int((outersize-boxsize)/2)
    cv.fill(xpos, ypos, boxsize, boxsize)

    -- draw magnified pixels
    for i = 1, numrows do
        for j = 1, numcols do
            if color[i][j][1] >= 0 then
                cv.rgba(color[i][j])
                local xv = xpos+1+(j-1)*(magsize+1)
                local yv = ypos+1+(i-1)*(magsize+1)
                cv.fill(xv, yv, magsize, magsize)
            end
        end
    end

    -- erase outer ring
    local oldwidth = cv.linewidth(int((outersize-boxsize)/2))
    cv.rgba(0,0,0,0)
    draw_ellipse(0, 0, outersize, outersize)

    -- surround with a gray circle
    cv.rgba(cp.gray)
    cv.linewidth(4)
    cv.blend(1)
    draw_ellipse(xpos-2, ypos-2, boxsize+4, boxsize+4)

    -- restore target and draw magnified circle with center at x,y
    cv.target()
    xpos = int(x-outersize/2)
    ypos = int(y-outersize/2)
    cv.blend(1)
    cv.paste(magclip, xpos,ypos)
    cv.delete(magclip)

    -- restore settings
    cv.rgba(oldrgba)
    cv.blend(oldblend)
    cv.linewidth(oldwidth)
end

--------------------------------------------------------------------------------

local function test_lines()
    -- this test requires a bigger canvas
    local canvasht = 600
    cv.resize(800, canvasht)

    cv.rgba(cp.white)
    cv.fill()
    cv.rgba(cp.black)

    local oldblend = cv.blend(0)
    local oldwidth = cv.linewidth(1)
    local _

    -- non-antialiased lines (line width = 1)
    radial_lines(80, 90, 45)
    -- antialiased lines
    cv.blend(1)
    radial_lines(200, 90, 45)
    cv.blend(0)

    -- thick non-antialiased lines (line width = 2)
    cv.linewidth(2)
    radial_lines(80, 190, 45)
    -- thick antialiased lines
    cv.blend(1)
    radial_lines(200, 190, 45)
    cv.blend(0)

    -- thick non-antialiased lines (line width = 3)
    cv.linewidth(3)
    radial_lines(80, 290, 45)
    -- thick antialiased lines
    cv.blend(1)
    radial_lines(200, 290, 45)
    cv.blend(0)

    -- non-antialiased lines with increasing thickness
    vertical_lines(30, 350)
    diagonal_lines(30, 390)

    -- antialiased lines with increasing thickness
    cv.blend(1)
    vertical_lines(150, 350)
    diagonal_lines(150, 390)
    cv.blend(0)

    -- non-antialiased ellipses (line width = 1)
    cv.linewidth(1)
    nested_ellipses(350, 90)

    -- antialiased ellipses (line width = 1)
    cv.blend(1)
    nested_ellipses(520, 90)
    cv.blend(0)

    -- thick non-antialiased ellipses
    cv.linewidth(3)
    nested_ellipses(350, 290)

    -- thick antialiased ellipses
    cv.blend(1)
    nested_ellipses(520, 290)
    cv.blend(0)
    cv.linewidth(1)

    -- test overlapping translucent colors
    cv.blend(1)
    cv.linewidth(20)
    local oldrgba = cv.rgba(0,255,0,128)
    draw_ellipse(50, 450, 100, 100)
    cv.rgba(255, 0, 0, 128)
    cv.line(50, 450, 150, 550)
    cv.rgba(0, 0, 255, 128)
    cv.line(150, 450, 50, 550)
    cv.blend(0)
    cv.linewidth(1)
    cv.rgba(oldrgba)

    -- draw filled ellipses using fill_ellipse function from cplus
    -- (no border if given border width is 0)
    cp.fill_ellipse(370, 450, 50, 100, 0, {0,0,255,128})
    cv.rgba(200, 200, 200, 128) -- translucent gray border
    cp.fill_ellipse(300, 450, 100, 80, 15, cp.green)
    cv.rgba(oldrgba)
    cp.fill_ellipse(200, 450, 140, 99, 2, {255,255,0,128})

    -- draw rounded rectangles using round_rect function from cplus
    cp.round_rect(670, 50, 60, 40,  10,  3, "")
    cp.round_rect(670, 100, 60, 40, 20,  0, {0,0,255,128})
    cp.round_rect(670, 150, 60, 21,  3,  0, cp.blue)
    cv.rgba(200, 200, 200, 128) -- translucent gray border
    cp.round_rect(700, 30, 70, 200, 35, 10, {255,255,0,128})
    cv.rgba(oldrgba)

    -- draw some non-rounded rectangles (radius is 0)
    cp.round_rect(670, 200, 10, 8, 0, 3, "")
    cp.round_rect(670, 210, 10, 8, 0, 1, cp.red)
    cp.round_rect(670, 220, 10, 8, 0, 0, cp.green)
    cp.round_rect(670, 230, 10, 8, 0, 0, "")        -- nothing is drawn

    -- draw solid circles (non-antialiased and antialiased)
    cv.linewidth(10)
    draw_ellipse(450, 500, 20, 20)
    cv.blend(1)
    draw_ellipse(480, 500, 20, 20)
    cv.blend(0)
    cv.linewidth(1)

    -- draw solid ellipses (non-antialiased and antialiased)
    cv.linewidth(11)
    draw_ellipse(510, 500, 21, 40)
    cv.blend(1)
    draw_ellipse(540, 500, 21, 40)
    cv.blend(0)
    cv.linewidth(1)

    -- create a circular hole with fuzzy edges
    cv.rgba(255, 255, 255, 0)
    cv.blend(0)
    cv.linewidth(25)
    local x, y, w, h = 670, 470, 50, 50
    draw_ellipse(x, y, w, h)
    cv.linewidth(1)
    local a = 0
    for _ = 1, 63 do
        a = a + 4
        cv.rgba(255, 255, 255, a)
        -- we need to draw 2 offset ellipses to ensure all pixels are altered
        x = x-1
        w = w+2
        draw_ellipse(x, y, w, h)
        y = y-1
        h = h+2
        draw_ellipse(x, y, w, h)
    end
    cv.linewidth(1)
    cv.rgba(oldrgba)

    -- create and draw the exit message
    local oldfont, oldsize = cv.font(demofont, demofontsize)
    cv.blend(1)
    local text
    if glu.os() == "Mac" then
        text = "Click or hit the return key to return to the main menu."
    else
        text = "Click or hit the enter key to return to the main menu."
    end
    cv.rgba(cp.black)
    _, h = maketext(text)
    pastetext(10, canvasht - h - 10)
    maketext("Hit the M key to toggle the magnifying glass.")
    pastetext(10, 10)
    cv.font(oldfont, oldsize)

    glu.update()

    cv.blend(0)
    cv.copy(0, 0, 0, 0, "bg")
    local showing_magnifier = false
    local display_magnifier = true
    local prevx, prevy

    -- loop until enter/return key pressed or mouse clicked
    while true do
        local event = glu.getevent()
        if #event == 0 then
            -- don't hog the CPU when idle
            glu.sleep(20)
        elseif event:find("^cclick") or event == "key return none" then
            -- tidy up and return to main menu
            cv.blend(oldblend)
            cv.linewidth(oldwidth)
            cv.delete("bg")
            return_to_main_menu = true
            return
        elseif event == "key m none" then
            -- toggle magnifier
            display_magnifier = not display_magnifier
            if showing_magnifier and not display_magnifier then
                cv.paste("bg", 0, 0)
                glu.show("")
                glu.update()
                showing_magnifier = false
            elseif display_magnifier and not showing_magnifier then
                -- force it to appear if mouse is in canvas
                prevx = -1
            end
        else
            -- might be a keyboard shortcut
            glu.doevent(event)
        end

        -- track mouse and magnify pixels under cursor
        local x, y = cv.getxy()
        if x >= 0 and y >= 0 then
            if x ~= prevx or y ~= prevy then
                prevx = x
                prevy = y
                cv.paste("bg", 0, 0)
                if display_magnifier then
                    -- show position and color of x,y pixel in status bar
                    local r,g,b,a = cv.getpixel(x, y)
                    show_magnified_pixels(x, y)
                    glu.show("xy: "..x.." "..y.."  rgba: "..r.." "..g.." "..b.." "..a)
                    glu.update()
                    showing_magnifier = true
                end
            end
        elseif showing_magnifier then
            cv.paste("bg", 0, 0)
            glu.show("")
            glu.update()
            showing_magnifier = false
        end
    end
end

--------------------------------------------------------------------------------

local function test_multiline_text()
    ::restart::

    -- resize canvas to cover entire viewport
    wd, ht = glu.getview()
    cv.resize(wd, ht)

    local oldfont, oldsize = cv.font("mono-bold", 10)   -- use a mono-spaced font

    -- draw solid background
    local oldblend = cv.blend(0)
    cv.rgba(cp.blue)
    cv.fill()

    local textstr =
[[
"To be or not to be, that is the question;
Whether 'tis nobler in the mind to suffer
The slings and arrows of outrageous fortune,
Or to take arms against a sea of troubles,
And by opposing, end them. To die, to sleep;
No more; and by a sleep to say we end
The heart-ache and the thousand natural shocks
That flesh is heir to — 'tis a consummation
Devoutly to be wish'd. To die, to sleep;
To sleep, perchance to dream. Ay, there's the rub,
For in that sleep of death what dreams may come,
When we have shuffled off this mortal coil,
Must give us pause. There's the respect
That makes calamity of so long life,
For who would bear the whips and scorns of time,
Th'oppressor's wrong, the proud man's contumely,
The pangs of despised love, the law's delay,
The insolence of office, and the spurns
That patient merit of th'unworthy takes,
When he himself might his quietus make
With a bare bodkin? who would fardels bear,
To grunt and sweat under a weary life,
But that the dread of something after death,
The undiscovered country from whose bourn
No traveller returns, puzzles the will,
And makes us rather bear those ills we have
Than fly to others that we know not of?
Thus conscience does make cowards of us all,
And thus the native hue of resolution
Is sicklied o'er with the pale cast of thought,
And enterprises of great pitch and moment
With this regard their currents turn awry,
And lose the name of action.
Soft you now! The fair Ophelia! Nymph,
In thy Orisons be all my sins remembered.

Test non-ASCII: áàâäãåçéèêëíìîïñóòôöõúùûüæøœÿ
                ÁÀÂÄÃÅÇÉÈÊËÍÌÎÏÑÓÒÔÖÕÚÙÛÜÆØŒŸ
]]

    -- toggle the column alignments and transparency
    if align == "left" then
       align = "center"
    else
        if align == "center" then
            align = "right"
        else
            if align == "right" then
                align = "left"
                transbg = 2 - transbg
                if transbg == 2 then
                    shadow = 1 - shadow
                end
            end
        end
    end

    -- set text alignment
    local oldalign = cv.textalign(align)

    -- set the text foreground color
    cv.rgba(cp.white)

    -- set the text background color
    local oldbackground
    local transmsg
    if transbg == 2 then
        oldbackground = cv.textback(0, 0, 0, 0)
        transmsg = "transparent background"
    else
        oldbackground = cv.textback(0, 128, 128, 255)
        transmsg = "opaque background     "
    end

    local t1 = glu.millisecs()

    cv.blend(transbg)

    -- create the text clip
    if shadow == 0 then
        maketext(textstr)
    else
        maketext(textstr, nil, nil, 2, 2)
    end

    -- paste the clip onto the canvas
    local t2 = glu.millisecs()
    t1 = t2 - t1
    pastetext(0, 0)

    -- output timing and drawing options
    local shadowmsg
    if shadow == 1 then
        shadowmsg = "on"
    else
        shadowmsg = "off"
    end
    glu.show("Time to test multiline text: maketext "..ms(t1)..
           "  pastetext "..ms(glu.millisecs() - t2)..
           "  align "..string.format("%-6s", align)..
           "  "..transmsg.."  shadow "..shadowmsg)

    -- restore old settings
    cv.textback(oldbackground)
    cv.textalign(oldalign)
    cv.font(oldfont, oldsize)
    cv.blend(oldblend)

    if repeat_test(" with different text options") then goto restart end
end

--------------------------------------------------------------------------------

local function test_text()
    ::restart::

    local t1 = glu.millisecs()

    local oldfont, oldsize, oldblend, w, h, descent, nextx, _

    oldblend = cv.blend(0)
    cv.rgba(cp.white) -- white background
    cv.fill()
    cv.rgba(cp.black) -- black text

    cv.blend(1)
    maketext("FLIP Y")
    pastetext(20, 30)
    pastetext(20, 30, cp.flip_y)

    maketext("FLIP X")
    pastetext(110, 30)
    pastetext(110, 30, cp.flip_x)

    maketext("FLIP BOTH")
    pastetext(210, 30)
    pastetext(210, 30, cp.flip)

    maketext("ROTATE CW")
    pastetext(20, 170)
    pastetext(20, 170, cp.rcw)

    maketext("ROTATE ACW")
    pastetext(20, 140)
    pastetext(20, 140, cp.racw)

    maketext("SWAP XY")
    pastetext(150, 170)
    pastetext(150, 170, cp.swap_xy)

    maketext("SWAP XY FLIP")
    pastetext(150, 140)
    pastetext(150, 140, cp.swap_xy_flip)

    oldfont, oldsize = cv.font("default", 7)
    w, h, descent = maketext("tiny")
    pastetext(300, 30 - h + descent)
    nextx = 300 + w + 5

    cv.font(oldfont, oldsize)    -- restore previous font
    w, h, descent = maketext("normal")
    pastetext(nextx, 30 - h + descent)
    nextx = nextx + w + 5

    cv.font("default-bold", 20)
    _, h, descent = maketext("Big")
    pastetext(nextx, 30 - h + descent)

    cv.font("default-bold", 10)
    w = maketext("bold")
    pastetext(300, 40)
    nextx = 300 + w + 5

    cv.font("default-italic", 10)
    maketext("italic")
    pastetext(nextx, 40)

    cv.font("mono", 10)
    w, h, descent = maketext("mono")
    pastetext(300, 80 - h + descent)
    nextx = 300 + w + 5

    cv.font("", 12)   -- just change font size
    _, h, descent = maketext("mono12")
    pastetext(nextx, 80 - h + descent)

    cv.font("mono-bold", 10)
    maketext("mono-bold")
    pastetext(300, 90)

    cv.font("mono-italic", 10)
    maketext("mono-italic")
    pastetext(300, 105)

    cv.font("roman", 10)
    maketext("roman")
    pastetext(300, 130)

    cv.font("roman-bold", 10)
    maketext("roman-bold")
    pastetext(300, 145)

    cv.font("roman-italic", 10)
    maketext("roman-italic")
    pastetext(300, 160)

    cv.font(oldfont, oldsize)    -- restore previous font

    cv.rgba(cp.red)
    w, h, descent = maketext("RED")
    pastetext(300, 200 - h + descent)
    nextx = 300 + w + 5

    cv.rgba(cp.green)
    w, h, descent = maketext("GREEN")
    pastetext(nextx, 200 - h + descent)
    nextx = nextx + w + 5

    cv.rgba(cp.blue)
    _, h, descent = maketext("BLUE")
    pastetext(nextx, 200 - h + descent)

    cv.rgba(cp.yellow)
    w, h = maketext("Yellow on black [] gjpqy")
    cv.rgba(cp.black)
    cv.fill(300, 210, w, h)
    pastetext(300, 210)

    cv.blend(0)
    cv.rgba(cp.yellow)             cv.fill(0,   250, 100, 100)
    cv.rgba(cp.cyan)               cv.fill(100, 250, 100, 100)
    cv.rgba(cp.magenta)            cv.fill(200, 250, 100, 100)
    cv.rgba(0, 0, 0, 0)  cv.fill(300, 250, 100, 100)
    cv.blend(1)

    cv.rgba(cp.black)
    maketext("The quick brown fox jumps over 123 dogs.")
    pastetext(10, 270)

    cv.rgba(cp.white)
    maketext("SPOOKY")
    pastetext(310, 270)

    oldfont, oldsize = cv.font("default-bold", rand(10,80))
    cv.rgba(cp.red)
    w, h, descent = maketext("Glug")
    local ypos = 360
    local tempclip = pastetext(10, ypos)

    -- draw box around text
    cv.line(10,ypos, w-1+10,ypos, w-1+10,h-1+ypos, 10,h-1+ypos, 10,ypos)
    -- show baseline
    cv.line(10,h-1+ypos-descent, w-1+10,h-1+ypos-descent)

    -- draw minimal bounding rect over text
    local xoff, yoff, minwd, minht = cp.minbox(tempclip, w, h)
    cv.rgba(0, 0, 255, 60)
    cv.fill(xoff+10, yoff+ypos, minwd, minht)

    -- restore blend state and font
    cv.blend(oldblend)
    cv.font(oldfont, oldsize)
    cv.rgba(cp.black)

    glu.show("Time to test text: "..ms(glu.millisecs()-t1))

    if repeat_test(" with a different sized \"Glug\"", true) then goto restart end
end

--------------------------------------------------------------------------------

local function test_fill()
    ::restart::

    cv.rgba(cp.white)
    cv.fill()

    toggle = toggle + 1
    if toggle == 3 then toggle = 0 end
    cv.blend(toggle)
    math.randomseed(531642)

    local maxx = wd-1
    local maxy = ht-1
    tstart()
    for _ = 1, 1000 do
        cv.rgba(rand(0,255), rand(0,255), rand(0,255), rand(0,255))
        cv.fill(rand(0,maxx), rand(0,maxy), rand(100), rand(100))
    end
    tsave()
    glu.show("Time to fill one thousand rectangles: "..tvalueall(2).."  blend "..toggle)
    cv.rgba(0, 0, 0, 0)
    cv.fill(10, 10, 100, 100) -- does nothing when alpha blending is on

    if toggle > 0 then
        cv.blend(0) -- turn off alpha blending
    end
    math.randomseed()   -- for other tests

    if repeat_test(" with a different blend setting") then goto restart end
end

--------------------------------------------------------------------------------

local target = 1

local function test_target()
    -- set canvas as the rendering target
    local oldtarget = cv.target()
    local oldfont, oldsize = cv.font("mono", 16)
    local oldblend = cv.blend(0)

    ::restart::

    target = 1 - target

    -- fill the canvas with black
    cv.blend(0)
    cv.rgba(cp.black)
    cv.fill()

    -- create a 300x300 clip
    cv.create(300, 300, "clip")

    -- change the clip contents to yellow
    cv.target("clip")
    cv.rgba(cp.yellow)
    cv.fill()

    -- either draw to the canvas or clip
    if target == 0 then
        cv.target("clip")
    else
        cv.target()
    end

    -- draw red, green and blue squares
    cv.rgba(cp.red)
    cv.fill(0, 0, wd, 50)

    cv.rgba(cp.green)
    cv.fill(0, 50, wd, 50)

    cv.rgba(cp.blue)
    cv.fill(0, 100, wd, 50)

    -- draw circle
    cv.blend(1)
    cv.rgba(cp.white)
    cp.fill_ellipse(0, 0, 100, 100, 1, cp.magenta)

    -- draw some lines
    cv.rgba(cp.cyan)
    for x = 0, wd, 20 do
        cv.line(0, 0 ,x, ht)
    end

    -- draw text label
    local textstring = "Clip"
    if target ~= 0 then
        textstring = "Canvas"
    end
    cv.rgba(cp.black)
    maketext(textstring, nil, cp.white, 2, 2)
    pastetext(0, 0)

    -- set canvas as the target
    cv.target()

    -- paste the clip
    cv.blend(0)
    cv.paste("clip", 200, 0)

    if repeat_test(" with a different target") then goto restart end

    -- free clip and restore previous target
    cv.delete("clip")
    cv.target(oldtarget)
    cv.font(oldfont, oldsize)
    cv.blend(oldblend)
end

--------------------------------------------------------------------------------

local function test_blending()
    ::restart::

    cv.rgba(cp.white)
    cv.fill()

    toggle = 1 - toggle
    if toggle > 0 then
        cv.blend(1)           -- turn on alpha blending
    end

    local oldfont, oldsize = cv.font(demofont, demofontsize)
    local oldblend = cv.blend(1)
    cv.rgba(cp.black)
    maketext("Alpha blending is turned on or off using the blend function:")
    pastetext(10, 10)
    maketext("blend "..oldblend)
    pastetext(40, 50)
    maketext("The blend function also controls antialiasing of lines and ellipses:")
    pastetext(10, 300)
    cv.blend(oldblend)
    cv.font(oldfont, oldsize)

    cv.rgba(0, 255, 0, 128)      -- 50% translucent green
    cv.fill(40, 70, 100, 100)

    cv.rgba(255, 0, 0, 128)      -- 50% translucent red
    cv.fill(80, 110, 100, 100)

    cv.rgba(0, 0, 255, 128)      -- 50% translucent blue
    cv.fill(120, 150, 100, 100)

    cv.rgba(cp.black)
    radial_lines(100, 400, 50)
    draw_ellipse(200, 350, 200, 100)
    draw_ellipse(260, 360, 80, 80)
    draw_ellipse(290, 370, 20, 60)
    -- draw a solid circle by setting the line width to the radius
    local oldwidth = cv.linewidth(50)
    draw_ellipse(450, 350, 100, 100)
    cv.linewidth(oldwidth)

    if toggle > 0 then
        cv.blend(0)           -- turn off alpha blending
    end

    if repeat_test(" with a different blend setting", true) then goto restart end
end

--------------------------------------------------------------------------------

local volume = 70
local paused = false

local function test_sound()
    local oldblend = cv.blend(0)
    cv.rgba(0,0,128,255)
    cv.fill()

    -- draw exit message
    cv.blend(1)
    cv.rgba(cp.white)
    local oldfont, oldsize = cv.font(demofont, demofontsize)
    local exitw = maketext("Click or press enter to return to the main menu.", nil, nil, 2, 2)
    pastetext(floor((wd - exitw) / 2), 530)

    -- draw commands
    cv.font("mono", 22)
    cv.rgba(cp.yellow)
    local w, _ = maketext("sound play", nil, nil, 2, 2)
    pastetext(floor((wd - w) / 2), 50)
    w, _ = maketext("sound loop", nil, nil, 2, 2)
    pastetext(floor((wd - w) / 2), 140)
    w, _ = maketext("sound stop", nil, nil, 2, 2)
    pastetext(floor((wd - w) / 2), 230)

    -- draw controls
    cv.font("mono", 16)
    cv.rgba(cp.white)
    w, _ = maketext("Press P to play sound", nil, nil, 2, 2)
    pastetext(floor((wd - w) / 2), 20)
    w, _ = maketext("Press L to loop sound", nil, nil, 2, 2)
    pastetext(floor((wd - w) / 2), 110)
    w, _ = maketext("Press S to stop sound", nil, nil, 2, 2)
    pastetext(floor((wd - w) / 2), 200)
    w, _ = maketext("Press - or + to adjust volume", nil, nil, 2, 2)
    pastetext(floor((wd - w) / 2), 290)

    -- update screen then copy background
    cv.update()
    local bgclip = "bg"
    cv.copy(0, 0, 0, 0, bgclip)

    local soundname = "sounds/levelcompleteloop.ogg"
    local running = true

    -- main loop
    local result = ""
    local command = "stop"
    while running do
        -- check for input
        local event = glu.getevent()
        if event:find("^cclick") or event == "key return none" then
            running = false
        elseif event == "key p none" then
            command = "play"
            result = cv.sound("play", soundname, volume / 100)
            paused = false
        elseif event == "key l none" then
            command = "loop"
            result = cv.sound("loop", soundname, volume / 100)
            paused = false
        elseif event == "key s none" then
            command = "stop"
            cv.sound("stop")
            result = ""
        elseif event == "key - none" then
            volume = volume - 10
            if (volume < 0) then volume = 0 end
            command = "volume "..(volume / 100)
            cv.sound("volume", soundname, volume / 100)
        elseif event == "key + none" or event == "key = none" then
            volume = volume + 10
            if (volume > 100) then volume = 100 end
            command = "volume "..(volume / 100)
            cv.sound("volume", soundname, volume / 100)
        elseif event == "key q none" then
            if paused then
                paused = false
                command = "resume"
                cv.sound("resume", soundname)
            else
                paused = true
                command = "pause"
                cv.sound("pause", soundname)
            end
        end

        -- draw background
        cv.blend(0)
        cv.paste(bgclip, 0, 0)

        -- draw pause or resume
        cv.font("mono", 16)
        cv.blend(1)
        cv.rgba(cp.white)
        if paused then
            w, _ = maketext("Press Q to resume playback", nil, nil, 2, 2)
        else
            w, _ = maketext("Press Q to pause playback", nil, nil, 2, 2)
        end
        pastetext(floor((wd - w) / 2), 380)
        cv.rgba(cp.yellow)
        cv.font("mono", 22)
        if paused then
            w, _ = maketext("sound resume", nil, nil, 2, 2)
        else
            w, _ = maketext("sound pause", nil, nil, 2, 2)
        end
        pastetext(floor((wd - w) / 2), 420)

        -- display volume
        cv.blend(1)
        w, _ = maketext("sound volume "..(volume / 100), nil, nil, 2, 2)
        pastetext(floor((wd - w) / 2), 320)

        -- draw last command
        cv.blend(1)
        cv.rgba(cp.cyan)
        cv.font("mono", 16)
        if (result ~= "" and result ~= nil) then
            w, _ = maketext("Last command: "..command.." ("..result..")", nil, nil, 2, 2)
        else
            w, _ = maketext("Last command: "..command, nil, nil, 2, 2)
        end
        pastetext(floor((wd - w) / 2), 470)

        -- draw status
        local state = cv.sound("state", soundname)
        w, _ = maketext("State: "..state, nil, nil, 2, 2)
        pastetext(floor((wd - w) / 2), 500)

        cv.update()
    end

    -- stop any sounds before exit
    cv.sound("stop")

    -- no point calling repeat_test()
    return_to_main_menu = true
    cv.delete(bgclip)
    cv.font(oldfont, oldsize)
    cv.blend(oldblend)
end

--------------------------------------------------------------------------------

local function test_mouse()
    ::restart::

    cv.rgba(cp.black)
    cv.fill()
    cv.rgba(cp.white)

    local oldfont, oldsize = cv.font(demofont, demofontsize)
    local oldblend = cv.blend(1)
    local _, h
    if glu.os() == "Mac" then
        maketext("Click and drag to draw.\n"..
                 "Option-click to flood.\n"..
                 "Control-click or right-click to change the color.")
        _, h = maketext("Hit the space bar to restart this test.\n"..
                        "Hit the return key to return to the main menu.", "botlines")
    else
        maketext("Click and drag to draw.\n"..
                 "Alt-click to flood. "..
                 "Control-click or right-click to change the color.")
        _, h = maketext("Hit the space bar to restart this test.\n"..
                        "Hit the enter key to return to the main menu.", "botlines")
    end
    pastetext(10, 10)
    pastetext(10, ht - 10 - h, cp.identity, "botlines")
    cv.blend(oldblend)
    cv.font(oldfont, oldsize)

    cv.cursor("pencil")
    glu.update()

    local mousedown = false
    local prevx, prevy
    while true do
        local event = glu.getevent()
        if event == "key space none" then
            goto restart
        end
        if event == "key return none" then
            return_to_main_menu = true
            return
        end
        if event:find("^cclick") then
            local _, x, y, button, mods = split(event)
            if mods == "alt" then
                cv.flood(x, y)
            else
                if mods == "ctrl" or button == "right" then
                    cv.rgba(rand(0,255), rand(0,255), rand(0,255), 255)
                end
                cv.setpixel(x, y)
                mousedown = true
                prevx = x
                prevy = y
            end
            glu.update()
        elseif event:find("^mup") then
            mousedown = false
        elseif #event > 0 then
            glu.doevent(event)
        end

        local x, y = cv.getxy()
        if x >= 0 and y >= 0 then
            local r,g,b,a = cv.getpixel(x, y)
            glu.show("pixel at "..x..","..y.." = "..r.." "..g.." "..b.." "..a)
            if mousedown and (x ~= prevx or y ~= prevy) then
                cv.line(prevx, prevy, x, y)
                prevx = x
                prevy = y
                glu.update()
            end
        else
            glu.show("mouse is outside canvas")
        end
    end
end

--------------------------------------------------------------------------------

local function create_buttons()
    cp.textgap = 15
    cp.textshadowx = 2
    cp.textshadowy = 2
    
    exit_button = cp.button("Exit", glu.exit)

    local longest = "Text and Transforms"
    blend_button = cp.button(       longest, test_blending)
    copy_button = cp.button(        longest, test_copy_paste)
    cursor_button = cp.button(      longest, test_cursors)
    set_button = cp.button(         longest, test_setpixel)
    fill_button = cp.button(        longest, test_fill)
    line_button = cp.button(        longest, test_lines)
    mouse_button = cp.button(       longest, test_mouse)
    multiline_button = cp.button(   longest, test_multiline_text)
    pos_button = cp.button(         longest, test_positions)
    render_button = cp.button(      longest, test_target)
    replace_button = cp.button(     longest, test_replace)
    save_button = cp.button(        longest, test_save)
    scale_button = cp.button(       longest, test_scale)
    sound_button = cp.button(       longest, test_sound)
    text_button = cp.button(        longest, test_text)
    transition_button = cp.button(  longest, test_transitions)

    -- change labels without changing button widths
    blend_button.setlabel(       "Alpha Blending", false)
    copy_button.setlabel(        "Copy and Paste", false)
    cursor_button.setlabel(      "Cursors", false)
    set_button.setlabel(         "Drawing Pixels", false)
    fill_button.setlabel(        "Filling Rectangles", false)
    line_button.setlabel(        "Lines and Ellipses", false)
    mouse_button.setlabel(       "Mouse Tracking", false)
    multiline_button.setlabel(   "Multi-line Text", false)
    pos_button.setlabel(         "Canvas Positions", false)
    render_button.setlabel(      "Render Target", false)
    replace_button.setlabel(     "Replacing Pixels", false)
    save_button.setlabel(        "Saving the Canvas", false)
    scale_button.setlabel(       "Scaling Images", false)
    sound_button.setlabel(       "Sounds", false)
    text_button.setlabel(        "Text and Transforms", false)
    transition_button.setlabel(  "Transitions", false)
end

--------------------------------------------------------------------------------

local function main_menu()
    local numbutts = 16     -- excluding Exit button
    local buttwd = blend_button.wd
    local buttht = blend_button.ht
    local buttgap = 9
    local hgap = 20
    local textgap = 10

    local oldfont, oldsize = cv.font("default-bold", 24)
    cv.rgba(cp.black)
    local w1, h1 = maketext("Welcome to the canvas demo!", "bigtext")
    cv.font(demofont, demofontsize)
    local w2, h2 = maketext("Click on a button to see what's possible.", "smalltext")
    local textht = h1 + textgap + h2

    -- check if sound is enabled
    local sound_enabled = cv.sound() == 2
    if not sound_enabled then numbutts = numbutts - 1 end

    -- resize canvas to fit buttons and text
    wd = hgap + buttwd + hgap + w1 + hgap
    ht = hgap + numbutts * buttht + (numbutts-1) * buttgap + hgap
    cv.resize(wd, ht)

    cv.position("middle")
    cv.cursor("arrow")
    cv.rgba(cp.gray)
    cv.fill()
    cv.rgba(cp.white)
    cv.fill(2, 2, -4, -4)

    local x = hgap
    local y = hgap

    blend_button.show(x, y)         y = y + buttgap + buttht
    pos_button.show(x, y)           y = y + buttgap + buttht
    copy_button.show(x, y)          y = y + buttgap + buttht
    cursor_button.show(x, y)        y = y + buttgap + buttht
    set_button.show(x, y)           y = y + buttgap + buttht
    fill_button.show(x, y)          y = y + buttgap + buttht
    line_button.show(x, y)          y = y + buttgap + buttht
    mouse_button.show(x, y)         y = y + buttgap + buttht
    multiline_button.show(x, y)     y = y + buttgap + buttht
    render_button.show(x, y)        y = y + buttgap + buttht
    replace_button.show(x, y)       y = y + buttgap + buttht
    save_button.show(x, y)          y = y + buttgap + buttht
    scale_button.show(x, y)         y = y + buttgap + buttht
    if sound_enabled then
        sound_button.show(x, y)     y = y + buttgap + buttht
    end
    text_button.show(x, y)          y = y + buttgap + buttht
    transition_button.show(x, y)
    
    -- put Exit button in bottom right corner
    exit_button.show(wd - exit_button.wd - hgap, y)

    local oldblend = cv.blend(1)

    x = hgap + buttwd + hgap
    y = int((ht - textht) / 2)
    pastetext(x, y, cv.identity, "bigtext")
    x = x + int((w1 - w2) / 2)
    y = y + h1 + textgap
    pastetext(x, y, cv.identity, "smalltext")

    cv.blend(oldblend)
    cv.font(oldfont, oldsize)

    glu.update()
    glu.show("Hit the escape key or the Exit button to end the demo.")

    -- wait for user to click a button
    return_to_main_menu = false
    while true do
        local event = cp.process( glu.getevent() )
        if return_to_main_menu then
            return
        end
        if #event > 0 then
            -- might be a keyboard shortcut
            glu.doevent(event)
        else
            glu.sleep(5)  -- don't hog the CPU when idle
        end
    end
end

--------------------------------------------------------------------------------

function main()
    create_canvas()
    create_buttons()
    -- run main loop
    while true do
        main_menu()
    end
end

--------------------------------------------------------------------------------

glu.settitle("Canvas Demo")
glu.setoption("showstatusbar", 1)
glu.setview(900, 700) -- larger than canvas size set in test_lines

local status, err = xpcall(main, gp.trace)
if err then glu.continue(err) end
-- the following code is always executed
glu.check(false)
cv.delete()
