neyuki 0.6.0

A small Lua-like scripting language implemented in Rust
-- A frameless window with an app-drawn title bar: drag it by the bar,
-- double-click the bar to maximize, resize it from any edge, and use the
-- minimize / maximize / close buttons. The search box accepts typed and
-- IME-composed text (the composition shows underlined until committed);
-- ctrl+v pastes. Files dropped on the window are listed.

local ui = require("@neyuki/ui")
local string = require("@neyuki/string")
local math = require("@neyuki/math")
local utf8 = require("@neyuki/utf8")

local win = ui.open({
    title = "Window chrome",
    width = 900,
    height = 600,
    decorations = false,
    minWidth = 480,
    minHeight = 320,
})

local BAR = 36       -- title bar height
local BUTTON = 46    -- caption button width
local EDGE = 6       -- resize border thickness

local query = ""     -- committed text in the search box
local preedit = ""   -- what the input method is composing
local editing = false
local hovered = nil  -- the control under the pointer
local pressed = nil  -- the control a button press started on
local cursor = "default"
local status = "drag the bar, resize from the edges, type in the box"
local dropped = {}

-- Which edge or corner of the window (x, y) is on, or nil.
function edgeAt(x: number, y: number, w: number, h: number): string?
    if (win:isMaximized()) then return nil end
    local ns = ""
    local ew = ""
    if (y < EDGE) then ns = "n" elseif (y >= h - EDGE) then ns = "s" end
    if (x < EDGE) then ew = "w" elseif (x >= w - EDGE) then ew = "e" end
    local direction = ns .. ew
    if (direction == "") then return nil end
    return direction
end

local EDGE_CURSORS = {
    n = "nsResize", s = "nsResize", e = "ewResize", w = "ewResize",
    ne = "neswResize", sw = "neswResize", nw = "nwseResize", se = "nwseResize",
}

-- The search box and the caption buttons, laid out for the current size.
function layout(w: number): {any}
    return {
        close = { x = w - BUTTON, y = 0, w = BUTTON, h = BAR },
        max = { x = w - BUTTON * 2, y = 0, w = BUTTON, h = BAR },
        min = { x = w - BUTTON * 3, y = 0, w = BUTTON, h = BAR },
        search = { x = 24, y = BAR + 24, w = math.min(420, w - 48), h = 36 },
        link = { x = 24, y = BAR + 84, w = 180, h = 28 },
    }
end

function inside(r: {any}, x: number, y: number): boolean
    return x >= r.x and x < r.x + r.w and y >= r.y and y < r.y + r.h
end

-- The control at (x, y): an edge, a button, the search box, the bar or nil.
function hitTest(x: number, y: number): string?
    local w, h = win:size()
    local edge = edgeAt(x, y, w, h)
    if (edge != nil) then return "edge:" .. edge end
    local boxes = layout(w)
    for _, name in { "close", "max", "min", "search", "link" } do
        if (inside(boxes[name], x, y)) then return name end
    end
    if (y < BAR) then return "bar" end
    return nil
end

function setEditing(on: boolean)
    editing = on
    if (not on) then preedit = "" end
    local w, _ = win:size()
    local box = layout(w).search
    -- The candidate window opens just below the caret.
    local textWidth, _ = ui.measureText(query, { size = 16 })
    win:setTextInput(on, { x = box.x + 10 + textWidth, y = box.y, w = 1, h = box.h })
end

function press(control: string?, event: any)
    if (control == nil) then
        if (editing) then setEditing(false) end
        return
    end
    if (string.startswith(control, "edge:")) then
        win:startResize(string.sub(control, 6))
    elseif (control == "bar") then
        if (event.clicks == 2) then
            if (win:isMaximized()) then win:restore() else win:maximize() end
        else
            win:startDrag()
        end
    elseif (control == "search") then
        setEditing(true)
    else
        pressed = control
    end
end

-- Caption buttons act on release over the same button, like native ones.
function release(control: string?)
    if (pressed != nil and pressed == control) then
        if (control == "close") then
            win:close()
        elseif (control == "max") then
            if (win:isMaximized()) then win:restore() else win:maximize() end
        elseif (control == "min") then
            win:minimize()
        elseif (control == "link") then
            ui.openUrl("https://neyuki.cc/")
        end
    end
    pressed = nil
end

function updateCursor()
    local wanted = "default"
    if (hovered != nil) then
        if (string.startswith(hovered, "edge:")) then
            wanted = EDGE_CURSORS[string.sub(hovered, 6)]
        elseif (hovered == "search") then
            wanted = "text"
        elseif (hovered != "bar") then
            wanted = "pointer"
        end
    end
    if (wanted != cursor) then
        cursor = wanted
        win:setCursor(cursor)
    end
