local table = require("@neyuki/table")
local string = require("@neyuki/string")
local math = require("@neyuki/math")
local os = require("@neyuki/os")
local ui = {}
-- Windows, drawing and input on top of the `__ui_*` natives in
-- src/vm/libs/ui.rs (winit, tiny-skia, fontdue and softbuffer: pure Rust, CPU
-- rendered, one code path for X11, Wayland, macOS and Windows).
--
-- Natives cannot call back into Neyuki, so there are no callbacks: the
-- script owns the loop. `ui.poll(timeout?)` pumps the OS event loop and
-- returns what happened as a list of event tables; drawing calls paint a
-- window's back buffer and `window:present()` shows it. `ui.run(handler)`
-- wraps that loop for the common case.
--
-- Coordinates are logical pixels (DPI scaling is handled underneath), colors
-- are "#rgb", "#rrggbb", "#rrggbbaa" strings, { r, g, b, a? } tables with
-- channels 0-255 or packed 0xRRGGBBAA integers from `ui.color`.
function checkString(s: string, name: string)
if (s == nil) then error(name .. " must be provided") end
if (type(s) != "string") then error(name .. " must be a string") end
end
function checkNumber(n: number, name: string)
if (n == nil) then error(name .. " must be provided") end
if (type(n) != "number") then error(name .. " must be a number") end
end
function checkOptionalString(s: string?, name: string)
if (s != nil) then checkString(s, name) end
end
function checkOptionalNumber(n: number?, name: string)
if (n != nil and type(n) != "number") then error(name .. " must be a number") end
end
function checkOptionalBoolean(b: boolean?, name: string)
if (b != nil and type(b) != "boolean") then error(name .. " must be a boolean") end
end
function checkOptionalTable(t: {any}?, name: string)
if (t != nil and type(t) != "table") then error(name .. " must be a table") end
end
function checkFunction(f: any, name: string)
if (type(f) != "function") then error(name .. " must be a function") end
end
-- COLORS -------------------------------------------------------------------
function checkChannel(value: any, name: string): number
if (type(value) != "number") then error(name .. " must be a number from 0 to 255") end
if (value < 0 or value > 255) then error(name .. " must be a number from 0 to 255") end
return math.floor(value + 0.5)
end
-- Turns a color into the 0xRRGGBBAA integer the natives take.
function packColor(color: any, name: string): number
if (type(color) == "number") then
if (color < 0 or color > 4294967295 or color != math.floor(color)) then
error(name .. " must be a packed 0xRRGGBBAA integer")
end
return color
elseif (type(color) == "string") then
if (string.sub(color, 1, 1) != "#") then
error(name .. " must be a #rgb, #rrggbb or #rrggbbaa string")
end
local hex = string.sub(color, 2)
local n = #hex
if (n == 3 or n == 4) then
local expanded = ""
for i = 1, n do
local digit = string.sub(hex, i, i)
expanded = expanded .. digit .. digit
end
hex = expanded
n = #hex
end
if (n != 6 and n != 8) then
error(name .. " must be a #rgb, #rrggbb or #rrggbbaa string")
end
local value = tonumber(hex, 16)
if (value == nil) then
error(name .. " must be a #rgb, #rrggbb or #rrggbbaa string")
end
if (n == 6) then value = value * 256 + 255 end
return value
elseif (type(color) == "table") then
local r = checkChannel(color.r ?? color[1], name .. ".r")
local g = checkChannel(color.g ?? color[2], name .. ".g")
local b = checkChannel(color.b ?? color[3], name .. ".b")
local a = checkChannel(color.a ?? color[4] ?? 255, name .. ".a")
return ((r * 256 + g) * 256 + b) * 256 + a
end
error(name .. " must be a color string or { r, g, b, a? } table")
end
-- Exposed so scripts can pre-pack colors they draw with every frame.
function ui.color(color: any): number
return packColor(color, "color")
end
-- FONTS AND IMAGES ---------------------------------------------------------
function makeFont(id: number, path: string?): Font
local freed = false
local font = {}
font.path = path
function font.id(self: Font): number
if (freed) then error("font was freed") end
return id
end
function font.close(self: Font): ()
if (freed) then return end
freed = true
__ui_free_font(id)
end
return table.freeze(font)
end
function fontId(font: Font?, name: string): number?
if (font == nil) then return nil end
if (type(font) != "table" or type(font.id) != "function") then
error(name .. " must be a font from ui.loadFont")
end
return font:id()
end
-- Loads a TrueType or OpenType font file. Without one, text uses the
-- bundled Noto Sans.
function ui.loadFont(path: string): Font
checkString(path, "path")
local id = __ui_load_font(path)
return makeFont(id, path)
end
-- Decodes a PNG file into an `Image` with `width` and `height`.
function ui.loadImage(path: string): Image
checkString(path, "path")
local id, width, height = __ui_load_image(path)
local freed = false
local image = {}
image.path = path
image.width = width
image.height = height
function image.id(self: Image): number
if (freed) then error("image was freed") end
return id
end
function image.close(self: Image): ()
if (freed) then return end
freed = true
__ui_free_image(id)
end
return table.freeze(image)
end
-- WINDOWS ------------------------------------------------------------------
-- Open windows keyed by native id (as a string: numeric keys iterate as an
-- array, holes included), so events can carry the object.
local windows = {}
local openCount = 0
-- Reads the shared stroke/fill options of the shape methods.
function shapeOptions(options: ShapeOptions?): (boolean, number, number)
checkOptionalTable(options, "options")
options = options ?? {}
checkOptionalBoolean(options.fill, "fill")
checkOptionalNumber(options.width, "width")
checkOptionalNumber(options.radius, "radius")
if (options.width != nil and options.width <= 0) then error("width must be positive") end
if (options.radius != nil and options.radius < 0) then error("radius must be non-negative") end
return options.fill ?? true, options.width ?? 1, options.radius ?? 0
end
function textOptions(options: TextOptions?): (number, number, number?)
checkOptionalTable(options, "options")
options = options ?? {}
checkOptionalNumber(options.size, "size")
if (options.size != nil and options.size <= 0) then error("size must be positive") end
local color = packColor(options.color ?? "#000000", "color")
return options.size ?? 16, color, fontId(options.font, "font")
end
-- Opens a window. `options` may hold `title` (default "Neyuki"), `width`
-- and `height` in logical pixels (default 800 × 600), `resizable` (default
-- true) and `visible` (default true). The canvas starts out white.
function ui.open(options: WindowOptions?): Window
checkOptionalTable(options, "options")
options = options ?? {}
checkOptionalString(options.title, "title")
checkOptionalNumber(options.width, "width")
checkOptionalNumber(options.height, "height")
checkOptionalBoolean(options.resizable, "resizable")
checkOptionalBoolean(options.visible, "visible")
local width = options.width ?? 800
local height = options.height ?? 600
if (width <= 0 or height <= 0) then error("width and height must be positive") end
local title = options.title ?? "Neyuki"
local id, scale = __ui_open(title, width, height, options.resizable ?? true, options.visible ?? true)
local closed = false
local window = {}
window.id = id
function checkOpen()
if (closed) then error("window is closed") end
end
function window.isOpen(self: Window): boolean
return not closed
end
function window.close(self: Window): ()
if (closed) then return end
closed = true
windows[tostring(id)] = nil
openCount -= 1
__ui_close(id)
end
function window.title(self: Window): string
return title
end
function window.setTitle(self: Window, newTitle: string): ()
checkOpen()
checkString(newTitle, "title")
title = newTitle
__ui_set_title(id, newTitle)
end
-- The current logical width and height.
function window.size(self: Window): (number, number)
checkOpen()
local w, h = __ui_size(id)
return w, h
end
function window.setSize(self: Window, w: number, h: number): ()
checkOpen()
checkNumber(w, "width")
checkNumber(h, "height")
if (w <= 0 or h <= 0) then error("width and height must be positive") end
__ui_set_size(id, w, h)
end
-- Physical pixels per logical pixel (2 on a HiDPI display, say).
function window.scale(self: Window): number
checkOpen()
local _, _, s = __ui_size(id)
return s
end
function window.clear(self: Window, color: any): ()
checkOpen()
__ui_clear(id, packColor(color ?? "#ffffff", "color"))
end
-- `options.radius` rounds the corners.
function window.fillRect(self: Window, x: number, y: number, w: number, h: number, color: any, options: ShapeOptions?): ()
checkOpen()
checkNumber(x, "x")
checkNumber(y, "y")
checkNumber(w, "width")
checkNumber(h, "height")
local _, _, radius = shapeOptions(options)
__ui_fill_rect(id, x, y, w, h, packColor(color, "color"), radius)
end
-- `options.width` is the line width, `options.radius` rounds the corners.
function window.strokeRect(self: Window, x: number, y: number, w: number, h: number, color: any, options: ShapeOptions?): ()
checkOpen()
checkNumber(x, "x")
checkNumber(y, "y")
checkNumber(w, "width")
checkNumber(h, "height")
local _, lineWidth, radius = shapeOptions(options)
__ui_stroke_rect(id, x, y, w, h, packColor(color, "color"), radius, lineWidth)
end
function window.line(self: Window, x1: number, y1: number, x2: number, y2: number, color: any, options: ShapeOptions?): ()
checkOpen()
checkNumber(x1, "x1")
checkNumber(y1, "y1")
checkNumber(x2, "x2")
checkNumber(y2, "y2")
local _, lineWidth = shapeOptions(options)
__ui_line(id, x1, y1, x2, y2, packColor(color, "color"), lineWidth)
end
-- Filled unless `options.fill` is false, in which case `options.width`
-- is the outline's line width.
function window.circle(self: Window, cx: number, cy: number, radius: number, color: any, options: ShapeOptions?): ()
checkOpen()
checkNumber(cx, "x")
checkNumber(cy, "y")
checkNumber(radius, "radius")
if (radius < 0) then error("radius must be non-negative") end
local fill, lineWidth = shapeOptions(options)
__ui_circle(id, cx, cy, radius, packColor(color, "color"), fill, lineWidth)
end
-- `points` is a flat { x1, y1, x2, y2, ... } list of at least two
-- points; the shape is closed automatically.
function window.polygon(self: Window, points: {number}, color: any, options: ShapeOptions?): ()
checkOpen()
if (type(points) != "table") then error("points must be a table") end
if (#points < 4) then error("points must hold at least two points") end
local fill, lineWidth = shapeOptions(options)
__ui_polygon(id, points, packColor(color, "color"), fill, lineWidth)
end
-- Draws `s` with the top-left corner of its line box at (x, y); "\n"
-- starts a new line. `options` may hold `size` (default 16), `color`
-- (default black) and `font` (from ui.loadFont). Returns the width drawn.
function window.text(self: Window, x: number, y: number, s: string, options: TextOptions?): number
checkOpen()
checkNumber(x, "x")
checkNumber(y, "y")
checkString(s, "text")
local size, color, font = textOptions(options)
return __ui_text(id, x, y, s, size, color, font)
end
-- Draws an image at its own size, or scaled to `options.width` and/or
-- `options.height`.
function window.drawImage(self: Window, image: Image, x: number, y: number, options: ImageOptions?): ()
checkOpen()
if (type(image) != "table" or type(image.id) != "function") then
error("image must be an image from ui.loadImage")
end
checkNumber(x, "x")
checkNumber(y, "y")
checkOptionalTable(options, "options")
options = options ?? {}
checkOptionalNumber(options.width, "width")
checkOptionalNumber(options.height, "height")
__ui_draw_image(id, image:id(), x, y, options.width, options.height)
end
-- Shows everything drawn since the last call.
function window.present(self: Window): ()
checkOpen()
__ui_present(id)
end
windows[tostring(id)] = window
openCount += 1
return table.freeze(window)
end
-- Whether a display can be reached. On a headless machine every other
-- function raises an error saying so.
function ui.isAvailable(): boolean
return __ui_available()
end
-- The logical width and height `window:text` would cover for `s`.
function ui.measureText(s: string, options: TextOptions?): (number, number)
checkString(s, "text")
local size, _, font = textOptions(options)
local w, h = __ui_measure_text(s, size, font)
return w, h
end
-- Pumps the event loop and returns the events since the last call, each a
-- table with `kind` and `window` plus the fields of its kind (`type` is a
-- keyword, hence the name):
-- close the close button or shortcut was used
-- resize width, height the canvas has this new logical size
-- focus / blur
-- redraw the system asked for the window's contents
-- keydown / keyup key ("a", "escape", "arrowleft", "f1"),
-- code ("KeyA"), repeat, text, mods { shift,
-- ctrl, alt, meta }
-- mousemove x, y
-- mousedown / mouseup button ("left", "right", "middle"), x, y
-- scroll dx, dy, unit ("lines" or "pixels"), x, y
-- mouseenter / mouseleave
-- Without a timeout it returns at once; with one (in seconds) it waits up
-- to that long for the first event. A `close` event only reports the
-- request: call `window:close()` to honour it.
function ui.poll(timeout: number?): {Event}
checkOptionalNumber(timeout, "timeout")
if (timeout != nil and timeout < 0) then error("timeout must be non-negative") end
local raw = __ui_poll(timeout)
local events = {}
for _, event in raw do
local window = windows[tostring(event.window)]
if (window != nil) then
event.window = window
events[#events + 1] = event
end
end
return events
end
-- Runs `handler(events, dt)` about 60 times a second until every window is
-- closed, where `dt` is the seconds since the previous call. Tweens from
-- `ui.animate` are stepped just before each call. `close` events
-- are honoured before the handler sees them unless `options.autoClose` is
-- false. An error inside the handler closes the windows and is re-raised.
function ui.run(handler: ({Event}, number) -> any, options: RunOptions?): ()
checkFunction(handler, "handler")
checkOptionalTable(options, "options")
options = options ?? {}
checkOptionalBoolean(options.autoClose, "autoClose")
checkOptionalNumber(options.fps, "fps")
local autoClose = options.autoClose ?? true
local fps = options.fps ?? 60
if (fps <= 0) then error("fps must be positive") end
local frame = 1 / fps
local last = os.clock()
while (openCount > 0) do
local events = ui.poll(frame)
if (autoClose) then
for _, event in events do
if (event.kind == "close") then event.window:close() end
end
end
local now = os.clock()
local dt = now - last
local ok, err = try(function(): any
ui.update(dt)
return handler(events, dt)
end)
last = now
if (not ok) then
-- Closed windows linger as nil entries until the table is rebuilt.
for _, window in table.clone(windows) do
if (window != nil) then window:close() end
end
error(err)
end
end
end
-- ANIMATION ----------------------------------------------------------------
-- Easing curves map linear progress (0 to 1) to eased progress. The names
-- follow easings.net: `in` starts slow, `out` ends slow, `inOut` does both.
local easing = {}
function easing.linear(t: number): number return t end
function easing.inQuad(t: number): number return t * t end
function easing.outQuad(t: number): number return t * (2 - t) end
function easing.inOutQuad(t: number): number
if (t < 0.5) then return 2 * t * t end
return 1 - (-2 * t + 2) ^ 2 / 2
end
function easing.inCubic(t: number): number return t * t * t end
function easing.outCubic(t: number): number return 1 - (1 - t) ^ 3 end
function easing.inOutCubic(t: number): number
if (t < 0.5) then return 4 * t * t * t end
return 1 - (-2 * t + 2) ^ 3 / 2
end
function easing.inSine(t: number): number return 1 - math.cos(t * math.pi / 2) end
function easing.outSine(t: number): number return math.sin(t * math.pi / 2) end
function easing.inOutSine(t: number): number return -(math.cos(math.pi * t) - 1) / 2 end
function easing.inExpo(t: number): number
if (t == 0) then return 0 end
return 2 ^ (10 * t - 10)
end
function easing.outExpo(t: number): number
if (t == 1) then return 1 end
return 1 - 2 ^ (-10 * t)
end
function easing.inBack(t: number): number
local c = 1.70158
return (c + 1) * t * t * t - c * t * t
end
function easing.outBack(t: number): number
local c = 1.70158
local u = t - 1
return 1 + (c + 1) * u * u * u + c * u * u
end
function easing.outElastic(t: number): number
if (t == 0 or t == 1) then return t end
return 2 ^ (-10 * t) * math.sin((t * 10 - 0.75) * (2 * math.pi / 3)) + 1
end
function easing.outBounce(t: number): number
local n = 7.5625
local d = 2.75
if (t < 1 / d) then return n * t * t end
if (t < 2 / d) then
t = t - 1.5 / d
return n * t * t + 0.75
end
if (t < 2.5 / d) then
t = t - 2.25 / d
return n * t * t + 0.9375
end
t = t - 2.625 / d
return n * t * t + 0.984375
end
ui.easing = table.freeze(easing)
-- The value `t` of the way from `a` to `b`.
function ui.lerp(a: number, b: number, t: number): number
return a + (b - a) * t
end
-- Splits a packed 0xRRGGBBAA color into its four channels.
function channels(color: number): (number, number, number, number)
return math.floor(color / 16777216) % 256, math.floor(color / 65536) % 256,
math.floor(color / 256) % 256, color % 256
end
-- Mixes two colors channel by channel, alpha included, and returns the
-- packed result: `t` = 0 gives `from` and 1 gives `to`.
function ui.mixColor(from: any, to: any, t: number): number
checkNumber(t, "t")
local r1, g1, b1, a1 = channels(packColor(from, "from"))
local r2, g2, b2, a2 = channels(packColor(to, "to"))
local function mix(x: number, y: number): number
return math.max(0, math.min(255, math.floor(x + (y - x) * t + 0.5)))
end
return ((mix(r1, r2) * 256 + mix(g1, g2)) * 256 + mix(b1, b2)) * 256 + mix(a1, a2)
end
function resolveEasing(value: any): (number) -> number
if (value == nil) then return easing.outQuad end
if (type(value) == "function") then return value end
if (type(value) == "string" and easing[value] != nil) then return easing[value] end
error("easing must be a function or the name of a ui.easing curve")
end
-- Animates the fields of `target` toward the values in `goal` over
-- `duration` seconds. Number fields move numerically; any other goal is a
-- color, and the field ends up a packed color the drawing calls accept.
-- Starting values are read when the tween starts (after its delay), so
-- tweens chained through onComplete continue from where the last ended.
--
-- options:
-- easing a ui.easing name or a function (default "outQuad")
-- delay seconds to wait before starting
-- repeat extra runs after the first, or true to repeat forever
-- yoyo every other run plays backwards
-- onUpdate called with the target after each step
-- onComplete called with the target once the tween ends
--
-- A tween does nothing until `tween:update(dt)` is called; `ui.animate`
-- creates one that `ui.run` (or `ui.update`) steps automatically.
function ui.tween(target: {any}, goal: {any}, duration: number, options: TweenOptions?): Tween
if (type(target) != "table") then error("target must be a table") end
if (type(goal) != "table") then error("goal must be a table") end
checkNumber(duration, "duration")
if (duration < 0) then error("duration must be non-negative") end
checkOptionalTable(options, "options")
local opts = options ?? {}
local ease = resolveEasing(opts.easing)
local delay = opts.delay ?? 0
checkNumber(delay, "delay")
if (delay < 0) then error("delay must be non-negative") end
local repeats = opts["repeat"] ?? 0
if (repeats != true and (type(repeats) != "number" or repeats < 0)) then
error("repeat must be a non-negative number or true")
end
checkOptionalBoolean(opts.yoyo, "yoyo")
local yoyo = opts.yoyo ?? false
local onUpdate = opts.onUpdate
local onComplete = opts.onComplete
if (onUpdate != nil) then checkFunction(onUpdate, "onUpdate") end
if (onComplete != nil) then checkFunction(onComplete, "onComplete") end
local keys = {}
local isColor = {}
for key, value in goal do
keys[#keys + 1] = key
if (type(value) == "number") then
isColor[key] = false
else
packColor(value, "goal." .. tostring(key))
isColor[key] = true
end
end
if (#keys == 0) then error("goal must name at least one field") end
local from = nil
local elapsed = -delay
local runs = 0
local backwards = false
local paused = false
local done = false
local function apply(progress: number)
local t = ease(backwards and 1 - progress or progress)
for _, key in keys do
if (isColor[key]) then
target[key] = ui.mixColor(from[key], goal[key], t)
else
target[key] = from[key] + (goal[key] - from[key]) * t
end
end
end
local function begin()
from = {}
for _, key in keys do
local current = target[key]
if (isColor[key]) then
from[key] = packColor(current ?? goal[key], "target." .. tostring(key))
elseif (type(current) == "number") then
from[key] = current
else
error("target." .. tostring(key) .. " must be a number to animate")
end
end
end
local function complete()
done = true
if (onComplete != nil) then onComplete(target) end
end
local tween = {}
-- Advances the tween by `dt` seconds and reports whether it has ended.
function tween.update(self: Tween, dt: number): boolean
checkNumber(dt, "dt")
if (done or paused) then return done end
elapsed = elapsed + dt
if (elapsed < 0) then return false end
if (from == nil) then begin() end
while (elapsed >= duration) do
if (repeats == true or runs < repeats) then
runs = runs + 1
elapsed = elapsed - duration
if (yoyo) then backwards = not backwards end
-- A zero-length tween that repeats forever would never leave.
if (duration == 0) then break end
else
apply(1)
if (onUpdate != nil) then onUpdate(target) end
complete()
return true
end
end
apply(duration == 0 and 1 or elapsed / duration)
if (onUpdate != nil) then onUpdate(target) end
return false
end
function tween.pause(self: Tween): ()
paused = true
end
function tween.resume(self: Tween): ()
paused = false
end
-- Stops where it is, without calling onComplete.
function tween.cancel(self: Tween): ()
done = true
end
-- Jumps to the end of the current run and calls onComplete.
function tween.finish(self: Tween): ()
if (done) then return end
if (from == nil) then begin() end
apply(1)
complete()
end
function tween.isDone(self: Tween): boolean return done end
function tween.isPaused(self: Tween): boolean return paused end
-- How far through the current run the tween is, from 0 to 1.
function tween.progress(self: Tween): number
if (done) then return 1 end
if (elapsed <= 0) then return 0 end
if (duration == 0) then return 1 end
return math.min(1, elapsed / duration)
end
return table.freeze(tween)
end
local running = {}
-- Like `ui.tween`, but the tween is stepped by `ui.run` before each call to
-- the handler (or by `ui.update` in a hand-written loop) until it ends.
function ui.animate(target: {any}, goal: {any}, duration: number, options: TweenOptions?): Tween
local tween = ui.tween(target, goal, duration, options)
running[#running + 1] = tween
return tween
end
-- Steps every tween started with `ui.animate` by `dt` seconds, drops the
-- ones that ended and returns how many are still running.
function ui.update(dt: number): number
checkNumber(dt, "dt")
-- Tweens started from callbacks during this step join the next one.
local current = running
running = {}
local still = {}
for _, tween in current do
if (not tween:update(dt)) then still[#still + 1] = tween end
end
for _, tween in running do still[#still + 1] = tween end
running = still
return #running
end
return table.freeze(ui)