-- This module is loaded if a script calls require "cplus".
-- It provides a high-level interface to the canvas functions.

local glu = glu()
local cv = canvas()
-- require "gplus.strict"
local gp = require "gplus"
local int = gp.int
local round = gp.round
local split = gp.split
local unpack = table.unpack
local min = math.min
local max = math.max
local abs = math.abs

local cp = {}

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

-- opaque colors for cv.rgba, etc.
cp.white   = {255, 255, 255, 255}
cp.gray    = {128, 128, 128, 255}
cp.black   = {  0,   0,   0, 255}
cp.red     = {255,   0,   0, 255}
cp.green   = {  0, 255,   0, 255}
cp.blue    = {  0,   0, 255, 255}
cp.cyan    = {  0, 255, 255, 255}
cp.magenta = {255,   0, 255, 255}
cp.yellow  = {255, 255,   0, 255}

-- common transformations for cv.transform, etc.
cp.identity     = { 1,  0,  0,  1}
cp.flip         = {-1,  0,  0, -1}
cp.flip_x       = {-1,  0,  0,  1}
cp.flip_y       = { 1,  0,  0, -1}
cp.swap_xy      = { 0,  1,  1,  0}
cp.swap_xy_flip = { 0, -1, -1,  0}
cp.rcw          = { 0, -1,  1,  0}
cp.rccw         = { 0,  1, -1,  0}
cp.racw         = cp.rccw
cp.r180         = cp.flip

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

-- scripts can adjust any of the default settings in this section:
-- (check boxes and radio buttons are collectively known as select buttons)

cp.buttonht = 22                    -- height of buttons (including select buttons)
cp.buttonwd = 0                     -- force initial width of buttons if > 0
cp.radius = 11                      -- curvature of button corners
cp.border = 0                       -- thickness of button border (no border if 0)
cp.borderrgba = cp.white            -- white border around buttons (if cp.border > 0)
cp.buttonrgba = {40,128,255,255}    -- light blue buttons
cp.textrgba = cp.white              -- white button labels, marks on select buttons
cp.textfont = "default-bold"        -- font for button labels
cp.textfontsize = 10                -- font size for button labels
cp.textshadowx = 0                  -- shadow x offset for button labels, menu items, etc
cp.textshadowy = 0                  -- shadow y offset for button labels, menu items, etc
cp.textshadowrgba = cp.black        -- black label shadow color
cp.buttonshadowx = 0                -- button shadow x offset
cp.buttonshadowy = 0                -- button shadow y offset
cp.buttonshadowrgba = cp.black      -- black button shadow color
cp.textgap = 10                     -- gap between edge of button and its label
cp.yoffset = 0                      -- for adjusting y position of button labels

-- these are only used in cp.checkbox and cp.radiobutton:

cp.labelrgba = cp.buttonrgba        -- best if label color matches button color
cp.selectgap = 5                    -- gap between button and its label

-- these are only used in cp.menubar and cp.popupmenu:

cp.menubg = {40,128,255,255}        -- light blue background for menu bar and items
cp.menutext = cp.white              -- white text for menu and item labels
cp.menufont = "default-bold"        -- font for menu and item labels
cp.menufontsize = 12                -- font size for menu and item labels
cp.menugap = 10                     -- horizontal space around each menu label
cp.rightgap = 20                    -- horizontal space for tick/radio mark
cp.itemgap = 2                      -- vertical space above and below item labels
cp.itemy = 0                        -- y offset for item labels

-- these are only used in cp.slider:

cp.sliderwd = 16                    -- width of slider button (best if even)
cp.sliderht = 24                    -- height of slider button (ditto)
cp.barht = 6                        -- height of horizontal bar (ditto)

-- some platform-specific adjustments might be needed:

if glu.os() == "Mac" and glu.getscale() > 1.0 then
    -- fix scaling problem on Retina display
    cp.yoffset = 1
end

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

local button_rects = {}         -- for detecting click in a button
local select_rects = {}         -- for detecting click in a select button
local slider_rects = {}         -- for detecting click in a slider
local menubar_rects = {}        -- for detecting click in a menu bar

local darken_button = false     -- selected button needs to be drawn darker?

local selmenu = 0               -- index of selected menu (if > 0)
local selitem = 0               -- index of selected item in selmenu (if > 0)

local textclip = "textclip"     -- default clip name for cp.maketext and cp.pastetext

local item_normal = 1           -- normal menu item
local item_tick   = 2           -- tick (multi-select) menu item
local item_radio  = 3           -- radio (single-select) menu item

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

local function rect(x, y, w, h)
    if w <= 0 then error("rect width must be > 0", 2) end
    if h <= 0 then error("rect height must be > 0", 2) end

    -- return a table that makes it easier to manipulate rectangles
    local r = {}
    r.x  = x
    r.y  = y
    r.wd = w
    r.ht = h
    r.left   = r.x
    r.top    = r.y
    r.width  = r.wd
    r.height = r.ht
    r.right  = r.left + r.wd - 1
    r.bottom = r.top  + r.ht - 1
    return r
end

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

function cp.pastetext(x, y, transform, clipname)
    -- set optional parameter defaults
    transform = transform or cp.identity
    clipname  = clipname or textclip
    -- apply transform and paste text clip
    local oldtransform = cv.transform(transform)
    cv.paste(clipname, x, y)
    cv.transform(oldtransform)
    return clipname
end

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

function cp.maketext(s, clipname, textcol, shadowx, shadowy, shadowcol)
    local oldrgba = cv.rgba(cp.white)
    -- set optional parameter defaults
    clipname  = clipname or textclip
    textcol   = textcol or oldrgba
    shadowx   = shadowx or 0
    shadowy   = shadowy or 0
    shadowcol = shadowcol or cp.black
    local w, h, d
    -- check if shadow required
    if shadowx == 0 and shadowy == 0 then
        cv.rgba(textcol)
        w, h, d = cv.text(clipname, s)
    else
        -- build shadow clip
        cv.rgba(shadowcol)
        local oldr, oldg, oldb, olda = unpack(cv.textback(0, 0, 0, 0))
        local oldblend
        if oldr+oldg+oldb+olda == 0 then
            oldblend = cv.blend(1)
        else
            oldblend = cv.blend(0)
            cv.textback(oldr, oldg, oldb, olda)
        end
        local tempclip = clipname.."_temp"
        w, h, d  = cv.text(tempclip, s)
        -- compute paste location based on shadow offset
        local tx = 0
        local ty = 0
        local sx = 0
        local sy = 0
        if shadowx < 0 then
            tx = -shadowx
        else
            sx = shadowx
        end
        if shadowy < 0 then
            ty = -shadowy
        else
            sy = shadowy
        end
        -- size result clip to fit text and shadow
        w = w + abs(shadowx)
        h = h + abs(shadowy)
        cv.create(w, h, clipname)
        -- paste shadow onto result
        local oldtarget = cv.target(clipname)
        if oldr+oldg+oldb+olda ~= 0 then
            cv.rgba(oldr, oldg, oldb, olda)
            cv.fill()
        end
        cv.paste(tempclip, sx, sy)
        
        -- build normal text clip
        cv.rgba(textcol)
        if oldr+oldg+oldb+olda ~= 0 then
            cv.textback(0, 0, 0, 0)
            cv.blend(1)
        end
        cv.text(tempclip, s)
        
        -- paste normal onto result
        cv.paste(tempclip, tx, ty)
        
        -- restore settings
        cv.textback(oldr, oldg, oldb, olda)
        cv.delete(tempclip)
        cv.target(oldtarget)
        cv.blend(oldblend)
    end
    -- add index
    cv.optimize(clipname)

    -- restore color
    cv.rgba(oldrgba)

    return w, h, d
end

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