end

function draw()
    local w, h = win:size()
    local boxes = layout(w)
    win:clear("#1e1e2e")

    -- Title bar and caption buttons.
    win:fillRect(0, 0, w, BAR, "#181825")
    win:text(14, 9, "Window chrome", { size = 14, color = "#cdd6f4" })
    for _, name in { "min", "max", "close" } do
        local r = boxes[name]
        if (hovered == name) then
            win:fillRect(r.x, r.y, r.w, r.h, name == "close" and "#e64553" or "#313244")
        end
    end
    local cx, cy = boxes.min.x + BUTTON / 2, BAR / 2
    win:line(cx - 5, cy, cx + 5, cy, "#cdd6f4")
    cx = boxes.max.x + BUTTON / 2
    if (win:isMaximized()) then
        win:strokeRect(cx - 3, cy - 6, 8, 8, "#cdd6f4")
        win:fillRect(cx - 6, cy - 3, 8, 8, hovered == "max" and "#313244" or "#181825")
        win:strokeRect(cx - 6, cy - 3, 8, 8, "#cdd6f4")
    else
        win:strokeRect(cx - 5, cy - 5, 10, 10, "#cdd6f4")
    end
    cx = boxes.close.x + BUTTON / 2
    win:line(cx - 5, cy - 5, cx + 5, cy + 5, "#cdd6f4")
    win:line(cx - 5, cy + 5, cx + 5, cy - 5, "#cdd6f4")

    -- The search box: committed text, then the composition underlined.
    local box = boxes.search
    win:fillRect(box.x, box.y, box.w, box.h, "#313244", { radius = 6 })
    win:strokeRect(box.x, box.y, box.w, box.h, editing and "#89b4fa" or "#45475a", { radius = 6, width = 1.5 })
    local x = box.x + 10
    local y = box.y + 8
    if (query == "" and preedit == "" and not editing) then
        win:text(x, y, "Search...", { size = 16, color = "#6c7086" })
    else
        x += win:text(x, y, query, { size = 16, color = "#cdd6f4" })
        if (preedit != "") then
            local pw = win:text(x, y, preedit, { size = 16, color = "#f9e2af" })
            win:line(x, y + 21, x + pw, y + 21, "#f9e2af")
            x += pw
        end
        if (editing) then win:line(x + 1, y, x + 1, y + 20, "#cdd6f4", { width = 1.5 }) end
    end

    local link = boxes.link
    win:text(link.x, link.y + 4, "Open the Neyuki website", {
        size = 14, color = hovered == "link" and "#b4befe" or "#89b4fa",
    })

    win:text(24, BAR + 132, status, { size = 14, color = "#a6adc8" })
    for i, path in dropped do
        win:text(24, BAR + 150 + i * 20, "dropped: " .. path, { size = 13, color = "#a6adc8" })
    end

    -- A hairline border so the frameless window stands out from what is behind.
    win:strokeRect(0.5, 0.5, w - 1, h - 1, "#45475a")
    win:present()
end

ui.run(function(events: {Event}, _dt: number)
    for _, event in events do
        local kind = event.kind
        if (kind == "mousemove") then
            hovered = hitTest(event.x, event.y)
        elseif (kind == "mouseleave") then
            hovered = nil
        elseif (kind == "mousedown" and event.button == "left") then
            press(hitTest(event.x, event.y), event)
        elseif (kind == "mouseup" and event.button == "left") then
            release(hitTest(event.x, event.y))
        elseif (kind == "textinput" and editing) then
            query = query .. event.text
            preedit = ""
            setEditing(true) -- move the candidate window along
        elseif (kind == "ime") then
            if (event.phase == "preedit") then preedit = event.text else preedit = "" end
        elseif (kind == "keydown" and editing) then
            if (event.key == "backspace" and preedit == "" and query != "") then
                query = utf8.sub(query, 1, -2)
            elseif (event.key == "v" and (event.mods.ctrl or event.mods.meta)) then
                query = query .. (ui.clipboard.get() ?? "")
            elseif (event.key == "escape" or event.key == "enter") then
                setEditing(false)
            end
        elseif (kind == "maximize" or kind == "minimize" or kind == "restore") then
            status = "window: " .. kind
        elseif (kind == "dpichange") then
            status = "scale factor: " .. tostring(event.scale)
        elseif (kind == "dropfile") then
            dropped[#dropped + 1] = event.path
        end
    end
    if (not win:isOpen()) then return end
    updateCursor()
    draw()
end)