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: 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?, name: string?): Font
local freed = false
local font = {}
font.path = path
font.name = name
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 from a file path or from a buffer of
-- its bytes. Without one, text uses the bundled Noto Sans.
function ui.loadFont(source: any): Font
if (type(source) == "buffer") then
local id, name = __ui_load_font_bytes(source)
return makeFont(id, nil, name)
end
checkString(source, "path")
local id, name = __ui_load_font(source)
return makeFont(id, source, name)
end
-- Loads the installed font with this family name (or PostScript name),
-- ignoring case, or returns nil when there is none. `options.weight` (100
-- to 900, default 400) and `options.italic` pick the closest face. The
-- installed fonts are scanned on the first call.
function ui.systemFont(family: string, options: SystemFontOptions?): Font?
checkString(family, "family")
checkOptionalTable(options, "options")
options = options ?? {}
checkOptionalNumber(options.weight, "weight")
checkOptionalBoolean(options.italic, "italic")
local id, name, path = __ui_system_font(family, options.weight, options.italic)
if (id == nil) then return nil end
return makeFont(id, path, name)
end
-- Turns a list of fonts into the ids the natives take.
function fontIds(fonts: {Font}, name: string): {number}
if (type(fonts) != "table") then error(name .. " must be a table of fonts") end
local ids = {}
for i, font in fonts do
ids[i] = fontId(font, name .. "[" .. tostring(i) .. "]")
end
return ids
end
-- Sets the fonts every text looks in, in order, for characters its own
-- font lacks (kana, kanji, Hangul, emoji, ...). The bundled font is always
-- tried last. An empty list (or nil) clears them; a closed font drops out.
function ui.setFallbackFonts(fonts: {Font}?): ()
__ui_set_fallback_fonts(fontIds(fonts ?? {}, "fonts"))
end
function checkImage(image: any, name: string)
if (type(image) != "table" or type(image.id) != "function") then
error(name .. " must be an image from ui.loadImage or ui.decodeImage")
end
end
function checkFrame(image: Image, i: any, name: string)
if (type(i) != "number" or i != math.floor(i) or i < 1 or i > image.frames) then
error(name .. " must be a frame number from 1 to " .. tostring(image.frames))
end
end
-- Builds the `Image` object around what the image natives return. Drawing
-- only needs `id`, so anything drawable (a canvas, say) can share
-- `window:drawImage` by offering the same method.
function makeImage(id: number, width: number, height: number, frames: number, loopCount: number, delays: {number}, path: string?): Image
local freed = false
local image = {}
image.path = path
image.width = width
image.height = height
image.frames = frames
image.loopCount = loopCount
function image.id(self: Image): number
if (freed) then error("image was freed") end
return id
end
-- Seconds frame `i` (from 1) stays on screen. Delays of 10 ms or less
-- are read as 100 ms, as browsers do; stills report 0.
function image.frameDelay(self: Image, i: number): number
checkFrame(self, i, "i")
return delays[i]
end
-- A new image with every frame resampled to `w` × `h` pixels, for
-- drawing at one size over and over without scaling.
function image.resized(self: Image, w: number, h: number): Image
checkNumber(w, "width")
checkNumber(h, "height")
if (w < 1 or h < 1 or w != math.floor(w) or h != math.floor(h)) then
error("width and height must be positive whole numbers")
end
local nid, nw, nh, nframes, nloops, ndelays = __ui_resize_image(self:id(), w, h)
return makeImage(nid, nw, nh, nframes, nloops, ndelays, path)
end
-- A new image with every frame blurred by a gaussian whose standard
-- deviation is `radius` pixels.
function image.blur(self: Image, radius: number): Image
checkNumber(radius, "radius")
if (radius < 0 or radius > 200) then error("radius must be from 0 to 200") end
local nid, nw, nh, nframes, nloops, ndelays = __ui_blur_image(self:id(), radius)
return makeImage(nid, nw, nh, nframes, nloops, ndelays, path)
end
function image.close(self: Image): ()
if (freed) then return end
freed = true
__ui_free_image(id)
end
return table.freeze(image)
end
-- Reads `maxPixels` and `maxBytes` from the options of loadImage and
-- decodeImage.
function imageLimits(options: ImageLimits?): (number?, number?)
checkOptionalTable(options, "options")
options = options ?? {}
checkOptionalNumber(options.maxPixels, "maxPixels")
checkOptionalNumber(options.maxBytes, "maxBytes")
return options.maxPixels, options.maxBytes
end
-- Decodes a PNG, JPEG, WebP, GIF or BMP file into an `Image` with `width`,
-- `height`, `frames` and `loopCount`. The format is read from the file's
-- first bytes, not its name. Animated GIF, WebP and PNG files decode every
-- frame. `options.maxPixels` (default 64 million) and `options.maxBytes`
-- (memory for all decoded frames, default 512 MiB) bound what is accepted.
function ui.loadImage(path: string, options: ImageLimits?): Image
checkString(path, "path")
local maxPixels, maxBytes = imageLimits(options)
local id, width, height, frames, loopCount, delays = __ui_load_image(path, maxPixels, maxBytes)
return makeImage(id, width, height, frames, loopCount, delays, path)
end
-- Like `loadImage`, from the bytes of an image in a buffer (or a string),
-- such as a downloaded file.
function ui.decodeImage(data: any, options: ImageLimits?): Image
if (type(data) != "buffer" and type(data) != "string") then
error("data must be a buffer or string")
end
local maxPixels, maxBytes = imageLimits(options)
local id, width, height, frames, loopCount, delays = __ui_decode_image(data, maxPixels, maxBytes)
return makeImage(id, width, height, frames, loopCount, delays, nil)
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
local overflows = { clip = true, ellipsis = true, wrap = true }
local alignments = { left = true, center = true, right = true }
-- Reads the options `window:text` and `ui.measureText` share, in the order
-- the natives take them after the text: size, color, font, fallback,
-- maxWidth, overflow, align, lineHeight, letterSpacing.
function textOptions(options: TextOptions?): (number, number, number?, {number}?, number?, string?, string?, 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")
checkOptionalNumber(options.opacity, "opacity")
if (options.opacity != nil) then
if (options.opacity < 0 or options.opacity > 1) then error("opacity must be from 0 to 1") end
local alpha = color % 256
color = color - alpha + math.floor(alpha * options.opacity + 0.5)
end
local fallback = nil
if (options.fallback != nil) then fallback = fontIds(options.fallback, "fallback") end
checkOptionalNumber(options.maxWidth, "maxWidth")
if (options.maxWidth != nil and options.maxWidth <= 0) then error("maxWidth must be positive") end
checkOptionalString(options.overflow, "overflow")
if (options.overflow != nil and overflows[options.overflow] == nil) then
error("overflow must be \"clip\", \"ellipsis\" or \"wrap\"")
end
checkOptionalString(options.align, "align")
if (options.align != nil and alignments[options.align] == nil) then
error("align must be \"left\", \"center\" or \"right\"")
end
checkOptionalNumber(options.lineHeight, "lineHeight")
if (options.lineHeight != nil and options.lineHeight <= 0) then error("lineHeight must be positive") end
checkOptionalNumber(options.letterSpacing, "letterSpacing")
return options.size ?? 16, color, fontId(options.font, "font"), fallback, options.maxWidth,
options.overflow, options.align, options.lineHeight, options.letterSpacing
end
-- GRADIENTS, SHADOWS AND PATHS ---------------------------------------------
-- Packs `options.gradient` for the natives: 0 (linear) or 1 (radial), four
-- numbers of geometry, then offset and color for every stop. `x, y, w, h`
-- is the box the shape covers, which the default geometry is relative to.
function gradientSpec(gradient: any, x: number, y: number, w: number, h: number): {number}?
if (gradient == nil) then return nil end
if (type(gradient) != "table") then error("gradient must be a table") end
local stops = gradient.stops
if (stops == nil) then
stops = { { 0, gradient.from }, { 1, gradient.to } }
end
if (type(stops) != "table" or #stops < 2) then error("gradient needs at least two stops") end
local kind = gradient.kind ?? "linear"
local spec = nil
if (kind == "linear") then
checkOptionalNumber(gradient.angle, "angle")
local angle = math.rad(gradient.angle ?? 0)
local dx = math.cos(angle)
local dy = math.sin(angle)
-- The line runs through the box's center and just reaches the
-- corners on either side, as in CSS.
local half = (math.abs(w * dx) + math.abs(h * dy)) / 2
local cx = x + w / 2
local cy = y + h / 2
spec = { 0, cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half }
elseif (kind == "radial") then
checkOptionalTable(gradient.center, "center")
checkOptionalNumber(gradient.radius, "radius")
local center = gradient.center ?? {}
checkOptionalNumber(center.x, "center.x")
checkOptionalNumber(center.y, "center.y")
local radius = gradient.radius ?? math.sqrt(w * w + h * h) / 2
if (radius <= 0) then error("radius must be positive") end
spec = { 1, center.x ?? (x + w / 2), center.y ?? (y + h / 2), radius, 0 }
else
error("gradient kind must be \"linear\" or \"radial\"")
end
for _, stop in stops do
if (type(stop) != "table") then error("gradient stops must be { offset, color } tables") end
local offset = stop[1] ?? stop.offset
checkNumber(offset, "stop offset")
if (offset < 0 or offset > 1) then error("stop offset must be from 0 to 1") end
spec[#spec + 1] = offset
spec[#spec + 1] = packColor(stop[2] ?? stop.color, "stop color")
end
return spec
end
-- Packs `options.shadow`: blur radius, x and y offset, spread and color.
function shadowSpec(shadow: any): {number}?
if (shadow == nil) then return nil end
if (type(shadow) != "table") then error("shadow must be a table") end
checkOptionalNumber(shadow.blur, "blur")
checkOptionalNumber(shadow.offsetX, "offsetX")
checkOptionalNumber(shadow.offsetY, "offsetY")
checkOptionalNumber(shadow.spread, "spread")
if ((shadow.blur ?? 0) < 0) then error("blur must be non-negative") end
if ((shadow.spread ?? 0) < 0) then error("spread must be non-negative") end
return {
shadow.blur ?? 0,
shadow.offsetX ?? 0,
shadow.offsetY ?? 0,
shadow.spread ?? 0,
packColor(shadow.color ?? "#00000040", "color"),
}
end
-- The gradient and shadow specs of a fill's options, for a shape covering
-- the box `x, y, w, h`.
function fillEffects(options: ShapeOptions?, x: number, y: number, w: number, h: number): ({number}?, {number}?)
if (options == nil) then return nil, nil end
return gradientSpec(options.gradient, x, y, w, h), shadowSpec(options.shadow)
end
function checkPath(path: any, name: string)
if (type(path) != "table" or type(path.commands) != "function") then
error(name .. " must be a path from ui.path")
end
end
-- Control distance that makes a cubic Bezier follow a quarter circle.
local KAPPA = 0.5522847498
-- Starts an empty path to fill, stroke or clip with, in logical pixels.
-- Every method returns the path, so calls chain:
-- ui.path():moveTo(0, 0):lineTo(40, 0):lineTo(20, 30):close()
function ui.path(): Path
local ops = {}
local box = { x1 = math.huge, y1 = math.huge, x2 = -math.huge, y2 = -math.huge }
local path = {}
-- Appends a command and widens the bounds by its points, which come in
-- x, y pairs.
local function add(op: number, coords: {number})
for i, c in coords do
checkNumber(c, "coordinate")
end
ops[#ops + 1] = op
for i = 1, #coords, 2 do
box.x1 = math.min(box.x1, coords[i])
box.x2 = math.max(box.x2, coords[i])
box.y1 = math.min(box.y1, coords[i + 1])
box.y2 = math.max(box.y2, coords[i + 1])
end
for _, c in coords do
ops[#ops + 1] = c
end
end
function path.moveTo(self: Path, x: number, y: number): Path
add(0, { x, y })
return self
end
function path.lineTo(self: Path, x: number, y: number): Path
add(1, { x, y })
return self
end
-- A quadratic curve to (x, y) bending toward the control point (cx, cy).
function path.quadTo(self: Path, cx: number, cy: number, x: number, y: number): Path
add(2, { cx, cy, x, y })
return self
end
-- A cubic curve to (x, y) with control points (c1x, c1y) and (c2x, c2y).
function path.cubicTo(self: Path, c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): Path
add(3, { c1x, c1y, c2x, c2y, x, y })
return self
end
-- Joins the current sub-path's end back to its start.
function path.close(self: Path): Path
add(4, {})
return self
end
function path.rect(self: Path, x: number, y: number, w: number, h: number): Path
checkNumber(w, "width")
checkNumber(h, "height")
self:moveTo(x, y):lineTo(x + w, y):lineTo(x + w, y + h):lineTo(x, y + h):close()
return self
end
-- A rectangle whose corners have radius `r` (limited to what fits).
function path.roundRect(self: Path, x: number, y: number, w: number, h: number, r: number): Path
checkNumber(w, "width")
checkNumber(h, "height")
checkNumber(r, "radius")
if (r < 0) then error("radius must be non-negative") end
r = math.min(r, math.min(w / 2, h / 2))
local k = r * KAPPA
local right = x + w
local bottom = y + h
self:moveTo(x + r, y)
self:lineTo(right - r, y)
self:cubicTo(right - r + k, y, right, y + r - k, right, y + r)
self:lineTo(right, bottom - r)
self:cubicTo(right, bottom - r + k, right - r + k, bottom, right - r, bottom)
self:lineTo(x + r, bottom)
self:cubicTo(x + r - k, bottom, x, bottom - r + k, x, bottom - r)
self:lineTo(x, y + r)
self:cubicTo(x, y + r - k, x + r - k, y, x + r, y)
self:close()
return self
end
function path.ellipse(self: Path, cx: number, cy: number, rx: number, ry: number): Path
checkNumber(rx, "rx")
checkNumber(ry, "ry")
if (rx < 0 or ry < 0) then error("radius must be non-negative") end
local kx = rx * KAPPA
local ky = ry * KAPPA
self:moveTo(cx + rx, cy)
self:cubicTo(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry)
self:cubicTo(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy)
self:cubicTo(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry)
self:cubicTo(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy)
self:close()
return self
end
function path.circle(self: Path, cx: number, cy: number, r: number): Path
checkNumber(r, "radius")
return self:ellipse(cx, cy, r, r)
end
-- The box x, y, width, height around every point and control point
-- added so far (all zero for an empty path).
function path.bounds(self: Path): (number, number, number, number)
if (#ops == 0) then return 0, 0, 0, 0 end
return box.x1, box.y1, box.x2 - box.x1, box.y2 - box.y1
end
-- The flat command list the natives read; treat it as read-only.
function path.commands(self: Path): {number}
return ops
end
return table.freeze(path)
end
-- Defines the drawing methods shared by windows and canvases on `target`,
-- which paints into native surface `id`. `ensureOpen` raises once the
-- surface is gone; `clearColor` is what `clear()` paints without a color.
function addDrawing(target: any, id: number, ensureOpen: any, clearColor: any)
function target.clear(self: Window, color: any): ()
ensureOpen()
__ui_clear(id, packColor(color ?? clearColor, "color"))
end
-- `options.radius` rounds the corners; `options.gradient` and
-- `options.shadow` (see the reference) replace the flat color with a
-- gradient and add a drop shadow.
function target.fillRect(self: Window, x: number, y: number, w: number, h: number, color: any, options: ShapeOptions?): ()
ensureOpen()
checkNumber(x, "x")
checkNumber(y, "y")
checkNumber(w, "width")
checkNumber(h, "height")
local _, _, radius = shapeOptions(options)
local gradient, shadow = fillEffects(options, x, y, w, h)
__ui_fill_rect(id, x, y, w, h, packColor(color, "color"), radius, gradient, shadow)
end
-- `options.width` is the line width, `options.radius` rounds the corners.
function target.strokeRect(self: Window, x: number, y: number, w: number, h: number, color: any, options: ShapeOptions?): ()
ensureOpen()
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 target.line(self: Window, x1: number, y1: number, x2: number, y2: number, color: any, options: ShapeOptions?): ()
ensureOpen()
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 target.circle(self: Window, cx: number, cy: number, radius: number, color: any, options: ShapeOptions?): ()
ensureOpen()
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)
local gradient, shadow = fillEffects(options, cx - radius, cy - radius, radius * 2, radius * 2)
__ui_circle(id, cx, cy, radius, packColor(color, "color"), fill, lineWidth, gradient, shadow)
end
-- `points` is a flat { x1, y1, x2, y2, ... } list of at least two
-- points; the shape is closed automatically.
function target.polygon(self: Window, points: {number}, color: any, options: ShapeOptions?): ()
ensureOpen()
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)
local minX, minY, maxX, maxY = math.huge, math.huge, -math.huge, -math.huge
for i = 1, #points - 1, 2 do
minX = math.min(minX, points[i])
maxX = math.max(maxX, points[i])
minY = math.min(minY, points[i + 1])
maxY = math.max(maxY, points[i + 1])
end
local gradient, shadow = fillEffects(options, minX, minY, maxX - minX, maxY - minY)
__ui_polygon(id, points, packColor(color, "color"), fill, lineWidth, gradient, shadow)
end
-- Draws `s` with the top-left corner of its first line box at (x, y);
-- "\n" starts a new line. `options` may hold `size` (default 16),
-- `color` (default black), `opacity`, `font` (from ui.loadFont),
-- `fallback` (fonts for characters `font` lacks, instead of the global
-- ones), `maxWidth` with `overflow` ("wrap" by default, "ellipsis" or
-- "clip"), `align` ("left", "center" or "right"), `lineHeight` (a
-- multiple of the size) and `letterSpacing` (pixels). Without
-- `maxWidth`, centered and right-aligned text is centered on or ends at
-- x. Returns the width and height drawn, as ui.measureText does.
function target.text(self: Window, x: number, y: number, s: string, options: TextOptions?): (number, number)
ensureOpen()
checkNumber(x, "x")
checkNumber(y, "y")
checkString(s, "text")
local size, color, font, fallback, maxWidth, overflow, align, lineHeight, letterSpacing = textOptions(options)
local w, h = __ui_text(id, x, y, s, size, color, font, fallback, maxWidth, overflow, align, lineHeight, letterSpacing)
return w, h
end
-- Draws an image at its own size, or into the box given by
-- `options.width` and/or `options.height`. Other options:
-- frame which frame of an animated image to draw, from 1 (default 1)
-- opacity 0 (invisible) to 1 (default)
-- radius rounds the corners of the drawn image
-- fit "fill" (default) stretches the image to the box, "contain"
-- shows all of it centered, "cover" fills the box and crops
-- the overflow around the center
function target.drawImage(self: Window, image: Image, x: number, y: number, options: ImageOptions?): ()
ensureOpen()
checkImage(image, "image")
checkNumber(x, "x")
checkNumber(y, "y")
checkOptionalTable(options, "options")
options = options ?? {}
checkOptionalNumber(options.width, "width")
checkOptionalNumber(options.height, "height")
checkOptionalNumber(options.opacity, "opacity")
checkOptionalNumber(options.radius, "radius")
checkOptionalString(options.fit, "fit")
if (options.frame != nil) then checkFrame(image, options.frame, "frame") end
__ui_draw_image(id, image:id(), x, y, options.width, options.height, options.frame,
options.opacity, options.radius, options.fit)
end
-- Fills a path from `ui.path`. Options: `gradient` and `shadow` as for
-- `fillRect`, and `evenOdd` (default false) to fill by the even-odd
-- rule instead of the nonzero one (it takes no gradient or shadow).
function target.fillPath(self: Window, path: Path, color: any, options: ShapeOptions?): ()
ensureOpen()
checkPath(path, "path")
shapeOptions(options)
options = options ?? {}
checkOptionalBoolean(options.evenOdd, "evenOdd")
local bx, by, bw, bh = path:bounds()
local gradient, shadow = fillEffects(options, bx, by, bw, bh)
__ui_fill_path(id, path:commands(), packColor(color, "color"), gradient, shadow, options.evenOdd ?? false)
end
-- Strokes a path. Options: `width` (default 1), `cap` ("butt" default,
-- "round" or "square") and `join` ("miter" default, "round" or "bevel").
function target.strokePath(self: Window, path: Path, color: any, options: StrokeOptions?): ()
ensureOpen()
checkPath(path, "path")
local _, lineWidth = shapeOptions(options)
options = options ?? {}
checkOptionalString(options.cap, "cap")
checkOptionalString(options.join, "join")
__ui_stroke_path(id, path:commands(), packColor(color, "color"), lineWidth, options.cap, options.join)
end
-- Limits later drawing to a rectangle (rounded by `options.radius`)
-- until the matching `popClip`. Clips nest: each one intersects the
-- clips pushed before it.
function target.pushClip(self: Window, x: number, y: number, w: number, h: number, options: ShapeOptions?): ()
ensureOpen()
checkNumber(x, "x")
checkNumber(y, "y")
checkNumber(w, "width")
checkNumber(h, "height")
if (w < 0 or h < 0) then error("width and height must be non-negative") end
local _, _, radius = shapeOptions(options)
__ui_push_clip(id, x, y, w, h, radius)
end
-- Like `pushClip`, limited to the inside of a path.
function target.pushClipPath(self: Window, path: Path): ()
ensureOpen()
checkPath(path, "path")
__ui_push_clip_path(id, path:commands())
end
-- Ends the latest `pushClip` or `pushClipPath`.
function target.popClip(self: Window): ()
ensureOpen()
__ui_pop_clip(id)
end
-- Starts a group: everything drawn until the matching `popOpacity` is
-- composited as one at `opacity` (0 to 1), so overlapping shapes in it
-- do not show through each other. Groups nest. Close the clips opened
-- inside a group before popping it.
function target.pushOpacity(self: Window, opacity: number): ()
ensureOpen()
checkNumber(opacity, "opacity")
if (opacity < 0 or opacity > 1) then error("opacity must be from 0 to 1") end
__ui_push_opacity(id, opacity)
end
function target.popOpacity(self: Window): ()
ensureOpen()
__ui_pop_opacity(id)
end
-- Blurs what has been drawn so far under the rectangle, the way a CSS
-- `backdrop-filter` does, with a gaussian whose standard deviation is
-- `radius` logical pixels (up to 200). `options.radius` rounds the
-- rectangle's corners. Inside `pushOpacity`, only the group's own
-- layer is blurred.
function target.blurRegion(self: Window, x: number, y: number, w: number, h: number, radius: number, options: ShapeOptions?): ()
ensureOpen()
checkNumber(x, "x")
checkNumber(y, "y")
checkNumber(w, "width")
checkNumber(h, "height")
checkNumber(radius, "radius")
if (w < 0 or h < 0) then error("width and height must be non-negative") end
if (radius < 0 or radius > 200) then error("radius must be from 0 to 200") end
local _, _, corner = shapeOptions(options)
__ui_blur_region(id, x, y, w, h, radius, corner)
end
-- The color of the pixel under (x, y) as r, g, b, a from 0 to 255.
function target.getPixel(self: Window, x: number, y: number): (number, number, number, number)
ensureOpen()
checkNumber(x, "x")
checkNumber(y, "y")
local r, g, b, a = __ui_get_pixel(id, x, y)
return r, g, b, a
end
end
-- Runs `body` with the native id of an icon given as an Image or a PNG path
-- (nil for none), freeing an image it had to load itself.
function withIcon(icon: any, body: (number?) -> any): any
if (icon == nil) then return body(nil) end
if (type(icon) == "string") then
local id = __ui_load_image(icon)
local ok, result = try(body, id)
__ui_free_image(id)
if (not ok) then error(result) end
return result
end
if (type(icon) != "table" or type(icon.id) != "function") then
error("icon must be a PNG path or an image from ui.loadImage")
end
return body(icon:id())
end
-- Opens a window. `options` may hold `title` (default "Neyuki"), `width`
-- and `height` in logical pixels (default 800 × 600), `resizable` (default
-- true), `visible` (default true), `decorations` (default true; false for
-- an app-drawn title bar), `minWidth` and `minHeight`, `icon` (a PNG path
-- or Image), `transparent` (default false) and `shadow` (default true:
-- keep the drop shadow when frameless). The canvas starts out white, or
-- clear when transparent.
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")
checkOptionalBoolean(options.decorations, "decorations")
checkOptionalNumber(options.minWidth, "minWidth")
checkOptionalNumber(options.minHeight, "minHeight")
checkOptionalBoolean(options.transparent, "transparent")
checkOptionalBoolean(options.shadow, "shadow")
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 minWidth = options.minWidth ?? 0
local minHeight = options.minHeight ?? 0
if (minWidth < 0 or minHeight < 0) then error("minWidth and minHeight must be non-negative") end
local title = options.title ?? "Neyuki"
local id = withIcon(options.icon, function(icon: number?): number
local opened = __ui_open(title, width, height, options.resizable ?? true,
options.visible ?? true, options.decorations ?? true, minWidth, minHeight,
options.transparent ?? false, options.shadow ?? true, icon)
return opened
end)
local closed = false
local window = {}
window.id = id
local function ensureOpen()
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): ()
ensureOpen()
checkString(newTitle, "title")
title = newTitle
__ui_set_title(id, newTitle)
end
-- The current logical width and height.
function window.size(self: Window): (number, number)
ensureOpen()
local w, h = __ui_size(id)
return w, h
end
function window.setSize(self: Window, w: number, h: number): ()
ensureOpen()
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
ensureOpen()
local _, _, s = __ui_size(id)
return s
end
-- Shows everything drawn since the last call. `options.dirty` lists the
-- `{x, y, w, h}` rectangles (logical pixels) that changed; only those
-- are sent to the screen. Drawing is not limited to them, so the script
-- must have redrawn everything that changed inside them.
function window.present(self: Window, options: {any}?): ()
ensureOpen()
checkOptionalTable(options, "options")
local flat = nil
local dirty = nil
if (options != nil) then dirty = options.dirty end
if (dirty != nil) then
checkOptionalTable(dirty, "dirty")
flat = {}
for i, rect in dirty do
if (type(rect) != "table") then error("dirty[" .. i .. "] must be {x, y, w, h}") end
for k = 1, 4 do
checkNumber(rect[k], "dirty[" .. i .. "][" .. k .. "]")
flat[#flat + 1] = rect[k]
end
end
end
__ui_present(id, flat)
end
addDrawing(window, id, ensureOpen, "#ffffff")
-- WINDOW CHROME ---------------------------------------------------------
--
-- What an app with `decorations = false` needs to draw its own title
-- bar: moving and resizing are handed to the window system, so they
-- snap, cross monitors and respect `minWidth` like a native frame.
-- Moves the window with the mouse as if its title bar were dragged. Call
-- it on `mousedown` in the custom title bar, while the button is held.
-- Returns whether the window system took over.
function window.startDrag(self: Window): boolean
ensureOpen()
return __ui_drag(id)
end
-- Resizes the window with the mouse from an edge or corner: "n", "s",
-- "e", "w", "ne", "nw", "se" or "sw". Call it on `mousedown`, like
-- startDrag.
function window.startResize(self: Window, direction: string): boolean
ensureOpen()
checkString(direction, "direction")
return __ui_drag_resize(id, direction)
end
function window.minimize(self: Window): ()
ensureOpen()
__ui_minimize(id)
end
function window.maximize(self: Window): ()
ensureOpen()
__ui_set_maximized(id, true)
end
-- Brings a minimized window back, or else returns a maximized window
-- to its normal size.
function window.restore(self: Window): ()
ensureOpen()
__ui_restore(id)
end
function window.isMaximized(self: Window): boolean
ensureOpen()
return __ui_is_maximized(id)
end
-- Borderless fullscreen on the window's current monitor.
function window.setFullscreen(self: Window, fullscreen: boolean): ()
ensureOpen()
if (type(fullscreen) != "boolean") then error("fullscreen must be a boolean") end
__ui_set_fullscreen(id, fullscreen)
end
-- The pointer shown over the window: "default", "pointer", "text",
-- "grab", "ewResize" and the rest listed in docs/reference/ui.md, or
-- "hidden".
function window.setCursor(self: Window, cursor: string): ()
ensureOpen()
checkString(cursor, "cursor")
__ui_set_cursor(id, cursor)
end
-- Turns text input (and with it the input method, for composed
-- languages) on or off. `area` is the { x, y, w, h } of the text being
-- edited, so the candidate window opens next to it.
function window.setTextInput(self: Window, enabled: boolean, area: TextArea?): ()
ensureOpen()
if (type(enabled) != "boolean") then error("enabled must be a boolean") end
checkOptionalTable(area, "area")
if (area == nil) then
__ui_set_text_input(id, enabled)
return
end
local x = area.x ?? area[1]
local y = area.y ?? area[2]
local w = area.w ?? area[3] ?? 0
local h = area.h ?? area[4] ?? 0
checkNumber(x, "area.x")
checkNumber(y, "area.y")
checkNumber(w, "area.w")
checkNumber(h, "area.h")
__ui_set_text_input(id, enabled, x, y, w, h)
end
-- Brings the window to the front and gives it the keyboard, as far as
-- the window system allows.
function window.focus(self: Window): ()
ensureOpen()
__ui_focus(id)
end
-- Flashes the taskbar entry or bounces the dock icon until the user
-- looks at the window.
function window.requestAttention(self: Window): ()
ensureOpen()
__ui_request_attention(id)
end
-- Sets the title bar and taskbar icon from an Image or a PNG path, or
-- clears it with nil.
function window.setIcon(self: Window, icon: any): ()
ensureOpen()
withIcon(icon, function(image: number?): any
__ui_set_icon(id, image)
return nil
end)
end
windows[tostring(id)] = window
openCount += 1
return table.freeze(window)
end
-- Makes an off-screen surface of `w` × `h` logical pixels that starts out
-- transparent. It has the drawing methods of a window (`fillRect`, `text`,
-- `pushClip`, ...) but no `present`; draw it onto a window or another
-- canvas with `drawImage`, which takes `opacity`, `radius` and `fit` as for
-- images. `options.scale` is its physical pixels per logical pixel
-- (default 1): pass `window:scale()` for a canvas drawn one to one on a
-- HiDPI display. Close it to free its memory.
function ui.canvas(w: number, h: number, options: CanvasOptions?): Canvas
checkNumber(w, "width")
checkNumber(h, "height")
checkOptionalTable(options, "options")
options = options ?? {}
checkOptionalNumber(options.scale, "scale")
local scale = options.scale ?? 1
if (w <= 0 or h <= 0) then error("width and height must be positive") end
if (scale <= 0) then error("scale must be positive") end
local id = __ui_canvas(w, h, scale)
local freed = false
local canvas = {}
canvas.width = w
canvas.height = h
canvas.scale = scale
canvas.frames = 1
local function ensureOpen()
if (freed) then error("canvas was freed") end
end
function canvas.id(self: Canvas): number
ensureOpen()
return id
end
function canvas.size(self: Canvas): (number, number)
return w, h
end
function canvas.close(self: Canvas): ()
if (freed) then return end
freed = true
__ui_free_canvas(id)
end
addDrawing(canvas, id, ensureOpen, 0)
return table.freeze(canvas)
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` with the
-- same options (fallback fonts, wrapping and ellipsis included), and the
-- number of lines.
function ui.measureText(s: string, options: TextOptions?): (number, number, number)
checkString(s, "text")
local size, _, font, fallback, maxWidth, overflow, align, lineHeight, letterSpacing = textOptions(options)
local w, h, lines = __ui_measure_text(s, size, font, fallback, maxWidth, overflow, align, lineHeight, letterSpacing)
return w, h, lines
end
-- CLIPBOARD AND LINKS ------------------------------------------------------
-- The system clipboard, shared with `@neyuki/hid` (same natives).
local clipboard = {}
-- The clipboard's text, or nil when it holds something else or nothing.
function clipboard.get(): string?
return __hid_clipboard_get()
end
function clipboard.set(text: string): ()
checkString(text, "text")
__hid_clipboard_set(text)
end
ui.clipboard = table.freeze(clipboard)
-- Opens a link in the default browser or mail client. Only http://,
-- https:// and mailto: links are accepted: anything else (a file path,
-- file:, a custom protocol) could start a program.
function ui.openUrl(url: string): ()
checkString(url, "url")
__ui_open_url(url)
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 }
-- textinput text text typed or committed by an input method
-- ime phase, text, input method composition: phase is "start",
-- cursor "preedit", "commit" or "end"; cursor is the
-- byte offset into the preedit text
-- mousemove x, y
-- mousedown button ("left", "right", "middle"), x, y, clicks (2 for a
-- double click)
-- mouseup button, x, y
-- scroll dx, dy, unit ("lines" or "pixels"), x, y
-- mouseenter / mouseleave
-- mediakey key ("playpause", "play", "pause", "next", "previous",
-- "stop"): a media key or OS media control (see mediaSession)
-- mediasession action ("seek" with position or offset, "raise", "quit")
-- maximize / minimize / restore
-- dropfile path a file was dropped on the window
-- dpichange scale the window moved to a display with another
-- scale factor
-- 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
if (event.kind == "webview") then
-- Web views register under their own ids (`@neyuki/webview`).
local view = windows[tostring(event.webview)]
if (view != nil) then
event.webview = view
events[#events + 1] = event
end
else
local window = windows[tostring(event.window)]
if (window != nil) then
event.window = window
events[#events + 1] = event
end
end
end
return events
end
-- Internal: lets `@neyuki/webview` have its views counted by `ui.run` and
-- their events routed through `ui.poll`. Not part of the public API.
function ui.track(id: number, object: any): ()
windows[tostring(id)] = object
openCount += 1
end
function ui.untrack(id: number): ()
if (windows[tostring(id)] == nil) then return end
windows[tostring(id)] = nil
openCount -= 1
end
-- Frame pacing state shared with the tween list below: how many tweens from
-- `ui.animate` are still running, so `fps = "auto"` knows when to idle.
local loopState = { tweens = 0 }
-- 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.
-- With `fps = "auto"` the loop runs at 60 fps only while a tween is running,
-- events arrived or the handler returned `true` (it is animating); otherwise
-- it sleeps in the event loop for up to a second between calls.
function ui.run(handler: ({Event}, number) -> any, options: RunOptions?): ()
checkFunction(handler, "handler")
checkOptionalTable(options, "options")
options = options ?? {}
checkOptionalBoolean(options.autoClose, "autoClose")
local auto = options.fps == "auto"
if (not auto) then checkOptionalNumber(options.fps, "fps") end
local autoClose = options.autoClose ?? true
local fps = 60
if (not auto) then fps = options.fps ?? 60 end
if (fps <= 0) then error("fps must be positive") end
local frame = 1 / fps
local last = os.clock()
local busy = true
while (openCount > 0) do
local wait = frame
if (auto and not busy and loopState.tweens == 0) then wait = 1 end
local events = ui.poll(wait)
busy = #events > 0
if (autoClose) then
for _, event in events do
if (event.kind == "close") then event.window:close() end
if (event.kind == "webview" and event.type == "close") then event.webview:close() end
end
end
local now = os.clock()
local dt = now - last
local ok, err = try(function(): any
ui.update(dt)
local animating = handler(events, dt)
if (animating == true) then busy = true end
return nil
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
-- STATS --------------------------------------------------------------------
-- Timing of the last presented frame in milliseconds: `draw` (script and
-- drawing between presents, without blur), `blur`, `present` and `frame`
-- (all three), plus the `frames` presented so far.
function ui.stats(): {number}
return __ui_stats()
end
-- MEDIA SESSION ------------------------------------------------------------
-- The open window with the lowest id: the first one opened that is still
-- open.
function firstWindow(): Window?
local first = nil
for _, window in windows do
if (window != nil and (first == nil or window.id < first.id)) then first = window end
end
return first
end
-- Publishes now-playing metadata to the OS media controls (the Windows
-- volume flyout, MPRIS applets on Linux, Control Center on macOS) and
-- starts receiving their commands, global media keys included. `info` may
-- hold `title`, `artist`, `album`, `artwork` (an Image, a path or an
-- http(s) URL), `duration` and `position` in seconds, `playing` and the
-- `window` to attach to (default: the first open one). Call it again to
-- update; every call replaces what was shown. `nil` ends the session.
-- Returns true, or false and the reason when the OS has no media controls
-- to offer (no D-Bus session, say).
--
-- Commands arrive from `poll` as events: `mediakey` with `key` ("playpause",
-- "play", "pause", "next", "previous", "stop") and `mediasession` with
-- `action` "seek" (and `position`, or a relative `offset`), "raise" or
-- "quit".
function ui.mediaSession(info: MediaInfo?): (boolean, string?)
if (info == nil) then
__ui_media_session_clear()
return true
end
if (type(info) != "table") then error("info must be a table or nil") end
checkOptionalString(info.title, "title")
checkOptionalString(info.artist, "artist")
checkOptionalString(info.album, "album")
checkOptionalNumber(info.duration, "duration")
checkOptionalNumber(info.position, "position")
checkOptionalBoolean(info.playing, "playing")
if (info.duration != nil and info.duration < 0) then error("duration must be non-negative") end
if (info.position != nil and info.position < 0) then error("position must be non-negative") end
local artworkPath = nil
local artworkImage = nil
if (type(info.artwork) == "string") then
artworkPath = info.artwork
elseif (type(info.artwork) == "table" and type(info.artwork.id) == "function") then
artworkImage = info.artwork:id()
elseif (info.artwork != nil) then
error("artwork must be an image from ui.loadImage, a path or a URL")
end
local window = info.window ?? firstWindow()
if (window == nil) then error("a media session needs an open window") end
if (type(window) != "table" or type(window.isOpen) != "function") then
error("window must be a window from ui.open")
end
if (not window:isOpen()) then error("window is closed") end
local ok, err = __ui_media_session(window.id, info.title, info.artist, info.album,
artworkPath, artworkImage, info.duration, info.position, info.playing ?? false)
return ok, err
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
loopState.tweens = #running
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
loopState.tweens = #running
return #running
end
-- Plays an animated image: `update(dt)` advances `frame` by the image's own
-- frame delays, so a script only forwards the time since the last frame.
-- Draw with `win:drawImage(img, x, y, { frame = anim.frame })`. The
-- animation loops `image.loopCount` times (0 or `options.loop = true`: forever)
-- and then stays on the last frame. Options: `speed` (default 1) scales time.
function ui.animation(image: Image, options: AnimationOptions?): ImageAnimation
checkImage(image, "image")
checkOptionalTable(options, "options")
local opts = options ?? {}
checkOptionalNumber(opts.speed, "speed")
checkOptionalBoolean(opts.loop, "loop")
local speed = opts.speed ?? 1
if (speed < 0) then error("speed must be non-negative") end
local forever = opts.loop == true or image.loopCount == 0
local plays = 0
local anim = {}
anim.frame = 1
anim.playing = true
anim.finished = false
local elapsed = 0
-- Moves the animation `dt` seconds on and returns the current frame.
function anim.update(self: ImageAnimation, dt: number): number
checkNumber(dt, "dt")
if (not self.playing or self.finished or image.frames <= 1) then return self.frame end
elapsed = elapsed + dt * speed
local guard = 0
while (elapsed >= image:frameDelay(self.frame) and guard < 100000) do
guard += 1
elapsed = elapsed - image:frameDelay(self.frame)
if (self.frame < image.frames) then
self.frame += 1
else
plays += 1
if (not forever and plays >= image.loopCount) then
self.finished = true
elapsed = 0
break
end
self.frame = 1
end
end
return self.frame
end
-- Goes back to the first frame and plays again.
function anim.reset(self: ImageAnimation): ()
self.frame = 1
self.finished = false
plays = 0
elapsed = 0
end
return anim
end
return table.freeze(ui)