167 lines
7.2 KiB
Lua
167 lines
7.2 KiB
Lua
-- Run: lua test/ui_layout.lua
|
|
-- Asserts the rects ui.lua computes, against the shared fake device.
|
|
package.path = "sdcard/lib/?.lua;test/?.lua;" .. package.path
|
|
|
|
local device = require("fake_device").install()
|
|
local ui = require("ui")
|
|
|
|
local function rect(node)
|
|
return string.format("%d,%d %dx%d", node.rect.x, node.rect.y, node.rect.w, node.rect.h)
|
|
end
|
|
|
|
-- Vertical flow: children fill the content width, stack by their own height plus gap.
|
|
local title = ui.text("settings")
|
|
local first = ui.button{label = "one", on_press = function() end}
|
|
local second = ui.button{label = "two", on_press = function() end}
|
|
local screen = ui.screen(ui.box{pad = 12, gap = 8, title, first, second})
|
|
|
|
assert(rect(title) == "12,12 296x8", rect(title))
|
|
-- Button height is text plus its own padding: 8 + 8 + 8 = 24.
|
|
assert(rect(first) == "12,28 296x24", rect(first))
|
|
assert(rect(second) == "12,60 296x24", rect(second))
|
|
|
|
screen:draw()
|
|
device.painted = {}
|
|
first:setText("a longer label")
|
|
screen:draw()
|
|
assert(#device.painted == 1 and device.painted[1].label == "a longer label",
|
|
"changing a button label repainted other components")
|
|
|
|
-- Fractions resolve against the parent content box, absolutes stay absolute.
|
|
local half = ui.box{w = 0.5, h = 40}
|
|
local fixed = ui.box{w = 100, h = 40}
|
|
ui.screen(ui.box{pad = 10, half, fixed})
|
|
assert(rect(half) == "10,10 150x40", rect(half))
|
|
assert(rect(fixed) == "10,50 100x40", rect(fixed))
|
|
|
|
-- Horizontal flow with centre alignment on the cross axis of a fixed-height row.
|
|
local tall = ui.box{w = 40, h = 40}
|
|
local short = ui.box{w = 40, h = 10}
|
|
local row = ui.box{row = true, gap = 6, align = "center", h = 40, tall, short}
|
|
ui.screen(ui.box{row})
|
|
assert(rect(tall) == "0,0 40x40", rect(tall))
|
|
assert(rect(short) == "46,15 40x10", rect(short))
|
|
|
|
-- justify distributes the main axis: "between" pushes the last child to the far edge,
|
|
-- which is how a header keeps a title left and a clock right without arithmetic.
|
|
local left = ui.box{w = 40, h = 10}
|
|
local right = ui.box{w = 60, h = 10}
|
|
ui.screen(ui.box{pad = 10, ui.box{row = true, justify = "between", left, right}})
|
|
assert(rect(left) == "10,10 40x10", rect(left))
|
|
assert(rect(right) == "250,10 60x10", rect(right))
|
|
|
|
-- The other modes shift the whole run rather than spreading it.
|
|
local a = ui.box{w = 40, h = 10}
|
|
local b = ui.box{w = 60, h = 10}
|
|
ui.screen(ui.box{ui.box{row = true, gap = 10, justify = "end", a, b}})
|
|
assert(rect(a) == "210,0 40x10", rect(a))
|
|
assert(rect(b) == "260,0 60x10", rect(b))
|
|
|
|
-- One child cannot be spread against anything, so "between" degrades to "start".
|
|
local only = ui.box{w = 40, h = 10}
|
|
ui.screen(ui.box{ui.box{row = true, justify = "between", only}})
|
|
assert(rect(only) == "0,0 40x10", rect(only))
|
|
|
|
-- The root always fills the screen, so alignment there spans the whole panel.
|
|
local lone = ui.box{w = 40, h = 40}
|
|
ui.screen(ui.box{align = "center", lone})
|
|
assert(rect(lone) == "140,0 40x40", rect(lone))
|
|
|
|
-- A fraction inside an auto-sized parent is a build-time error, not a silent zero.
|
|
local ok = pcall(function()
|
|
ui.screen(ui.box{ui.box{h = "auto", ui.box{w = 20, h = 0.5}}})
|
|
end)
|
|
assert(not ok, "fraction inside an auto parent must fail loudly")
|
|
|
|
-- Hit testing: deepest interactive component wins over its tappable ancestor.
|
|
local inner = ui.button{label = "inner", on_press = function() end}
|
|
local outer = ui.box{pad = 20, on_press = function() end, inner}
|
|
local nested = ui.screen(outer)
|
|
assert(outer:hit(160, 30) == inner, "inner button should win inside its rect")
|
|
assert(outer:hit(2, 2) == outer, "the container claims its own padding")
|
|
assert(outer:hit(-50, -50) == nil, "outside the tree is a miss")
|
|
|
|
-- Capture: press then release outside cancels; release inside fires once.
|
|
local fired = 0
|
|
local button = ui.button{label = "go", on_press = function() fired = fired + 1 end}
|
|
local app = ui.screen(ui.box{pad = 10, button})
|
|
app:down(100, 20)
|
|
assert(button.pressed, "press must show immediately")
|
|
app:up(300, 470)
|
|
assert(fired == 0, "release outside must cancel")
|
|
|
|
app:down(100, 20)
|
|
app:up(100, 20)
|
|
assert(fired == 1, "release inside must fire once")
|
|
|
|
-- A dialog is an ordinary node the app includes, so it covers the flow and swallows the
|
|
-- taps that would otherwise reach what is underneath it.
|
|
local underneath = 0
|
|
local answered
|
|
local buried = ui.button{label = "do not press", on_press = function() underneath = underneath + 1 end}
|
|
local dialog = ui.confirm{
|
|
title = "forget network?",
|
|
ok = "forget",
|
|
on_ok = function() answered = "ok" end,
|
|
on_cancel = function() answered = "cancel" end,
|
|
}
|
|
local dialogScreen = ui.screen(ui.box{ui.box{pad = 12, buried}, dialog})
|
|
|
|
-- The card is centered on both axes, and the layer fills the panel.
|
|
assert(rect(dialog) == "0,0 320x480", rect(dialog))
|
|
local card = dialog.children[1]
|
|
assert(card.rect.w == 272, "card width " .. card.rect.w) -- 0.85 of 320
|
|
assert(card.rect.x == 24, "card x " .. card.rect.x) -- centered horizontally
|
|
assert(card.rect.y == math.floor((480 - card.rect.h) / 2), "card y " .. card.rect.y)
|
|
|
|
-- Dimming repaints what is behind in a darkened palette, and the dialog opts out, so a
|
|
-- card sits lit over dimmed content without any pixel ever being blended.
|
|
local litText = ui.text("behind")
|
|
ui.screen(ui.box{litText})
|
|
local dimText = ui.text("behind")
|
|
local lit = ui.text("in the dialog")
|
|
ui.screen(ui.box{dimmed = true, ui.box{dimText}, ui.box{dimmed = false, lit}})
|
|
-- A black scrim leaves black text black; the surface under it is what darkens.
|
|
assert(dimText.bg ~= litText.bg, "the dimmed surface kept its background")
|
|
assert(lit.bg == litText.bg, "the dialog was dimmed along with the content behind it")
|
|
assert(ui.theme.dim.bg ~= ui.theme.bg, "the dim palette matches the lit one")
|
|
assert(ui.theme.dim.muted ~= ui.theme.muted, "dimming did not reach derived roles")
|
|
|
|
-- A bordered card blends its rounded edge into what is behind it, not into its own
|
|
-- fill, or the corners it does not cover show the wrong color.
|
|
local bordered = ui.box{border = 1, bg = 2, w = 40, h = 20}
|
|
ui.screen(ui.box{dimmed = true, bordered})
|
|
assert(bordered.surface == ui.theme.dim.bg, "card blends against its own fill, not the surface")
|
|
assert(bordered.paintsBackground, "a bordered box must suppress the square fill")
|
|
|
|
-- A tap on the button that the dialog covers must not reach it.
|
|
dialogScreen:down(60, 24)
|
|
dialogScreen:up(60, 24)
|
|
assert(underneath == 0, "the dialog was tapped through")
|
|
|
|
-- The dialog's own buttons still work.
|
|
local ok = card.children[#card.children].children[2]
|
|
local point = {x = ok.rect.x + 4, y = ok.rect.y + 4}
|
|
dialogScreen:down(point.x, point.y)
|
|
dialogScreen:up(point.x, point.y)
|
|
assert(answered == "ok", "ok button did not fire, got " .. tostring(answered))
|
|
|
|
-- setText repaints only on a real change, so a caller may push a value every tick.
|
|
local label = ui.text("12:00:00")
|
|
local clockScreen = ui.screen(ui.box{label})
|
|
clockScreen:draw()
|
|
assert(not label.dirty, "a drawn node starts clean")
|
|
label:setText("12:00:00")
|
|
assert(not label.dirty, "unchanged text must not repaint")
|
|
label:setText("12:00:01")
|
|
assert(label.dirty, "changed text must repaint")
|
|
assert(label.label == "12:00:01", label.label)
|
|
|
|
-- The pressed look is held briefly, then cleared on a later draw.
|
|
assert(button.pressed, "pressed look must outlast the release")
|
|
device.now = device.now + 100
|
|
app:draw()
|
|
assert(not button.pressed, "pressed look must clear after the hold")
|
|
|
|
print("ok")
|