local ui = require("@neyuki/ui")
local fs = require("@neyuki/fs")
local string = require("@neyuki/string")
function expectError(f: any, needle: string, message: string, ...: any)
local ok, err = try(f, ...)
assert(not ok and string.find(tostring(err), needle, 1, true) != nil, message .. ": " .. tostring(err))
end
-- COLORS (no display needed) -----------------------------------------------
assert(ui.color("#ff0000") == 0xff0000ff, "#rrggbb should get an opaque alpha")
assert(ui.color("#f00") == 0xff0000ff, "#rgb should expand each digit")
assert(ui.color("#12345678") == 0x12345678, "#rrggbbaa should pass through")
assert(ui.color("#1234") == 0x11223344, "#rgba should expand each digit")
assert(ui.color({ 255, 128, 0 }) == 0xff8000ff, "array colors should pack in order")
assert(ui.color({ r = 1, g = 2, b = 3, a = 4 }) == 0x01020304, "named channels should pack in order")
assert(ui.color({ 0, 0, 0, 0 }) == 0, "a transparent black packs to zero")
expectError(ui.color, "#rgb", "colors need the # prefix", "ff0000")
expectError(ui.color, "#rgb", "colors need 3, 4, 6 or 8 digits", "#12345")
expectError(ui.color, "#rgb", "colors need hex digits", "#gg0000")
expectError(ui.color, "0 to 255", "channels are clamped", { 256, 0, 0 })
expectError(ui.color, "0 to 255", "channels must be numbers", { "1", 0, 0 })
assert(ui.color(0x11223344) == 0x11223344, "packed colors pass through")
expectError(ui.color, "packed 0xRRGGBBAA", "packed colors fit in 32 bits", 0x100000000)
expectError(ui.color, "packed 0xRRGGBBAA", "packed colors are integers", 1.5)
expectError(ui.color, "color string", "colors must be strings, tables or numbers", true)
-- ANIMATION (no display needed) --------------------------------------------
assert(ui.lerp(10, 20, 0.25) == 12.5, "lerp")
assert(ui.mixColor("#000000", "#ffffff", 0.5) == 0x808080ff, "mixColor mixes each channel")
assert(ui.mixColor("#ff000000", "#ff0000ff", 1) == 0xff0000ff, "mixColor mixes alpha")
for name, curve in ui.easing do
assert(math.abs(curve(0)) < 1e-9 and math.abs(curve(1) - 1) < 1e-9, name .. " runs from 0 to 1")
end
local box = { x = 0, fill = "#000000" }
local tween = ui.tween(box, { x = 100, fill = "#ffffff" }, 2, { easing = "linear" })
assert(box.x == 0, "a tween waits for update")
assert(not tween:update(1), "a tween runs for its duration")
assert(box.x == 50 and box.fill == 0x808080ff and tween:progress() == 0.5, "halfway")
tween:pause()
tween:update(5)
assert(box.x == 50 and tween:isPaused(), "a paused tween holds still")
tween:resume()
assert(tween:update(1.5) and box.x == 100 and box.fill == 0xffffffff, "a tween ends on its goal")
assert(tween:isDone() and tween:progress() == 1, "an ended tween reports it")
local delayed = { v = 1 }
local starts = ui.tween(delayed, { v = 3 }, 1, { delay = 0.5, easing = "linear" })
starts:update(0.25)
delayed.v = 2
starts:update(0.75)
assert(delayed.v == 2.5, "the start value is read once the delay ends: " .. tostring(delayed.v))
local bounce = { y = 0 }
local completed = 0
ui.animate(bounce, { y = 10 }, 1, {
easing = "linear",
["repeat"] = 1,
yoyo = true,
onComplete = function() completed = completed + 1 end,
})
assert(ui.update(0.5) == 1 and bounce.y == 5, "ui.update steps animations")
assert(ui.update(1) == 1 and bounce.y == 5, "a yoyo plays the next run backwards")
assert(ui.update(1) == 0 and bounce.y == 0 and completed == 1, "repeats end with onComplete")
local chained = { x = 0 }
ui.animate(chained, { x = 1 }, 0, {
onComplete = function(t: any) ui.animate(t, { x = 5 }, 1, { easing = "linear" }) end,
})
assert(ui.update(0) == 1 and chained.x == 1, "tweens started in callbacks join the next step")
ui.update(1)
assert(chained.x == 5, "a chained tween continues from where the last ended")
local cancelled = { x = 0 }
local stop = ui.tween(cancelled, { x = 10 }, 1, { easing = "linear" })
stop:update(0.5)
stop:cancel()
stop:update(1)
assert(cancelled.x == 5 and stop:isDone(), "cancel stops in place")
local skipped = { x = 0 }
ui.tween(skipped, { x = 10 }, 1):finish()
assert(skipped.x == 10, "finish jumps to the goal")
expectError(ui.tween, "target must be a table", "tween needs a target", 1, {}, 1)
expectError(ui.tween, "at least one field", "tween needs a goal", {}, {}, 1)
expectError(ui.tween, "non-negative", "tween checks the duration", { x = 0 }, { x = 1 }, -1)
expectError(ui.tween, "easing must be", "tween checks the easing", { x = 0 }, { x = 1 }, 1, { easing = "wobble" })
expectError(ui.tween, "repeat must be", "tween checks repeat", { x = 0 }, { x = 1 }, 1, { ["repeat"] = "x" })
expectError(function() ui.tween({}, { x = 1 }, 1):update(1) end, "must be a number to animate", "targets need numbers")
-- TEXT MEASUREMENT (no display needed) -------------------------------------
local w, h = ui.measureText("Hello")
assert(w > 0 and h > 0, "measuring text should give a size")
local w2, h2 = ui.measureText("Hello", { size = 32 })
assert(w2 > w and h2 > h, "larger text should measure larger")
local _, twoLines = ui.measureText("a\nb")
assert(twoLines > h, "a newline should add a line")
local empty, _ = ui.measureText("")
assert(empty == 0, "empty text has no width")
expectError(ui.measureText, "text must be a string", "measureText checks its text", 5)
expectError(ui.measureText, "size must be positive", "measureText checks the size", "x", { size = 0 })
expectError(ui.measureText, "font must be a font", "measureText checks the font", "x", { font = "Arial" })
-- FALLBACK FONTS AND LAYOUT (no display needed) -----------------------------
--
-- The fixtures are tiny generated fonts whose glyphs are plain boxes: one
-- covers the kana and kanji below (full-width), the other the emoji.
local title = "ループザルーム (feat. 初音ミク) — rusino 🎵"
local cjk = ui.loadFont("tests/fixtures/fallback-cjk.ttf")
-- tests/fixtures/fallback-symbols.ttf, loaded from memory.
local symbolsFont = "AAEAAAAHAEAAAgAwY21hcAAS558AAAB8AAAAKGdseWYDvgoAAAAApAAAACRoZWFkY85DogAAAMgAAAA2aGhlYQfSBE8AAAEAAAAAJGhtdHgJYADIAAABJAAAAAhsb2NhAAAAJAAAASwAAAAMbWF4cAACUAAAAAE4AAAABgAAAAEAAwAKAAAADAAMAAAAAAAcAAAAAAAAAAEAAfO1AAHztQAAAAEAAQBkAAAETAK8AAMAAAEBAQEAZAAAA+gAAAAAArwAAP1EAAAAAQAAAAEAAAAAAABfDzz1AAsD6AAAAAAAAAAAAAAAAAAAAAAAZAAABEwCvAAAAAgAAgABAAAAAAABAAADIP84AAAEsABkAGQETAABAAAAAAAAAAAAAAAAAAAAAgSwAGQEsABkAAAAAAAAAAAAAAAkAABQAAACAAA="
local symbols = ui.loadFont(buffer.frombase64(symbolsFont))
assert(cjk.path == "tests/fixtures/fallback-cjk.ttf" and symbols.path == nil, "fonts remember their path")
-- Without fallbacks the kana come out as the bundled font's missing-glyph
-- box; with them each takes its fallback's full-width (1 em) box.
local kana = "ループ"
local tofu, _ = ui.measureText(kana, { size = 20 })
local boxes, _ = ui.measureText(kana, { size = 20, fallback = { cjk } })
assert(math.abs(boxes - 60) < 0.01, "three full-width fallback glyphs at 20 px: " .. tostring(boxes))
assert(tofu != boxes, "the fallback font should be used")
-- The global list applies when a call names none.
ui.setFallbackFonts({ cjk, symbols })
local globalWidth, _ = ui.measureText(kana, { size = 20 })
assert(globalWidth == boxes, "global fallbacks should match per-call ones")
local note, _ = ui.measureText("🎵", { size = 10 })
assert(math.abs(note - 12) < 0.01, "the emoji comes from the second fallback: " .. tostring(note))
-- Ellipsis truncates to the width, and measureText reports the truncated
-- size (the same layout text() draws).
local full, oneLine = ui.measureText(title, { size = 14 })
local cut, cutHeight, cutLines = ui.measureText(title, { size = 14, maxWidth = 160, overflow = "ellipsis" })
assert(full > 160 and cut <= 160 and cut > 100, "ellipsis should fit the width: " .. tostring(cut))
assert(cutHeight == oneLine and cutLines == 1, "ellipsis keeps one line")
-- Wrapping breaks lines; clipping cuts the width.
local _, wrappedHeight, wrappedLines = ui.measureText(title, { size = 14, maxWidth = 120 })
assert(wrappedLines > 1 and wrappedHeight > oneLine, "wrapping adds lines")
local clipped, _, clippedLines = ui.measureText(title, { size = 14, maxWidth = 50, overflow = "clip" })
assert(clipped == 50 and clippedLines == 1, "clipping limits the width")
local _, spaced = ui.measureText("a\nb", { size = 10, lineHeight = 2 })
assert(spaced == 40, "lineHeight is a multiple of the size: " .. tostring(spaced))
local tight, _ = ui.measureText("abc", { size = 10 })
local loose, _ = ui.measureText("abc", { size = 10, letterSpacing = 1 })
assert(math.abs(loose - tight - 2) < 0.01, "letterSpacing adds a gap between characters")
expectError(ui.measureText, "overflow must be", "measureText checks overflow", "x", { maxWidth = 10, overflow = "fade" })
expectError(ui.measureText, "align must be", "measureText checks align", "x", { align = "middle" })
expectError(ui.measureText, "maxWidth must be positive", "measureText checks maxWidth", "x", { maxWidth = 0 })
expectError(ui.measureText, "lineHeight must be positive", "measureText checks lineHeight", "x", { lineHeight = -1 })
expectError(ui.measureText, "opacity must be from 0 to 1", "measureText checks opacity", "x", { opacity = 2 })
expectError(ui.measureText, "fallback[1] must be a font", "measureText checks fallbacks", "x", { fallback = { "Arial" } })
expectError(ui.measureText, "size must be at most", "text sizes are capped", "x", { size = 5000 })
expectError(ui.setFallbackFonts, "fonts must be a table", "setFallbackFonts checks its list", "Arial")
expectError(ui.loadFont, "cannot load font", "loadFont rejects bytes that are not a font", buffer.fromstring("nope"))
expectError(ui.systemFont, "family must be a string", "systemFont checks the family", 5)
-- Closing a font drops it from the global list.
symbols:close()
local closedNote, _ = ui.measureText("🎵", { size = 10 })
assert(closedNote != note, "a closed fallback is no longer used")
ui.setFallbackFonts({})
local cleared, _ = ui.measureText(kana, { size = 20 })
assert(cleared == tofu, "an empty list clears the fallbacks")
cjk:close()
-- ARGUMENT CHECKS (no display needed) ---------------------------------------
expectError(ui.open, "options must be a table", "open rejects non-table options", 5)
expectError(ui.open, "title must be a string", "open checks the title", { title = 1 })
expectError(ui.open, "width must be a number", "open checks the width", { width = "wide" })
expectError(ui.open, "width and height must be positive", "open rejects empty windows", { width = 0 })
expectError(ui.open, "resizable must be a boolean", "open checks resizable", { resizable = 1 })
expectError(ui.open, "decorations must be a boolean", "open checks decorations", { decorations = "no" })
expectError(ui.open, "minWidth must be a number", "open checks minWidth", { minWidth = "x" })
expectError(ui.open, "non-negative", "open rejects negative minimum sizes", { minHeight = -1 })
expectError(ui.open, "transparent must be a boolean", "open checks transparent", { transparent = 1 })
expectError(ui.open, "shadow must be a boolean", "open checks shadow", { shadow = 1 })
expectError(ui.open, "icon must be a PNG path", "open checks the icon", { icon = 5 })
expectError(ui.open, "cannot open", "open reports a missing icon", { icon = "tests/fixtures/missing.png" })
expectError(ui.poll, "timeout must be a number", "poll checks the timeout", "soon")
expectError(ui.poll, "non-negative", "poll rejects negative timeouts", -1)
expectError(ui.run, "handler must be a function", "run needs a handler")
expectError(ui.run, "fps must be positive", "run checks fps", function() end, { fps = 0 })
expectError(ui.loadFont, "path must be provided", "loadFont checks the path")
expectError(ui.loadFont, "cannot open", "loadFont reports a missing file", "tests/fixtures/missing.ttf")
expectError(ui.loadImage, "cannot open", "loadImage reports a missing file", "tests/fixtures/missing.png")
expectError(ui.loadImage, "cannot decode", "loadImage rejects non-PNG files", "tests/ui.nyk")
expectError(ui.loadImage, "cannot decode", "loadImage rejects unknown formats", "tests/ui.nyk")
expectError(ui.decodeImage, "must be a buffer or string", "decodeImage checks its input", 5)
expectError(ui.decodeImage, "cannot decode", "decodeImage rejects garbage", "not an image")
expectError(ui.mediaSession, "info must be a table", "mediaSession checks its info", 5)
expectError(ui.mediaSession, "title must be a string", "mediaSession checks the title", { title = 1 })
expectError(ui.mediaSession, "duration must be non-negative", "mediaSession checks the duration", { duration = -1 })
expectError(ui.mediaSession, "playing must be a boolean", "mediaSession checks playing", { playing = "yes" })
expectError(ui.mediaSession, "artwork must be", "mediaSession checks the artwork", { artwork = 5 })
expectError(ui.mediaSession, "needs an open window", "mediaSession needs a window", { title = "x" })
assert(ui.mediaSession(nil), "ending a session that never started is fine")
-- LINKS AND CLIPBOARD (no display needed) -----------------------------------
--
-- Only refusals are tested: opening a link would start a browser, and the
-- clipboard needs a desktop session.
expectError(ui.openUrl, "url must be provided", "openUrl needs a url")
expectError(ui.openUrl, "http://, https:// or mailto:", "openUrl refuses files", "file:///etc/passwd")
expectError(ui.openUrl, "http://, https:// or mailto:", "openUrl refuses paths", "C:\\Windows\\notepad.exe")
expectError(ui.openUrl, "http://, https:// or mailto:", "openUrl refuses scripts", "javascript:alert(1)")
expectError(ui.openUrl, "spaces", "openUrl refuses spaces", "https://example.com/a b")
expectError(ui.openUrl, "nothing after", "openUrl needs more than a scheme", "https://")
expectError(ui.clipboard.set, "text must be provided", "clipboard.set needs text")
assert(type(ui.clipboard.get) == "function", "ui.clipboard.get exists")
-- IMAGES (no display needed) ------------------------------------------------
local image = ui.loadImage("tests/fixtures/dot.png")
assert(image.width == 4 and image.height == 3, "the fixture is 4 x 3")
assert(image.path == "tests/fixtures/dot.png")
image:close()
image:close()
expectError(function() return image:id() end, "image was freed", "a freed image is unusable")
local jpeg = ui.loadImage("tests/fixtures/quadrants.jpg")
assert(jpeg.width == 16 and jpeg.height == 16 and jpeg.frames == 1, "the JPEG is 16 x 16")
assert(jpeg:frameDelay(1) == 0, "stills have no delay")
local still = ui.loadImage("tests/fixtures/still.webp")
assert(still.frames == 1, "the WebP is a still")
local gif = ui.loadImage("tests/fixtures/spin.gif")
assert(gif.width == 4 and gif.height == 4 and gif.frames == 3, "the GIF has three 4 x 4 frames")
assert(gif.loopCount == 0, "the GIF loops forever")
assert(gif:frameDelay(1) == 0.05 and gif:frameDelay(2) == 0.1 and gif:frameDelay(3) == 0.2)
expectError(function() return gif:frameDelay(4) end, "frame number", "frameDelay checks the frame")
local blink = ui.loadImage("tests/fixtures/blink.webp")
assert(blink.frames == 2 and blink.loopCount == 3, "the animated WebP has two frames and three loops")
local anim = ui.animation(gif)
assert(anim.frame == 1)
anim:update(0.06)
assert(anim.frame == 2, "0.06 s is past the first 0.05 s frame")
anim:update(0.1)
assert(anim.frame == 3)
anim:update(0.2)
assert(anim.frame == 1, "an endless animation wraps around")
anim:reset()
assert(anim.frame == 1 and not anim.finished)
local finite = ui.animation(blink)
for _ = 1, 20 do finite:update(0.2) end
assert(finite.finished and finite.frame == 2, "a finite animation stops on its last frame")
local small = gif:resized(2, 2)
assert(small.width == 2 and small.height == 2 and small.frames == 3, "resized keeps the frames")
assert(small:frameDelay(3) == 0.2)
expectError(function() return gif:resized(0, 2) end, "positive whole numbers", "resized checks the size")
small:close()
-- 3 x 2 BMP, from memory.
local bmp = ui.decodeImage(buffer.fromhex("424d4e000000000000003600000028000000030000000200000001001800000000001800000000000000000000000000000000000000ffffff0000008080800000000000ff00ff00ff0000000000"))
assert(bmp.width == 3 and bmp.height == 2, "decodeImage reads buffers")
expectError(ui.decodeImage, "maxPixels", "decodeImage limits the pixel count", buffer.fromhex("424d4e000000000000003600000028000000030000000200000001001800000000001800000000000000000000000000000000000000ffffff0000008080800000000000ff00ff00ff0000000000"), { maxPixels = 4 })
local blurredBmp = bmp:blur(1)
assert(blurredBmp.width == 3 and blurredBmp.height == 2 and blurredBmp.frames == 1, "blur keeps the size and frames")
blurredBmp:close()
expectError(function() return bmp:blur(-1) end, "radius", "blur checks the radius")
for _, img in { jpeg, still, gif, blink, bmp } do img:close() end
-- CANVAS (no display needed) ------------------------------------------------
local function channel(c: any, i: number, x: number, y: number): number
local r, g, b, a = c:getPixel(x, y)
if (i == 1) then return r end
if (i == 2) then return g end
if (i == 3) then return b end
return a
end
local canvas = ui.canvas(20, 10)
assert(canvas.width == 20 and canvas.height == 10 and canvas.scale == 1, "a canvas knows its size")
local w, h = canvas:size()
assert(w == 20 and h == 10)
local r, g, b, a = canvas:getPixel(1, 1)
assert(a == 0, "a canvas starts transparent")
canvas:fillRect(0, 0, 10, 10, "#ff0000")
r, g, b, a = canvas:getPixel(5, 5)
assert(r == 255 and g == 0 and b == 0 and a == 255, "fillRect paints a canvas")
_, _, _, a = canvas:getPixel(15, 5)
assert(a == 0, "outside the rectangle stays transparent")
expectError(function() return canvas:getPixel(25, 5) end, "outside", "getPixel checks the point")
expectError(ui.canvas, "positive", "canvas needs a size", 0, 5)
expectError(ui.canvas, "scale must be positive", "canvas needs a positive scale", 5, 5, { scale = 0 })
-- Drawing methods take every color form directly, checked as ui.color does.
canvas:fillRect(0, 0, 10, 10, { r = 0, g = 255, b = 0 })
r, g, b, a = canvas:getPixel(5, 5)
assert(r == 0 and g == 255 and b == 0 and a == 255, "fillRect takes a color table")
canvas:fillRect(0, 0, 10, 10, { 0, 0, 255, 255 })
assert(channel(canvas, 3, 5, 5) == 255, "fillRect takes a color list")
canvas:fillRect(0, 0, 10, 10, "#f00")
assert(channel(canvas, 1, 5, 5) == 255 and channel(canvas, 2, 5, 5) == 0, "fillRect takes a short color string")
expectError(function() canvas:fillRect(0, 0, 5, 5, "red") end, "#rgb", "drawing checks color strings")
expectError(function() canvas:fillRect(0, 0, 5, 5, { 300, 0, 0 }) end, "0 to 255", "drawing checks color channels")
expectError(function() canvas:fillRect(0, 0, 5, 5, 1.5) end, "packed 0xRRGGBBAA", "drawing checks packed colors")
expectError(function() canvas:fillRect(0, 0, 5, 5) end, "color string", "drawing needs a color")
expectError(function() canvas:fillRect("a", 0, 5, 5, "#000") end, "x must be a number", "drawing checks coordinates")
expectError(function() canvas:fillRect(0, 0, 5, 5, "#000", { radius = -1 }) end, "non-negative", "fillRect checks the radius")
expectError(function() canvas:strokeRect(0, 0, 5, 5, "#000", { width = 0 }) end, "width must be positive", "strokeRect checks the width")
expectError(function() canvas:circle(5, 5, 2, "#000", { fill = "yes" }) end, "fill must be a boolean", "circle checks fill")
expectError(function() canvas:text(0, 0, 5) end, "text must be a string", "text checks its text")
canvas:clear()
-- Gradients.
canvas:fillRect(0, 0, 20, 10, "#000000", { gradient = { from = "#000000", to = "#ffffff" } })
assert(canvas:getPixel(1, 5) < 40 and canvas:getPixel(18, 5) > 215, "a linear gradient runs left to right")
canvas:fillRect(0, 0, 20, 10, "#000000", { gradient = { from = "#000000", to = "#ffffff", angle = 90 } })
assert(canvas:getPixel(10, 0) < 40 and canvas:getPixel(10, 9) > 215, "angle 90 runs top to bottom")
canvas:fillRect(0, 0, 20, 10, "#000000", { gradient = { kind = "radial", from = "#ffffff", to = "#000000" } })
assert(canvas:getPixel(10, 5) > 230 and canvas:getPixel(0, 0) < 60, "a radial gradient runs outward")
canvas:fillRect(0, 0, 20, 10, "#000000", {
gradient = { stops = { { 0, "#ff0000" }, { 0.5, "#00ff00" }, { 1, "#0000ff" } } },
})
r, g, b = canvas:getPixel(10, 5)
assert(g > 200 and r < 60 and b < 60, "stops place colors along the gradient")
expectError(function() canvas:fillRect(0, 0, 5, 5, "#000", { gradient = { from = "#000000" } }) end, "stop color", "gradients need both colors")
expectError(function() canvas:fillRect(0, 0, 5, 5, "#000", { gradient = { kind = "conic", from = "#000", to = "#fff" } }) end, "kind", "gradient kinds are checked")
expectError(function() canvas:fillRect(0, 0, 5, 5, "#000", { gradient = { stops = { { 0, "#000" } } } }) end, "two stops", "gradients need two stops")
expectError(function() canvas:fillRect(0, 0, 5, 5, "#000", { gradient = { stops = { { 2, "#000" }, { 1, "#fff" } } } }) end, "offset", "stop offsets are checked")
-- Clipping.
canvas:clear("#ffffff")
canvas:pushClip(0, 0, 10, 10)
canvas:fillRect(0, 0, 20, 10, "#ff0000")
canvas:popClip()
assert(channel(canvas, 1, 5, 5) == 255 and channel(canvas, 2, 5, 5) == 0, "inside a clip paints")
assert(channel(canvas, 2, 15, 5) == 255, "outside a clip stays untouched")
expectError(canvas.popClip, "pushClip", "popClip needs a pushClip", canvas)
expectError(function() canvas:pushClip(0, 0, -1, 5) end, "non-negative", "pushClip checks the size")
canvas:clear("#ffffff")
canvas:pushClip(0, 0, 10, 10, { radius = 5 })
canvas:fillRect(0, 0, 20, 10, "#ff0000")
canvas:popClip()
assert(channel(canvas, 2, 0, 0) == 255, "a rounded clip cuts the corner")
assert(channel(canvas, 2, 5, 5) == 0, "a rounded clip keeps the middle")
canvas:clear("#ffffff")
canvas:pushClip(0, 0, 10, 10)
canvas:pushClip(5, 0, 10, 10)
canvas:fillRect(0, 0, 20, 10, "#ff0000")
canvas:popClip()
canvas:popClip()
assert(channel(canvas, 2, 2, 5) == 255 and channel(canvas, 2, 7, 5) == 0, "nested clips intersect")
assert(channel(canvas, 2, 12, 5) == 255, "nested clips intersect on the right too")
canvas:clear("#ffffff")
canvas:pushClipPath(ui.path():circle(5, 5, 4))
canvas:fillRect(0, 0, 20, 10, "#ff0000")
canvas:popClip()
assert(channel(canvas, 2, 5, 5) == 0 and channel(canvas, 2, 0, 0) == 255, "a path clips to its inside")
-- Opacity groups.
canvas:clear("#ffffff")
canvas:pushOpacity(0.5)
canvas:fillRect(0, 0, 20, 10, "#ff0000")
canvas:fillRect(0, 0, 20, 10, "#000000")
canvas:popOpacity()
r, g, b, a = canvas:getPixel(5, 5)
assert(r > 120 and r < 135 and g > 120 and g < 135 and a == 255, "a group fades as one: " .. tostring(r) .. " " .. tostring(g))
expectError(canvas.popOpacity, "pushOpacity", "popOpacity needs a pushOpacity", canvas)
expectError(function() canvas:pushOpacity(2) end, "0 to 1", "pushOpacity checks the range")
canvas:pushOpacity(1)
canvas:pushClip(0, 0, 1, 1)
expectError(canvas.popOpacity, "popClip", "a group must close its clips first", canvas)
canvas:popClip()
canvas:popOpacity()
-- Paths.
local sheet = ui.canvas(30, 30)
sheet:fillPath(ui.path():moveTo(0, 0):lineTo(10, 0):lineTo(0, 10):close(), "#0000ff")
r, g, b, a = sheet:getPixel(2, 2)
assert(b == 255 and a == 255, "fillPath fills a triangle")
assert(channel(sheet, 4, 9, 9) == 0, "fillPath leaves the outside alone")
sheet:strokePath(ui.path():moveTo(0, 20):lineTo(30, 20), "#000000", { width = 4, cap = "square" })
assert(channel(sheet, 4, 10, 20) == 255 and channel(sheet, 4, 10, 15) == 0, "strokePath draws a line")
local rings = ui.path():rect(0, 0, 30, 30):rect(5, 5, 20, 20)
local plain = ui.canvas(30, 30)
plain:fillPath(rings, "#000000")
local holed = ui.canvas(30, 30)
holed:fillPath(rings, "#000000", { evenOdd = true })
assert(channel(plain, 4, 15, 15) == 255, "the nonzero rule fills nested shapes")
assert(channel(holed, 4, 15, 15) == 0 and channel(holed, 4, 2, 15) == 255, "evenOdd leaves a hole")
local x, y, pw, ph = ui.path():moveTo(2, 3):lineTo(12, 8):bounds()
assert(x == 2 and y == 3 and pw == 10 and ph == 5, "bounds covers the points")
local _, _, ew = ui.path():bounds()
assert(ew == 0, "an empty path has no bounds")
expectError(function() sheet:fillPath(ui.path(), "#000") end, "path is empty", "an empty path is rejected")
expectError(function() sheet:fillPath({}, "#000") end, "must be a path", "fillPath needs a path")
expectError(function() sheet:strokePath(rings, "#000", { cap = "pointy" }) end, "cap", "strokePath checks the cap")
expectError(function() return ui.path():moveTo("a", 1) end, "must be a number", "path points are numbers")
for _, c in { sheet, plain, holed } do c:close() end
-- Shadows.
local card = ui.canvas(60, 60)
card:fillRect(20, 20, 20, 20, "#ff0000", { shadow = { blur = 10, color = "#000000" } })
r, g, b, a = card:getPixel(30, 30)
assert(r == 255 and a == 255, "the shape covers its shadow")
a = channel(card, 4, 17, 30)
assert(a > 10 and a < 255, "the shadow fades outside the shape: " .. a)
assert(channel(card, 4, 0, 0) == 0, "the shadow ends")
card:clear()
card:fillRect(20, 20, 10, 10, "#ff0000", { shadow = { blur = 0, offsetX = 15, color = "#000000" } })
assert(channel(card, 4, 40, 25) == 255 and channel(card, 4, 5, 25) == 0, "a shadow can be offset")
expectError(function() card:fillRect(0, 0, 5, 5, "#000", { shadow = { blur = -1 } }) end, "non-negative", "shadow blur is checked")
card:close()
-- Blur.
local glass = ui.canvas(40, 10)
glass:fillRect(0, 0, 20, 10, "#ff0000")
glass:fillRect(20, 0, 20, 10, "#0000ff")
glass:blurRegion(0, 0, 40, 10, 4)
r, g, b = glass:getPixel(19, 5)
assert(r > 30 and b > 30, "blur mixes both sides of an edge: " .. r .. " " .. b)
assert(glass:getPixel(0, 5) > 200, "blur keeps flat areas")
glass:clear("#ffffff")
glass:fillRect(0, 0, 10, 10, "#000000")
glass:blurRegion(25, 0, 10, 10, 4)
assert(glass:getPixel(30, 5) == 255 and glass:getPixel(5, 5) == 0, "blur leaves other regions alone")
glass:blurRegion(0, 0, 40, 10, 0)
expectError(function() glass:blurRegion(0, 0, 5, 5, 500) end, "radius", "blurRegion checks the radius")
glass:close()
-- Canvases draw like images.
local source = ui.canvas(10, 10)
source:fillRect(0, 0, 10, 10, "#00ff00")
local target = ui.canvas(40, 10)
target:drawImage(source, 5, 0)
assert(channel(target, 2, 8, 5) == 255 and channel(target, 4, 2, 5) == 0, "a canvas draws onto a canvas")
target:drawImage(source, 20, 0, { width = 5, height = 5 })
assert(channel(target, 2, 22, 2) == 255 and channel(target, 4, 22, 8) == 0, "a canvas can be scaled")
local faded = ui.canvas(10, 10)
faded:drawImage(source, 0, 0, { opacity = 0.5 })
a = channel(faded, 4, 5, 5)
assert(a > 120 and a < 135, "a canvas draws with opacity: " .. a)
faded:clear("#ffffff")
faded:pushClip(0, 0, 5, 10)
faded:drawImage(source, 0, 0)
faded:popClip()
assert(channel(faded, 3, 2, 5) == 0 and channel(faded, 3, 7, 5) == 255, "clips apply to drawn canvases")
expectError(function() source:drawImage(source, 0, 0) end, "itself", "a canvas cannot draw itself")
for _, c in { source, target, faded } do c:close() end
-- Freed canvases.
canvas:close()
canvas:close()
expectError(function() canvas:fillRect(0, 0, 1, 1, "#000000") end, "canvas was freed", "a freed canvas is unusable")
expectError(function() return canvas:id() end, "canvas was freed", "a freed canvas has no id")
-- LIVE WINDOW (opt in with an empty tests/ui.local file) --------------------
--
-- Opening windows needs a display and, on macOS, the main thread, which
-- `cargo test` does not provide; the marker keeps CI headless.
if (not fs.exists("tests/ui.local")) then
print("ui: skipping the live window checks (no tests/ui.local)")
return
end
assert(ui.isAvailable(), "tests/ui.local is present but no display is available")
local win = ui.open({ title = "Neyuki test", width = 200, height = 120, visible = false })
assert(win:isOpen(), "a fresh window is open")
assert(win:title() == "Neyuki test")
local width, height = win:size()
assert(width > 0 and height > 0, "a window has a size")
assert(win:scale() > 0, "a window has a scale factor")
win:setTitle("Renamed")
assert(win:title() == "Renamed")
win:clear("#ffffff")
win:fillRect(10, 10, 50, 30, "#ff0000", { radius = 5 })
win:strokeRect(10, 10, 50, 30, "#000000", { width = 2 })
win:line(0, 0, 200, 120, "#00ff00", { width = 3 })
win:circle(100, 60, 20, "#0000ff")
win:circle(100, 60, 30, "#0000ff", { fill = false })
win:polygon({ 150, 20, 190, 40, 170, 100 }, "#ff00ff")
win:polygon({ 150, 20, 190, 40, 170, 100 }, "#000000", { fill = false, width = 2 })
local drawn = win:text(10, 80, "Hello", { size = 20, color = "#333333" })
assert(drawn > 0, "drawing text reports its width")
local dot = ui.loadImage("tests/fixtures/dot.png")
win:drawImage(dot, 5, 100)
win:drawImage(dot, 20, 100, { width = 16, height = 12 })
win:drawImage(dot, 40, 100, { width = 30, height = 30, fit = "cover", opacity = 0.5, radius = 4 })
win:drawImage(dot, 80, 100, { width = 30, height = 30, fit = "contain", frame = 1 })
expectError(function() win:drawImage(dot, 0, 0, { frame = 2 }) end, "frame number", "drawImage checks the frame")
expectError(function() win:drawImage(dot, 0, 0, { fit = "stretch" }) end, "fit", "drawImage checks the fit")
expectError(function() win:drawImage(dot, 0, 0, { opacity = 2 }) end, "opacity", "drawImage checks the opacity")
dot:close()
win:present()
expectError(function() win:fillRect(0, 0, 10, 10, "red") end, "#rgb", "drawing checks colors")
expectError(function() win:polygon({ 1, 2 }, "#000") end, "at least two points", "polygons need two points")
expectError(function() win:polygon({ 1, 2, 3, 4, 5 }, "#000") end, "pairs", "polygon points come in pairs")
expectError(function() win:circle(0, 0, -1, "#000") end, "non-negative", "circles need a radius")
expectError(function() win:text(0, 0, 5) end, "text must be a string", "text checks its string")
expectError(function() win:drawImage("x", 0, 0) end, "must be an image", "drawImage checks the image")
expectError(function() win:setSize(0, 10) end, "must be positive", "setSize checks its arguments")
-- Window chrome calls that do not change what is on screen.
assert(type(win:isMaximized()) == "boolean", "isMaximized reports a boolean")
win:setCursor("pointer")
win:setCursor("hidden")
win:setCursor("default")
expectError(function() win:setCursor("hand") end, "unknown cursor", "setCursor checks the name")
expectError(function() win:startResize("up") end, "unknown resize direction", "startResize checks the direction")
expectError(function() win:startResize() end, "direction must be provided", "startResize needs a direction")
win:setTextInput(true, { x = 10, y = 10, w = 100, h = 20 })
win:setTextInput(true, { 10, 10 })
win:setTextInput(false)
expectError(function() win:setTextInput("yes") end, "enabled must be a boolean", "setTextInput checks enabled")
expectError(function() win:setTextInput(true, { x = "a", y = 0 }) end, "area.x must be a number", "setTextInput checks the area")
expectError(function() win:setFullscreen(1) end, "fullscreen must be a boolean", "setFullscreen checks its argument")
win:setIcon("tests/fixtures/dot.png")
local icon = ui.loadImage("tests/fixtures/dot.png")
win:setIcon(icon)
icon:close()
expectError(function() win:setIcon(icon) end, "image was freed", "setIcon rejects freed images")
win:setIcon(nil)
local framed = ui.open({ width = 120, height = 80, visible = false, decorations = false,
minWidth = 60, minHeight = 40, icon = "tests/fixtures/dot.png", shadow = false })
assert(framed:isOpen(), "a frameless window opens")
framed:close()
local events = ui.poll()
assert(type(events) == "table", "poll returns a table")
for _, event in events do
assert(event.window == win, "events name their window")
assert(type(event.kind) == "string", "events have a kind")
end
events = ui.poll(0.05)
assert(type(events) == "table", "poll with a timeout returns a table")
-- Unfinished clips or groups are reported at present and then dropped.
win:pushOpacity(0.5)
expectError(function() win:present() end, "missing before present", "present checks for open groups")
win:pushClip(0, 0, 10, 10)
expectError(function() win:present() end, "missing before present", "present checks for open clips")
win:fillRect(0, 0, 10, 10, "#ff0000")
win:present()
win:blurRegion(0, 0, 50, 50, 3, { radius = 8 })
local live = ui.canvas(10, 10)
live:fillRect(0, 0, 10, 10, "#00ff00")
win:drawImage(live, 5, 5)
live:close()
win:present()
-- The OS may have no media controls to offer (no D-Bus session on Linux),
-- so only the shape of the answer is checked.
local published, why = ui.mediaSession({ title = "Test", artist = "Neyuki", duration = 60, position = 1, playing = true })
assert(type(published) == "boolean", "mediaSession reports whether it published")
assert(published or type(why) == "string", "a refused session says why")
expectError(ui.mediaSession, "cannot read artwork", "mediaSession reports missing artwork",
{ title = "Test", artwork = "tests/fixtures/missing.png" })
local cover = ui.loadImage("tests/fixtures/dot.png")
ui.mediaSession({ title = "Test", artwork = cover })
cover:close()
assert(ui.mediaSession(nil), "a session can be ended")
win:close()
assert(not win:isOpen(), "a closed window reports it")
win:close()
expectError(function() win:present() end, "window is closed", "a closed window cannot be drawn")
expectError(function() win:size() end, "window is closed", "a closed window has no size")
-- `run` returns once every window is closed.
local second = ui.open({ width = 100, height = 100, visible = false })
local ticks = 0
ui.run(function(frameEvents: {Event}, dt: number)
assert(type(dt) == "number")
ticks += 1
second:clear("#123456")
second:present()
if (ticks >= 3) then second:close() end
end, { fps = 120 })
assert(ticks == 3, "run should have stopped when the window closed: " .. tostring(ticks))
assert(not second:isOpen())
-- An error inside the handler closes the windows and surfaces.
local third = ui.open({ width = 100, height = 100, visible = false })
expectError(ui.run, "boom", "run re-raises handler errors", function() error("boom") end)
assert(not third:isOpen(), "run closes windows when the handler fails")
print("ui: live window checks passed")