neyuki 0.6.3

A small Lua-like scripting language implemented in Rust
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
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")