function cp.fill_ellipse(x, y, w, h, borderwd, fillrgba)
    -- draw an ellipse with the given border width (if > 0) using the current color
    -- and fill it with the given color (if fillrgba isn't {})

    if borderwd == 0 then
        if #fillrgba > 0 then
            -- just draw solid anti-aliased ellipse using fillrgba
            local oldwidth = cv.linewidth(int(min(w,h)/2 + 0.5))
            local oldblend = cv.blend(1)
            local oldrgba = cv.rgba(fillrgba)
            cv.ellipse(x, y, w, h)
            cv.rgba(oldrgba)
            cv.blend(oldblend)
            cv.linewidth(oldwidth)
        end
        return
    end

    if w <= borderwd*2 or h <= borderwd*2 then
        -- no room to fill so just draw anti-aliased ellipse using current color
        local oldwidth = cv.linewidth(int(min(w,h)/2 + 0.5))
        local oldblend = cv.blend(1)
        cv.ellipse(x, y, w, h)
        cv.blend(oldblend)
        cv.linewidth(oldwidth)
        return
    end

    local oldblend = cv.blend(1)

    if #fillrgba > 0 then
        -- draw smaller filled ellipse using fillrgba
        local oldrgba = cv.rgba(fillrgba)
        local smallw = w - borderwd*2
        local smallh = h - borderwd*2
        local oldwidth = cv.linewidth(int(min(smallw,smallh)/2 + 0.5))
        cv.ellipse(x+borderwd, y+borderwd, smallw, smallh)
        cv.rgba(oldrgba)
        cv.linewidth(oldwidth)
    end

    -- draw outer ellipse using given borderwd
    local oldwidth = cv.linewidth(borderwd)
    cv.ellipse(x, y, w, h)

    -- restore line width and blend state
    cv.linewidth(oldwidth)
    cv.blend(oldblend)
end

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

function cp.round_rect(x, y, w, h, radius, borderwd, fillrgba)
    -- draw a rounded rectangle using the given radius for the corners
    -- with a border in the current color using the given width (if > 0)
    -- and filled with the given color (if fillrgba isn't {})

    if radius == 0 then
        -- draw a non-rounded rectangle (possibly translucent)
        local oldblend = cv.blend(1)
        if borderwd > 0 then
            -- draw border lines using current color
            cv.fill(x, y, w, borderwd)
            cv.fill(x, y+h-borderwd, w, borderwd)
            cv.fill(x, y+borderwd, borderwd, h-borderwd*2)
            cv.fill(x+w-borderwd, y+borderwd, borderwd, h-borderwd*2)
        end
        if #fillrgba > 0 then
            -- draw interior of rectangle
            local oldrgba = cv.rgba(fillrgba)
            cv.fill(x+borderwd, y+borderwd, w-borderwd*2, h-borderwd*2)
            cv.rgba(oldrgba)
        end
        cv.blend(oldblend)
        return
    end

    if radius > w/2 then radius = int(w/2) end
    if radius > h/2 then radius = int(h/2) end

    -- construct rounded rectangle in temporary clip
    local tempclip = "roundedrect"
    cv.create(w, h, tempclip)
    local oldtarget = cv.target(tempclip)
    local oldblend = cv.blend(0)

    -- create bottom right quarter circle in top left corner of tempclip
    cp.fill_ellipse(-radius, -radius, radius*2, radius*2, borderwd, fillrgba)
    cv.copy(0, 0, radius, radius, "qcircle")

    -- draw corners
    cv.paste("qcircle", w-radius, h-radius)
    local oldtransform = cv.transform(cp.flip_y)
    cv.paste("qcircle", w-radius, radius-1)
    cv.transform(cp.flip_x)
    cv.paste("qcircle", radius-1, h-radius)
    cv.transform(cp.flip)
    cv.paste("qcircle", radius-1, radius-1)
    cv.transform(cp.identity)
    cv.delete("qcircle")
    cv.transform(oldtransform)

    if #fillrgba > 0 then
        -- draw non-corner portions of rectangle
        local oldrgba = cv.rgba(fillrgba)
        if radius < w/2 then
            cv.fill(radius, 0, w-radius*2, h)
        end
        if radius < h/2 then
            cv.fill(0, radius, radius, h-radius*2)
            cv.fill(w-radius, radius, radius, h-radius*2)
        end
        cv.rgba(oldrgba)
    end

    if borderwd > 0 then
        -- draw border lines using current color
        if radius < w/2 then
            cv.fill(radius, 0, w-radius*2, borderwd)
            cv.fill(radius, h-borderwd, w-radius*2, borderwd)
        end
        if radius < h/2 then
            cv.fill(0, radius, borderwd, h-radius*2)
            cv.fill(w-borderwd, radius, borderwd, h-radius*2)
        end
    end

    -- restore target and blend rounded rectangle stored in tempclip
    cv.target(oldtarget)
    cv.blend(1)
    cv.paste(tempclip, x, y)
    cv.delete(tempclip)

    -- restore blend setting
    cv.blend(oldblend)
end

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

local function draw_buttonlayer(b, x, y, w, h, color)
    local oldblend = cv.blend(0)

    -- copy rect under button to temp_bg
    cv.copy(x, y, w, h, "temp_bg")

    -- clear rect under button
    local oldrgba = cv.rgba(0,0,0,0)
    cv.fill(x, y, w, h)

    -- draw button with rounded corners
    if b.border > 0 then
        cv.rgba(b.bordercolor)
    end
    cp.round_rect(x, y, w, h, b.radius, b.border, color)

    -- copy rect to temp_button
    cv.copy(x, y, w, h, "temp_button")

    -- paste temp_bg back to rect
    cv.paste("temp_bg", x, y)

    -- turn on blending and paste temp_button
    cv.blend(1)
    cv.paste("temp_button", x, y)
    
    cv.delete("temp_bg")
    cv.delete("temp_button")

    cv.rgba(oldrgba)
    cv.blend(oldblend)
end

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

local function draw_button(b, x, y, w, h)
    if b.buttonshadowx ~= 0 or b.buttonshadowy ~= 0 then
        draw_buttonlayer(b, x + b.buttonshadowx, y + b.buttonshadowy, w, h, b.buttonshadowcolor)
    end

    local butt_rgba = b.backcolor
    if darken_button then
        butt_rgba = {max(0,butt_rgba[1]-48),
                     max(0,butt_rgba[2]-48),
                     max(0,butt_rgba[3]-48),
                     butt_rgba[4]}
    end

    draw_buttonlayer(b, x, y, w, h, butt_rgba)
end

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

function cp.button(label, onclick, args)
    -- return a table that makes it easy to create and use buttons
    local b = {}

    args = args or {}

    if type(label) ~= "string" then
        error("1st arg of button must be a string", 2)
    end
    if type(onclick) ~= "function" then
        error("2nd arg of button must be a function", 2)
    end
    if type(args) ~= "table" then
        error("3rd arg of button must be a table", 2)
    end
    
    b.onclick = onclick     -- remember click handler
    b.fargs = args          -- pass these arguments to b.onclick
    b.shown = false         -- b.show hasn't been called
    b.enabled = true
    
    -- initialize settings for this button using current default settings:
    b.ht = cp.buttonht
    b.backcolor = {unpack(cp.buttonrgba)}
    b.textcolor = {unpack(cp.textrgba)}
    b.textfont = cp.textfont
    b.textfontsize = cp.textfontsize
    b.border = cp.border
    b.bordercolor = {unpack(cp.borderrgba)}
    b.radius = cp.radius
    b.textshadowx = cp.textshadowx
    b.textshadowy = cp.textshadowy
    b.textshadowcolor = {unpack(cp.textshadowrgba)}
    b.buttonshadowx = cp.buttonshadowx
    b.buttonshadowy = cp.buttonshadowy
    b.buttonshadowcolor = {unpack(cp.buttonshadowrgba)}
    b.textgap = cp.textgap
    b.yoffset = cp.yoffset
    
    local initialwd = cp.buttonwd

    b.setlabel = function (newlabel, changesize)
        if newlabel == "" then newlabel = " " end
        local oldfont, oldsize = cv.font(b.textfont, b.textfontsize)
        local oldback = cv.textback(0, 0, 0, 0)
        local w, h
        if b.enabled then
            w, h = cp.maketext(newlabel, b.labelclip, b.textcolor, b.textshadowx, b.textshadowy, b.textshadowcolor)
        else
            -- make text slightly lighter than background, and no shadow
            w, h = cp.maketext(newlabel, b.labelclip, {255,255,255,64})
        end
        cv.textback(oldback)
        cv.font(oldfont, oldsize)
        b.labelwd = w
        b.labelht = h
        if initialwd > 0 then
            b.wd = initialwd
        elseif changesize then
            -- use label size to set button width
            b.wd = b.labelwd + 2*b.textgap
        end
        b.label = newlabel
    end

    -- create text for label with a unique clip name
    b.labelclip = tostring(b).."+button"
    b.setlabel(label, true)
    initialwd = 0

    b.setbackcolor = function (rgba)
        b.backcolor = {unpack(rgba)}
    end
    
    b.settextcolor = function (rgba)
        b.textcolor = {unpack(rgba)}
        b.setlabel(b.label, false)
    end

    b.setfont = function (fontname, fontsize)
        if #fontname > 0 then b.textfont = fontname end
        if fontsize then b.textfontsize = fontsize end
        b.setlabel(b.label, true)
    end

    b.setborder = function (size, rgba)
        if size then b.border = size end
        if rgba then b.bordercolor = {unpack(rgba)} end
    end

    b.settextshadow = function (x, y, rgba)
        b.textshadowx = x
        b.textshadowy = y
        if rgba then b.textshadowcolor = {unpack(rgba)} end
        b.setlabel(b.label, true)
    end

    b.setbuttonshadow = function (x, y, rgba)
        b.buttonshadowx = x
        b.buttonshadowy = y
        if rgba then b.buttonshadowcolor = {unpack(rgba)} end
    end

    b.settextgap = function (i)
        b.textgap = i
        b.setlabel(b.label, true)
    end

    -- create clip name for saving and restoring background pixels
    b.background = b.labelclip.."+bg"

    b.show = function (x, y)
        if b.shown then
            -- remove old button from button_rects
            button_rects[b.rect] = nil
        end

        -- save position
        b.x = x
        b.y = y
        
        -- save background pixels
        cv.copy(b.x, b.y, b.wd, b.ht, b.background)

        -- draw the button at the given location
        draw_button(b, b.x, b.y, b.wd, b.ht)

        -- draw the label
        local oldblend = cv.blend(1)
        x = int(x + 1 + (b.wd - b.labelwd) / 2)
        y = int(y + b.yoffset + (b.ht - b.labelht) / 2)
        cv.paste(b.labelclip, x, y)
        cv.blend(oldblend)

        -- store this table using the button's rectangle as key
        b.rect = rect(b.x, b.y, b.wd, b.ht)
        button_rects[b.rect] = b
        b.shown = true
    end

    b.hide = function ()
        if b.shown then
            -- restore background pixels saved in b.show
            local oldblend = cv.blend(0)
            cv.paste(b.background, b.x, b.y)
            cv.blend(oldblend)

            -- remove the table entry
            button_rects[b.rect] = nil
            b.shown = false
        end
    end

    b.enable = function (bool)
        if b.enabled ~= bool then
            b.enabled = bool
            b.setlabel(b.label, false)
            if b.shown then b.show(b.x, b.y) end
        end
    end

    b.refresh = function ()
        -- redraw button immediately
        if b.shown then
            -- restore background pixels saved in b.show
            local oldblend = cv.blend(0)
            cv.paste(b.background, b.x, b.y)
            cv.blend(oldblend)
        end
        b.show(b.x, b.y)
        glu.update()
    end

    return b
end

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

local function draw_selectbutton(b, x, y, w, h)
    -- draw button for given check box or radio button
    draw_button(b, x, y, w, h)
    local disabled = {min(255,b.backcolor[1]+48),
                      min(255,b.backcolor[2]+48),
                      min(255,b.backcolor[3]+48),
                      b.backcolor[4]}
    if b.multi then
        -- draw a radio button
        local optcol = disabled
        if b.ticked and b.enabled then optcol = b.textcolor end
        local x1 = int(x+w/6)
        local y1 = int(y+w/6)
        local w1 = w - int(w/3)
        local h1 = h - int(h/3)
        if b.enabled and (b.textshadowx > 0 or b.textshadowy > 0) then
            cp.fill_ellipse(x1+b.textshadowx, y1+b.textshadowy, w1, h1, 0, b.textshadowcolor)
        end
        if b.enabled or b.ticked then
            cp.fill_ellipse(x1, y1, w1, h1, 0, optcol)
        end
    else
        if b.ticked then
            -- draw a tick mark
            local oldrgba
            if b.enabled then
                oldrgba = cv.rgba(b.textcolor)
            else
                oldrgba = cv.rgba(disabled)
            end
            local oldblend = cv.blend(1)
            local oldwidth = cv.linewidth(w//5)

            local x1 = round(x+w/2)
            local y1 = round(y+h*0.7)
            local x2 = round(x+w*0.7)
            local y2 = round(y+h*0.2)
            local x3 = round(x+w/2)
            local y3 = round(y+h*0.7)
            local x4 = round(x+w*0.2)
            local y4 = round(y+h*0.6)
            y1 = y1+w//12
            x1 = x1+1
            x2 = x2+1
            x3 = x3+1
            x4 = x4+1

            if b.enabled and (b.textshadowx > 0 or b.textshadowy > 0) then
                local oldcol = cv.rgba(b.textshadowcolor)
                cv.line(x1+b.textshadowx, y1+b.textshadowy, x2+b.textshadowx, y2+b.textshadowy)
                cv.line(x3+b.textshadowx, y3+b.textshadowy, x4+b.textshadowx, y4+b.textshadowy)
                cv.rgba(oldcol)
            end
            cv.line(x1, y1, x2, y2)
            cv.line(x3, y3, x4, y4)
            cv.linewidth(oldwidth)
            cv.blend(oldblend)
            cv.rgba(oldrgba)
        end
    end
end

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

function cp.radiobutton(label, onclick)
    return cp.selectbutton(label, onclick, true)
end

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

function cp.checkbox(label, onclick)
    return cp.selectbutton(label, onclick, false)
end

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

function cp.selectbutton(label, onclick, multi)
    -- return a table that makes it easy to create and use check boxes and radio buttons
    local c = {}
    
    local what
    if multi then what = "checkbox" else what = "radiobutton" end
    if type(label) ~= "string" then
        error("1st arg of "..what.." must be a string", 2)
    end
    if type(onclick) ~= "function" then
        error("2nd arg of "..what.." must be a function", 2)
    end

    c.onclick = onclick     -- remember click handler
    c.shown = false         -- c.show hasn't been called
    c.enabled = true
    c.multi = multi
    
    -- initialize settings for this checkbox/radiobutton using current default settings:
    c.ht = cp.buttonht
    c.backcolor = {unpack(cp.buttonrgba)}
    c.textcolor = {unpack(cp.textrgba)}
    c.labelcolor = {unpack(cp.labelrgba)}
    c.textfont = cp.textfont
    c.textfontsize = cp.textfontsize
    c.border = cp.border
    c.bordercolor = {unpack(cp.borderrgba)}
    c.radius = cp.radius
    c.textshadowx = cp.textshadowx
    c.textshadowy = cp.textshadowy
    c.textshadowcolor = {unpack(cp.textshadowrgba)}
    c.buttonshadowx = cp.buttonshadowx
    c.buttonshadowy = cp.buttonshadowy
    c.buttonshadowcolor = {unpack(cp.buttonshadowrgba)}
    c.yoffset = cp.yoffset
    c.selectgap = cp.selectgap

    c.setlabel = function (newlabel)
        if newlabel == "" then newlabel = " " end
        local oldfont, oldsize = cv.font(c.textfont, c.textfontsize)
        local oldback = cv.textback(0, 0, 0, 0)
        local w, h = cp.maketext(newlabel, c.labelclip, c.labelcolor, c.textshadowx, c.textshadowy, c.textshadowcolor)
        -- also create darker version of label for when the button is selected
        local darklabel = {max(0,c.labelcolor[1]-48),
                           max(0,c.labelcolor[2]-48),
                           max(0,c.labelcolor[3]-48),
                           c.labelcolor[4]}
        cp.maketext(newlabel, c.darkclip, darklabel, c.textshadowx, c.textshadowy, c.textshadowcolor)
        cv.textback(oldback)
        cv.font(oldfont, oldsize)

        -- use label size to set select button size
        c.labelwd = w
        c.labelht = h
        c.wd = c.ht + c.selectgap + c.labelwd
        c.label = newlabel
    end

    -- create text for label with a unique clip name
    c.labelclip = tostring(c).."+"..what
    c.darkclip = c.labelclip.."dark"
    c.setlabel(label)

    c.setbackcolor = function (rgba) c.backcolor = {unpack(rgba)} end
    c.settextcolor = function (rgba) c.textcolor = {unpack(rgba)} end

    c.setlabelcolor = function (rgba)
        c.labelcolor = {unpack(rgba)}
        c.setlabel(c.label)
    end

    c.setfont = function (fontname, fontsize)
        if #fontname > 0 then c.textfont = fontname end
        if fontsize then c.textfontsize = fontsize end
        c.setlabel(c.label)
    end

    c.setborder = function (size, rgba)
        if size then c.border = size end
        if rgba then c.bordercolor = {unpack(rgba)} end
    end

    c.settextshadow = function (x, y, rgba)
        c.textshadowx = x
        c.textshadowy = y
        if rgba then c.textshadowcolor = {unpack(rgba)} end
        c.setlabel(c.label)
    end

    c.setbuttonshadow = function (x, y, rgba)
        c.buttonshadowx = x
        c.buttonshadowy = y
        if rgba then c.buttonshadowcolor = {unpack(rgba)} end
    end

    -- create clip name for saving and restoring background pixels
    c.background = c.labelclip.."+bg"

    c.show = function (x, y, ticked)
        if c.shown then
            -- remove old select button from select_rects
            select_rects[c.rect] = nil
        end

        -- save position
        c.x = x
        c.y = y
        c.ticked = ticked
        
        -- save background pixels
        cv.copy(c.x, c.y, c.wd, c.ht, c.background)

        -- draw the select button (excluding label) at the given location
        draw_selectbutton(c, x+1, y+1, c.ht-2, c.ht-2)

        -- draw the label
        local oldblend = cv.blend(1)
        if darken_button then
            -- draw darkened label
            cv.paste(c.darkclip, (x+c.ht+c.selectgap), int(y+c.yoffset+(c.ht-c.labelht)/2))
        else
            cv.paste(c.labelclip, (x+c.ht+c.selectgap), int(y+c.yoffset+(c.ht-c.labelht)/2))
        end
        cv.blend(oldblend)

        -- store this table using the select button's rectangle as key
        c.rect = rect(c.x, c.y, c.wd, c.ht)
        select_rects[c.rect] = c
        c.shown = true
    end

    c.hide = function ()
        if c.shown then
            -- restore background pixels saved in c.show
            local oldblend = cv.blend(0)
            cv.paste(c.background, c.x, c.y)
            cv.blend(oldblend)
            -- remove the table entry
            select_rects[c.rect] = nil
            c.shown = false
        end
    end

    c.enable = function (bool)
        c.enabled = bool
        if c.shown then c.show(c.x, c.y, c.ticked) end
    end

    c.refresh = function ()
        -- redraw checkbox or radiobutton immediately
        if c.shown then
            -- restore background pixels saved in c.show
            local oldblend = cv.blend(0)
            cv.paste(c.background, c.x, c.y)
            cv.blend(oldblend)
        end
        c.show(c.x, c.y, c.ticked)
        glu.update()
    end

    return c
end

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

local function draw_slider(s, barpos)
    -- draw horizontal bar
    cp.round_rect(s.startbar, s.y+(s.ht-s.barht)//2, s.barwd, s.barht, s.barht//2, 0, cp.gray)

    if s.darken then darken_button = true end

    -- draw slider button on top of horizontal bar
    draw_button(s, s.startbar + barpos - int(s.sliderwd/2), s.y + abs(s.buttonshadowy),
                   s.sliderwd, s.ht - 2*abs(s.buttonshadowy))

    if s.darken then darken_button = false end
end

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

function cp.slider(barwidth, minval, maxval, onclick)
    -- return a table that makes it easy to create and use sliders
    local s = {}

    if type(onclick) ~= "function" then
        error("4th arg of slider must be a function", 2)
    end
    if barwidth <= 0 then
        error("slider width must be > 0", 2)
    end
    if minval >= maxval then
        error("minimum slider value must be < maximum value", 2)
    end

    s.onclick = onclick     -- remember click handler
    s.shown = false         -- s.show hasn't been called
    s.darken = false        -- only true during click_in_slider
    s.barwd = barwidth      -- width of the slider bar
    s.minval = minval
    s.maxval = maxval
    
    -- initialize settings for this slider using current default settings:
    s.backcolor = {unpack(cp.buttonrgba)}
    s.border = cp.border
    s.bordercolor = {unpack(cp.borderrgba)}
    s.radius = cp.radius
    s.buttonshadowx = cp.buttonshadowx
    s.buttonshadowy = cp.buttonshadowy
    s.buttonshadowcolor = {unpack(cp.buttonshadowrgba)}
    s.sliderwd = cp.sliderwd
    s.sliderht = cp.sliderht
    s.barht = cp.barht

    s.setbackcolor = function (rgba) s.backcolor = {unpack(rgba)} end

    s.setborder = function (size, rgba)
        if size then s.border = size end
        if rgba then s.bordercolor = {unpack(rgba)} end
    end

    s.setbuttonshadow = function (x, y, rgba)
        s.buttonshadowx = x
        s.buttonshadowy = y
        if rgba then s.buttonshadowcolor = {unpack(rgba)} end
    end

    -- set total slider size (including any shadows)
    s.wd = s.sliderwd + s.barwd + abs(s.buttonshadowx)
    s.ht = s.sliderht + 2*abs(s.buttonshadowy)

    -- create unique clip name for slider's background
    s.background = tostring(s).."+bg"

    s.show = function (x, y, pos)
        s.pos = int(pos)
        if s.pos < s.minval then s.pos = s.minval end
        if s.pos > s.maxval then s.pos = s.maxval end
        if s.shown then
            -- remove old slider from slider_rects
            slider_rects[s.rect] = nil
        end

        -- remember slider position
        s.x = x
        s.y = y
        
        -- save background pixels (including any covered by shadow)
        cv.copy(s.x, s.y, s.wd, s.ht, s.background)

        -- draw the slider at the given location
        s.startbar = x + int(s.sliderwd/2)
        local barpos = int(s.barwd * (s.pos - s.minval) / (s.maxval - s.minval))
        draw_slider(s, barpos)

        -- store this table using the slider bar's rectangle as key, including
        -- overlap of half button width at left and right edges of bar
        s.rect = rect(s.startbar-int(s.sliderwd/2), s.y, s.barwd+s.sliderwd, s.ht)
        slider_rects[s.rect] = s
        s.shown = true
    end

    s.hide = function ()
        if s.shown then
            -- restore background pixels saved in previous s.show
            local oldblend = cv.blend(0)
            cv.paste(s.background, s.x, s.y)
            cv.blend(oldblend)
            -- remove the table entry
            slider_rects[s.rect] = nil
            s.shown = false
        end
    end

    s.refresh = function ()
        -- redraw slider immediately
        if s.shown then
            -- restore background pixels saved in previous s.show
            local oldblend = cv.blend(0)
            cv.paste(s.background, s.x, s.y)
            cv.blend(oldblend)
        end
        s.show(s.x, s.y, s.pos)
        glu.update()
    end

    return s
end

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

local function release_in_rect(r, b)
    -- return true if mouse button is released while in given rect
    local inrect = true

    -- draw darkened button
    darken_button = true
    b.refresh()

    local t0 = glu.millisecs()

    while true do
        local event = glu.getevent()
        if event == "mup left" then break end
        local wasinrect = inrect
        local x, y = cv.getxy()
        if x < 0 or y < 0 then
            inrect = false      -- mouse is outside canvas
        else
            inrect = x >= r.left and x <= r.right and y >= r.top and y <= r.bottom
        end
        if inrect ~= wasinrect then
            -- mouse has moved in/out of r
            darken_button = inrect
            b.refresh()
        end
    end

    -- pause to ensure darkened button is seen
    while glu.millisecs() - t0 < 16 do end

    if inrect then
        -- undarken button
        darken_button = false
        b.refresh()
    end

    return inrect
end

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

local function click_in_button(x, y)
    for r, button in pairs(button_rects) do
        if x >= r.left and x <= r.right and y >= r.top and y <= r.bottom then
            if button.enabled and release_in_rect(r, button) then
                -- call this button's handler
                button.onclick( unpack(button.fargs) )
            end
            return true
        end
    end
    return false
end

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

local function click_in_checkbox_or_radio(x, y)
    for r, button in pairs(select_rects) do
        -- r includes button and label
        if x >= r.left and x <= r.right and y >= r.top and y <= r.bottom then
            if button.enabled and release_in_rect(r, button) then
                button.show(button.x, button.y, not button.ticked)
                -- call the handler, passing in which button was clicked
                button.onclick(button)
            end
            return true
        end
    end
    return false
end

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

local function click_in_slider(x, y)
    for r, slider in pairs(slider_rects) do
        if x >= r.left and x <= r.right and y >= r.top and y <= r.bottom then
            -- draw darkened slider button
            slider.darken = true
            slider.refresh()

            local prevx = x
            local range = slider.maxval - slider.minval + 1
            local maxx = slider.startbar + slider.barwd

            -- check if click is outside slider button
            local barpos = int(slider.barwd * (slider.pos - slider.minval) / (slider.maxval - slider.minval))
            local buttrect = rect(slider.startbar+barpos-int(slider.sliderwd/2), slider.y, slider.sliderwd, slider.ht)
            if not (x >= buttrect.left and x <= buttrect.right and y >= buttrect.top and y <= buttrect.bottom) then
                -- move button to clicked position immediately
                prevx = math.maxinteger
            end

            -- track horizontal movement of mouse until button is released
            while true do
                local event = glu.getevent()
                if event == "mup left" then break end
                x, y = cv.getxy()
                if x >= 0 and y >= 0 then
                    -- check if slider position needs to change
                    if x < slider.startbar then x = slider.startbar end
                    if x > maxx then x = maxx end

                    if x ~= prevx then
                        -- draw new position of slider button immediately;
                        -- first restore background pixels saved in previous slider.show
                        local oldblend = cv.blend(0)
                        cv.paste(slider.background, slider.x, slider.y)
                        cv.blend(oldblend)
                        draw_slider(slider, x - slider.startbar)
                        glu.update()

                        -- now check if slider value has to change
                        local newpos = int(slider.minval + range * (x - slider.startbar) / slider.barwd)
                        if newpos < slider.minval then newpos = slider.minval end
                        if newpos > slider.maxval then newpos = slider.maxval end
                        if slider.pos ~= newpos then
                            slider.pos = newpos
                            -- call this slider's handler with the new position and the slider
                            slider.onclick(newpos, slider)
                        end

                        prevx = x
                    end
                end
            end

            -- draw undarkened slider button
            slider.darken = false
            slider.refresh()
            return true
        end
    end
    return false
end

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

local function DrawMenuBar(mbar)
    local oldrgba = cv.rgba(mbar.menubg)
    cv.fill(mbar.r.x, mbar.r.y, mbar.r.wd, mbar.r.ht)
    local oldblend = cv.blend(1)
    local xpos = mbar.r.x + mbar.menugap
    local ypos = mbar.r.y + mbar.itemy + (mbar.r.ht - mbar.labelht) // 2
    for i = 1, #mbar.menus do
        local menu = mbar.menus[i]
        if i == selmenu then
            cv.rgba(mbar.selcolor)
            cv.fill(xpos, mbar.r.y, menu.labelwd + mbar.menugap*2, mbar.r.ht)
        end
        xpos = xpos + mbar.menugap
        cv.paste(menu.labelclip, xpos, ypos)
        xpos = xpos + menu.labelwd + mbar.menugap
    end
    cv.blend(oldblend)
    cv.rgba(oldrgba)
end

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

function cp.menubar()
    -- return a table that makes it easy to create and use a menu bar
    local mbar = {}

    mbar.menus = {}
    mbar.r = {}             -- menu bar's bounding rectangle
    mbar.labelht = 0        -- height of label text
    mbar.itemht = 0         -- height of a menu item
    mbar.shown = false      -- mbar.show hasn't been called

    mbar.setfont = function (fontname, fontsize)
        if #fontname > 0 then mbar.menufont = fontname end
        if fontsize then mbar.menufontsize = fontsize end
    end

    mbar.settextcolor = function (rgba)
        mbar.menutext = {unpack(rgba)}
    end
    
    mbar.setbackcolor = function (rgba)
        local R, G, B, A = unpack(rgba)
        mbar.menubg = {R, G, B, A}
        -- use a darker color when menu/item is selected
        mbar.selcolor = {max(0,R-48), max(0,G-48), max(0,B-48), A}
        -- use lighter color for disabled items and separator lines
        mbar.discolor = {min(255,R+48), min(255,G+48), min(255,B+48), A}
    end

    mbar.settextshadow = function (x, y, rgba)
        mbar.textshadowx = x
        mbar.textshadowy = y
        if rgba then mbar.textshadowcolor = {unpack(rgba)} end
    end
    
    -- initialize settings for this menubar using current default settings:
    mbar.setfont(cp.menufont, cp.menufontsize)
    mbar.settextcolor(cp.menutext)
    mbar.setbackcolor(cp.menubg)
    mbar.settextshadow(cp.textshadowx, cp.textshadowy, cp.textshadowrgba)
    mbar.menugap = cp.menugap
    mbar.rightgap = cp.rightgap
    mbar.itemgap = cp.itemgap
    mbar.itemy = cp.itemy

    mbar.addmenu = function (menuname)
        -- append a menu to the menu bar
        local index = #mbar.menus+1
        local clipname = tostring(mbar)..index
        local oldfont, oldsize = cv.font(mbar.menufont, mbar.menufontsize)
        local oldblend = cv.blend(1)
        local oldback = cv.textback(0, 0, 0, 0)
        local wd, ht = cp.maketext(menuname, clipname, mbar.menutext, mbar.textshadowx, mbar.textshadowy, mbar.textshadowcolor)
        cv.textback(oldback)
        cv.blend(oldblend)
        cv.font(oldfont, oldsize)
        mbar.labelht = ht
        mbar.itemht = ht + mbar.itemgap*2
        mbar.menus[index] = { labelwd=wd, labelclip=clipname, items={}, maxwd=0 }
    end

    local function check_width(menuindex, itemname)
        local oldfont, oldsize = cv.font(mbar.menufont, mbar.menufontsize)
        local oldblend = cv.blend(1)
        local wd, _ = cp.maketext(itemname, nil, mbar.menutext, mbar.textshadowx, mbar.textshadowy, mbar.textshadowcolor)
        cv.blend(oldblend)
        cv.font(oldfont, oldsize)
        local itemwd = wd + mbar.menugap*2 + mbar.rightgap
        if itemwd > mbar.menus[menuindex].maxwd then
            mbar.menus[menuindex].maxwd = itemwd
        end
    end

    mbar.additem = function (menuindex, itemname, onclick, args)
        -- append an item to given menu
        args = args or {}
        check_width(menuindex, itemname)
        local items = mbar.menus[menuindex].items
        items[#items+1] = { name=itemname, f=onclick, fargs=args, enabled=(onclick ~= nil), type=item_normal, value=false }
    end

    mbar.setitem = function (menuindex, itemindex, newname)
        -- change name of given menu item
        check_width(menuindex, newname)
        mbar.menus[menuindex].items[itemindex].name = newname
    end

    mbar.enableitem = function (menuindex, itemindex, bool)
        mbar.menus[menuindex].items[itemindex].enabled = bool
    end

    mbar.tickitem = function (menuindex, itemindex, bool)
        -- tick/untick the given menu item
        mbar.menus[menuindex].items[itemindex].type = item_tick
        mbar.menus[menuindex].items[itemindex].value = bool
    end

    mbar.radioitem = function (menuindex, itemindex, bool)
        -- mark menu item as a radio item and set its value
        mbar.menus[menuindex].items[itemindex].type = item_radio
        mbar.menus[menuindex].items[itemindex].value = bool
    end

    mbar.show = function (x, y, wd, ht)
        if wd > 0 and ht > 0 then
            if mbar.shown then
                -- remove old rect from menubar_rects
                menubar_rects[mbar.r] = nil
            end
            mbar.r = rect(x, y, wd, ht)
            DrawMenuBar(mbar)
            menubar_rects[mbar.r] = mbar
            mbar.shown = true
        end
    end

    mbar.hide = function ()
        if mbar.shown then
            menubar_rects[mbar.r] = nil
            mbar.shown = false
        end
    end

    mbar.refresh = function ()
        -- redraw menu bar
        mbar.show(mbar.r.x, mbar.r.y, mbar.r.wd, mbar.r.ht)
        glu.update()
    end

    return mbar
end

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

local function GetMenu(x, y, oldmenu, mbar)
    -- return menu index depending on given mouse location
    if y < mbar.r.y then return oldmenu end
    if y > mbar.r.y + mbar.r.ht - 1 then return oldmenu end

    if x < mbar.r.x + mbar.menugap then return 0 end
    if x > mbar.r.x + mbar.r.wd - 1 then return 0 end

    local endmenu = mbar.r.x + mbar.menugap
    for i = 1, #mbar.menus do
        endmenu = endmenu + mbar.menus[i].labelwd + mbar.menugap*2
        if x < endmenu then
            return i
        end
    end

    return 0
end

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

local function GetItem(x, y, menuindex, mbar)
    -- return index of menu item at given mouse location
    if menuindex == 0 then return 0 end

    if y < mbar.r.y + mbar.r.ht then return 0 end

    local numitems = #mbar.menus[menuindex].items
    local ht = numitems * mbar.itemht
    if y > mbar.r.y + mbar.r.ht + ht then return 0 end

    local mleft = mbar.r.x + mbar.menugap
    for i = 2, menuindex do
        mleft = mleft + mbar.menus[i-1].labelwd + mbar.menugap*2
    end
    if x < mleft then return 0 end
    if x > mleft + mbar.menus[menuindex].maxwd then return 0 end

    -- x,y is somewhere in a menu item
    local itemindex = (y - (mbar.r.y + mbar.r.ht)) // mbar.itemht + 1
    if itemindex > numitems then itemindex = numitems end

    return itemindex
end

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

local function DrawMenuItems(mbar)
    -- draw drop-down window showing all items in the currently selected menu
    local numitems = #mbar.menus[selmenu].items
    if numitems == 0 then return end

    local oldrgba = cv.rgba(mbar.menubg)
    local x = mbar.r.x + mbar.menugap
    for i = 2, selmenu do
        x = x + mbar.menus[i-1].labelwd + mbar.menugap*2
    end
    local ht = numitems * mbar.itemht + 1
    local wd = mbar.menus[selmenu].maxwd
    local y = mbar.r.y + mbar.r.ht
    cv.fill(x, y, wd, ht)

    local oldfont, oldsize = cv.font(mbar.menufont, mbar.menufontsize)
    local oldblend = cv.blend(1)

    -- draw translucent gray shadows
    cv.rgba(48,48,48,128)
    local shadowsize = 3
    cv.fill(x+shadowsize, y+ht, wd-shadowsize, shadowsize)
    cv.fill(x+wd, y, shadowsize, ht+shadowsize)

    x = x + mbar.menugap
    y = y + mbar.itemy
    for i = 1, numitems do
        local item = mbar.menus[selmenu].items[i]
        if item.f == nil then
            -- item is a separator
            cv.rgba(mbar.discolor)
            cv.line(x-mbar.menugap, y+mbar.itemht//2, x-mbar.menugap+wd-1, y+mbar.itemht//2)
        else
            if i == selitem and item.enabled then
                cv.rgba(mbar.selcolor)
                cv.fill(x-mbar.menugap, y, wd, mbar.itemht)
            end
            local oldback = cv.textback(0, 0, 0, 0)
            if item.enabled then
                cp.maketext(item.name, nil, mbar.menutext, mbar.textshadowx, mbar.textshadowy, mbar.textshadowcolor)
                cp.pastetext(x, y + mbar.itemgap)
                cv.rgba(mbar.menutext)
            else
                cp.maketext(item.name, nil, mbar.discolor)  -- no shadow if disabled
                cp.pastetext(x, y + mbar.itemgap)
                cv.rgba(mbar.discolor)
            end
            cv.textback(oldback)
            if item.type == item_tick then
                if item.value then
                    -- draw tick mark at right edge
                    local x1 = x - mbar.menugap + wd - mbar.menugap
                    local y1 = y + 6
                    local x2 = x1 - 6
                    local y2 = y + mbar.itemht - 8
                    local oldwidth = cv.linewidth(4)
                    if item.enabled and (mbar.textshadowx > 0 or mbar.textshadowy > 0) then
                        local oldcolor = cv.rgba(mbar.textshadowcolor)
                        cv.line(x1+mbar.textshadowx, y1+mbar.textshadowy, x2+mbar.textshadowx, y2+mbar.textshadowy)
                        cv.line(x2+mbar.textshadowx, y2+mbar.textshadowy, x2+mbar.textshadowx-5, y2+mbar.textshadowy-3)
                        cv.rgba(oldcolor)
                    end
                    cv.line(x1, y1, x2, y2)
                    cv.line(x2, y2, x2-5, y2-3)
                    cv.linewidth(oldwidth)
                end
            elseif item.type == item_radio then
                -- draw radio button at right edge
                local size = mbar.itemht - 12
                local x1 = x - mbar.menugap + wd - mbar.menugap - size
                local y1 = y + 6
                if item.enabled and (mbar.textshadowx > 0 or mbar.textshadowy > 0) then
                    cp.fill_ellipse(x1+mbar.textshadowx, y1+mbar.textshadowy, size, size, 0, mbar.textshadowcolor)
                end
                local optcol = mbar.discolor
                if item.value and item.enabled then optcol = mbar.menutext end
                if item.enabled or item.value then
                    cp.fill_ellipse(x1, y1, size, size, 0, optcol)
                end
            end
        end
        y = y + mbar.itemht
    end

    cv.blend(oldblend)
    cv.font(oldfont, oldsize)
    cv.rgba(oldrgba)
end

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

local function release_in_item(x, y, mbar)
    -- user clicked given point in menu bar so return the selected menu item
    -- on release, or nil if the item is disabled or no item is selected

    local t0 = glu.millisecs()
    local MacOS = glu.os() == "Mac"

    selitem = 0
    selmenu = GetMenu(x, y, 0, mbar)
    if selmenu == 0 and not MacOS then
        -- if initial click is not in any menu then ignore it on Windows/Linux
        return nil
    end

    -- save entire canvas (including menu bar) in bgclip
    local bgclip = tostring(mbar).."bg"
    cv.copy(0, 0, 0, 0, bgclip)

    if selmenu > 0 then
        DrawMenuBar(mbar)       -- highlight selected menu
        DrawMenuItems(mbar)
        glu.update()
    end

    local prevx = x
    local prevy = y
    local menuitem = nil
    -- on Windows/Linux we loop until an enabled item is clicked;
    -- on Mac we loop until an enabled or disabled item is clicked
    while true do
        -- loop until click or keypress
        while true do
            local event = glu.getevent()
            if event == "mup left" then
                if glu.millisecs() - t0 > 500 then
                    local oldmenu = selmenu
                    selmenu = GetMenu(x, y, selmenu, mbar)
                    if selmenu == 0 and not MacOS then selmenu = oldmenu end
                    break
                end
            elseif event:find("^cclick") then
                local _, sx, sy, butt, mods = split(event)
                if butt == "left" and mods == "none" then
                    x = tonumber(sx)
                    y = tonumber(sy)
                    local oldmenu = selmenu
                    selmenu = GetMenu(x, y, selmenu, mbar)
                    if selmenu == 0 and not MacOS then selmenu = oldmenu end
                    break
                end
            elseif event == "key return none" then
                break
            end
            x, y = cv.getxy()
            if x >= 0 and y >= 0 then
                if x ~= prevx or y ~= prevy then
                    -- check if mouse moved into or out of a menu/item
                    local oldmenu = selmenu
                    local olditem = selitem
                    selmenu = GetMenu(x, y, selmenu, mbar)
                    if selmenu == 0 and not MacOS then selmenu = oldmenu end
                    selitem = GetItem(x, y, selmenu, mbar)
                    if selmenu ~= oldmenu or selitem ~= olditem then
                        cv.paste(bgclip, 0, 0)
                        DrawMenuBar(mbar)
                        if MacOS then
                            if selmenu > 0 then DrawMenuItems(mbar) end
                        else
                            DrawMenuItems(mbar)
                        end
                        glu.update()
                    end
                    prevx = x
                    prevy = y
                end
            end
        end

        if MacOS then
            -- on Mac we can return nil if user clicked a disabled item
            menuitem = nil
            if selmenu > 0 then
                selitem = GetItem(x, y, selmenu, mbar)
                if selitem > 0 and mbar.menus[selmenu].items[selitem].enabled then
                    menuitem = mbar.menus[selmenu].items[selitem]
                end
            end
            break
        else
            -- Windows/Linux
            if selmenu > 0 then
                selitem = GetItem(x, y, selmenu, mbar)
                if selitem > 0 and mbar.menus[selmenu].items[selitem].enabled then
                    menuitem = mbar.menus[selmenu].items[selitem]
                    break
                elseif selitem == 0 then
                    break
                end
            end
        end
    end

    -- restore canvas and menu bar
    cv.paste(bgclip, 0, 0)
    glu.update()
    cv.delete(bgclip)
    selmenu = 0
    return menuitem
end

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

local function click_in_menubar(x, y)
    for r, mbar in pairs(menubar_rects) do
        if x >= r.left and x <= r.right and y >= r.top and y <= r.bottom then
            local menuitem = release_in_item(x, y, mbar)
            if menuitem and menuitem.f then
                -- call this menu item's handler
                menuitem.f( unpack(menuitem.fargs) )
            end
            return true
        end
    end
    return false
end

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

local function DrawPopUpMenu(p, chosenitem)
    -- draw pop-up window showing all items
    local numitems = #p.items
    if numitems == 0 then return end

    local oldfont, oldsize = cv.font(p.menufont, p.menufontsize)
    local oldblend = cv.blend(1)
    local oldrgba = cv.rgba(p.bgcolor)

    local ht = p.menuht + 1
    local wd = p.menuwd
    local x = p.x
    local y = p.y
    cv.fill(x, y, wd, ht)

    -- draw translucent gray shadows
    cv.rgba(48,48,48,128)
    local shadowsize = 3
    cv.fill(x+shadowsize, y+ht, wd-shadowsize, shadowsize)
    cv.fill(x+wd, y+shadowsize, shadowsize, ht)

    x = x + p.menugap
    y = y + p.itemy
    for i = 1, numitems do
        local item = p.items[i]
        if item.f == nil then
            -- item is a separator
            cv.rgba(p.discolor)
            cv.line(x-p.menugap, y+p.itemht//2, x-p.menugap+wd-1, y+p.itemht//2)
        else
            if i == chosenitem and item.enabled then
                cv.rgba(p.selcolor)
                cv.fill(x-p.menugap, y, wd, p.itemht)
            end
            local oldback = cv.textback(0, 0, 0, 0)
            if item.enabled then
                cp.maketext(item.name, nil, p.menutext, p.textshadowx, p.textshadowy, p.textshadowcolor)
                cp.pastetext(x, y + p.itemgap)
                cv.rgba(p.menutext)
            else
                cp.maketext(item.name, nil, p.discolor)  -- no shadow if disabled
                cp.pastetext(x, y + p.itemgap)
                cv.rgba(p.discolor)
            end
            cv.textback(oldback)
            if item.type == item_tick then
                if item.value then
                    -- draw tick mark at right edge
                    local x1 = x - p.menugap + wd - p.menugap
                    local y1 = y + 6
                    local x2 = x1 - 6
                    local y2 = y + p.itemht - 8
                    local oldwidth = cv.linewidth(4)
                    if item.enabled and (p.textshadowx > 0 or p.textshadowy > 0) then
                        local oldcolor = cv.rgba(p.textshadowcolor)
                        cv.line(x1+p.textshadowx, y1+p.textshadowy, x2+p.textshadowx, y2+p.textshadowy)
                        cv.line(x2+p.textshadowx, y2+p.textshadowy, x2+p.textshadowx-5, y2+p.textshadowy-3)
                        cv.rgba(oldcolor)
                    end
                    cv.line(x1, y1, x2, y2)
                    cv.line(x2, y2, x2-5, y2-3)
                    cv.linewidth(oldwidth)
                end
            elseif item.type == item_radio then
                -- draw radio button at right edge
                local size = p.itemht - 12
                local x1 = x - p.menugap + wd - p.menugap - size
                local y1 = y + 6
                if item.enabled and (p.textshadowx > 0 or p.textshadowy > 0) then
                    cp.fill_ellipse(x1+p.textshadowx, y1+p.textshadowy, size, size, 0, p.textshadowcolor)
                end
                local optcol = p.discolor
                if item.value and item.enabled then optcol = p.menutext end
                if item.enabled or item.value then
                    cp.fill_ellipse(x1, y1, size, size, 0, optcol)
                end
            end
        end
        y = y + p.itemht
    end

    cv.blend(oldblend)
    cv.font(oldfont, oldsize)
    cv.rgba(oldrgba)
end

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

local function GetPopUpItem(x, y, p)
    -- return index of item at given mouse location
    if x <= p.x or y <= p.y then return 0 end
    local numitems = #p.items
    if y > p.y + p.menuht then return 0 end
    if x > p.x + p.menuwd then return 0 end

    -- x,y is somewhere in a menu item
    local itemindex = (y - p.y) // p.itemht + 1
    if itemindex > numitems then itemindex = numitems end

    return itemindex
end

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

local function choose_popup_item(p)
    -- return a chosen item from the given pop-up menu
    -- or nil if the item is disabled or no item is selected

    local t0 = glu.millisecs()

    -- save entire canvas in bgclip
    local bgclip = tostring(p).."bg"
    cv.copy(0, 0, 0, 0, bgclip)

    local chosenitem = 0
    DrawPopUpMenu(p, chosenitem)
    glu.update()

    local x = p.x
    local y = p.y
    local prevx = x
    local prevy = y
    while true do
        local event = glu.getevent()
        if event:find("^mup") then
            if glu.millisecs() - t0 > 500 then
                break
            end
        elseif event:find("^cclick") then
            local _, sx, sy, butt, mods = split(event)
            if butt == "left" and mods == "none" then
                x = tonumber(sx)
                y = tonumber(sy)
                break
            end
        elseif event == "key return none" then
            break
        end
        x, y = cv.getxy()
        if x >= 0 and y >= 0 then
            if x ~= prevx or y ~= prevy then
                -- check if mouse moved into or out of an item
                local olditem = chosenitem
                chosenitem = GetPopUpItem(x, y, p)
                if chosenitem ~= olditem then
                    cv.paste(bgclip, 0, 0)
                    DrawPopUpMenu(p, chosenitem)
                    glu.update()
                end
                prevx = x
                prevy = y
            end
        end
    end

    chosenitem = GetPopUpItem(x, y, p)

    -- restore canvas
    cv.paste(bgclip, 0, 0)
    glu.update()
    cv.delete(bgclip)

    return chosenitem
end

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

function cp.popupmenu()
    -- return a table that makes it easy to create and use a pop-up menu
    local p = {}

    p.items = {}        -- array of items
    p.labelht = 0       -- height of label text
    p.itemht = 0        -- height of an item
    p.menuwd = 0        -- width of pop-up menu
    p.menuht = 0        -- height of pop-up menu
    p.x, p.y = 0, 0     -- top left location of pop-up menu

    p.setfont = function (fontname, fontsize)
        if #fontname > 0 then p.menufont = fontname end
        if fontsize then p.menufontsize = fontsize end
    end

    p.settextcolor = function (rgba)
        p.menutext = {unpack(rgba)}
    end

    p.setbackcolor = function (rgba)
        local R, G, B, A = unpack(rgba)
        p.bgcolor = {R, G, B, A}
        -- use a darker color when item is selected
        p.selcolor = {max(0,R-48), max(0,G-48), max(0,B-48), A}
        -- use lighter color for disabled items and separator lines
        p.discolor = {min(255,R+48), min(255,G+48), min(255,B+48), A}
    end

    p.settextshadow = function (x, y, rgba)
        p.textshadowx = x
        p.textshadowy = y
        if rgba then p.textshadowcolor = {unpack(rgba)} end
    end
    
    -- initialize settings for this popupmenu using current default settings:
    p.setfont(cp.menufont, cp.menufontsize)
    p.settextcolor(cp.menutext)
    p.setbackcolor(cp.menubg)
    p.settextshadow(cp.textshadowx, cp.textshadowy, cp.textshadowrgba)
    p.menugap = cp.menugap
    p.rightgap = cp.rightgap
    p.itemgap = cp.itemgap
    p.itemy = cp.itemy

    local function check_width(itemname)
        local oldfont, oldsize = cv.font(p.menufont, p.menufontsize)
        local oldblend = cv.blend(1)
        local wd, ht = cp.maketext(itemname, nil, p.menutext, p.textshadowx, p.textshadowy, p.textshadowcolor)
        cv.blend(oldblend)
        cv.font(oldfont, oldsize)
        p.labelht = ht
        p.itemht = ht + p.itemgap*2
        local itemwd = wd + p.menugap*2 + p.rightgap
        if itemwd > p.menuwd then p.menuwd = itemwd end
    end

    p.additem = function (itemname, onselect, args)
        args = args or {}
        check_width(itemname)
        p.items[#p.items+1] = { name=itemname, f=onselect, fargs=args, enabled=(onselect ~= nil), type=item_normal, value=false }
        p.menuht = #p.items * p.itemht
    end

    p.enableitem = function (itemindex, bool)
        -- enable/disable the given item
        p.items[itemindex].enabled = bool
    end

    p.tickitem = function (itemindex, bool)
        -- tick/untick the given item
        p.items[itemindex].type = item_tick
        p.items[itemindex].value = bool
    end

    p.radioitem = function (itemindex, bool)
        -- set/clear the given item option
        -- mark menu item as radio item and set its value
        p.items[itemindex].type = item_radio
        p.items[itemindex].value = bool
    end

    p.clearitems = function ()
        p.items = {}
        p.menuwd = 0
        p.menuht = 0
    end

    p.show = function (x, y)
        -- get canvas dimensions and adjust x,y to avoid clipping menu
        local cvwd, cvht = cv.getsize("")
        if x + p.menuwd > cvwd then x = x - p.menuwd - 2 end
        if y + p.menuht > cvht then y = cvht - p.menuht end
        p.x = x
        p.y = y
        local itemindex = choose_popup_item(p)
        if itemindex > 0 then
            local item = p.items[itemindex]
            if item and item.f and item.enabled then
                -- call this item's handler
                item.f( unpack(item.fargs) )
            end
        end
    end

    return p
end

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

function cp.process(event)
    if #event > 0 then
        if event:find("^cclick") then
            local _, x, y, butt, mods = split(event)
            if butt == "left" and mods == "none" then
                x = tonumber(x)
                y = tonumber(y)
                if click_in_button(x, y) then return "" end
                if click_in_checkbox_or_radio(x, y) then return "" end
                if click_in_slider(x, y) then return "" end
                if click_in_menubar(x, y) then return "" end
            end
        end
    end
    return event
end

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

local disabled_buttons = {}
local disabled_selects = {}
local disabled_sliders = {}
local disabled_menubars = {}

function cp.disable_all()
    -- prevent all current buttons, sliders, etc from being clicked and remember
    -- those that were disabled so cp.enable_all can restore them
    for r, t in pairs(button_rects) do
        disabled_buttons[#disabled_buttons+1] = t
        button_rects[t.rect] = nil
    end
    for r, t in pairs(select_rects) do
        disabled_selects[#disabled_selects+1] = t
        select_rects[t.rect] = nil
    end
    for r, t in pairs(slider_rects) do
        disabled_sliders[#disabled_sliders+1] = t
        slider_rects[t.rect] = nil
    end
    for r, t in pairs(menubar_rects) do
        disabled_menubars[#disabled_menubars+1] = t
        menubar_rects[t.rect] = nil
    end
end

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

function cp.enable_all()
    -- restore buttons, sliders, etc that were disable by the most recent cp.disable_all call
    for _, t in ipairs(disabled_buttons) do button_rects[t.rect] = t end
    for _, t in ipairs(disabled_selects) do select_rects[t.rect] = t end
    for _, t in ipairs(disabled_sliders) do slider_rects[t.rect] = t end
    for _, t in ipairs(disabled_menubars) do menubar_rects[t.rect] = t end
    disabled_buttons = {}
    disabled_selects = {}
    disabled_sliders = {}
    disabled_menubars = {}
end

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

function cp.minbox(clipname, wd, ht)
    -- find the minimal bounding box of non-transparent pixels in given clip

    local xmin, ymin, xmax, ymax, minwd, minht

    -- find the top edge (ymin)
    local oldtarget = cv.target(clipname)
    for row = 0, ht-1 do
        for col = 0, wd-1 do
            local _, _, _, a = cv.getpixel(col, row)
            if a ~= 0 then
                ymin = row
                goto found_top
            end
        end
    end

    -- only get here if clip has no non-transparent pixels
    xmin, ymin, minwd, minht = 0, 0, 0, 0
    goto finish

    ::found_top::
    -- get here if clip has at least one non-transparent pixel

    -- find the bottom edge (ymax)
    for row = ht-1, ymin, -1 do
        for col = 0, wd-1 do
            local _, _, _, a = cv.getpixel(col, row)
            if a ~= 0 then
                ymax = row
                goto found_bottom
            end
        end
    end
    ::found_bottom::

    -- find the left edge (xmin)
    for col = 0, wd-1 do
        for row = ymin, ymax do
            local _, _, _, a = cv.getpixel(col, row)
            if a ~= 0 then
                xmin = col
                goto found_left
            end
        end
    end
    ::found_left::

    -- find the right edge (xmax)
    for col = wd-1, xmin, -1 do
        for row = ymin, ymax do
            local _, _, _, a = cv.getpixel(col, row)
            if a ~= 0 then
                xmax = col
                goto found_right
            end
        end
    end
    ::found_right::

    -- all edges have been found
    minwd = xmax - xmin + 1
    minht = ymax - ymin + 1

    ::finish::
    cv.target(oldtarget)

    -- return the bounding box info
    return xmin, ymin, minwd, minht
end

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

return cp
