feat(ui): dialogs as ordinary nodes, with capture, border and fill
A dialog is a component the app includes when its state says so, placed absolutely so it covers the flow instead of joining it, and dismissed by rebuilding without it. No layer stack, no module state, no lifecycle: "on top" already means "later in the child list", which the draw walk gives for free, and nothing needs to survive a rebuild because the tree is derived from state rather than mutated beside it. The alternative was a ui.push/ui.pop stack of roots. It would have been the only imperative thing in an otherwise declarative program, and needed rules to reconcile two ways for something to reach the screen -- the rule that a rebuild must only replace the base being the one that would eventually be forgotten. Three primitives were missing and are now here. capture makes a component swallow the taps its children missed, without which the hit walk falls back to earlier siblings and a dialog can be tapped through. border draws a box as a rounded rect. fill names a size a fraction cannot express, since any number >= 1 is read as pixels, so w = 1.0 asked for one pixel. The root now measures as "fill" rather than auto. It is placed at the full panel rect already, so its own children could not resolve a fraction of the one component whose size is never in doubt. Settings asks before forgetting a network, which is the first caller.
This commit is contained in:
@@ -129,10 +129,32 @@ function on_touch_down(x, y) screen:down(x, y) end
|
||||
function on_touch_up(x, y) screen:up(x, y) end
|
||||
```
|
||||
|
||||
Sizes are pixels (>= 1), a fraction of the parent's content box (< 1), or `"auto"`
|
||||
(the default on the flow axis; the cross axis fills the parent). Boxes take `pad`,
|
||||
`gap`, `row`, `align` and `at` for absolute placement. `color`, `bg`, `radius`,
|
||||
`gradient` and the press palette inherit from the root, so a theme is set once.
|
||||
Sizes are pixels (>= 1), a fraction of the parent's content box (< 1), `"fill"` for all
|
||||
of it, or `"auto"` (the default on the flow axis; the cross axis fills the parent).
|
||||
Boxes take `pad`, `gap`, `row`, `align`, `justify`, `border`, `capture`, and `at` for
|
||||
absolute placement. `color`, `bg`, `radius`, `gradient` and the press palette inherit
|
||||
from the root, so a theme is set once.
|
||||
|
||||
Dialogs are not a layer the toolkit manages. A dialog is a node the app includes when
|
||||
its state calls for one, placed absolutely so it covers the flow rather than joining
|
||||
it, and dismissed by rebuilding without it:
|
||||
|
||||
```lua
|
||||
local function build()
|
||||
local content = ui.box{pad = 12, gap = 8, rows()}
|
||||
if not confirming then return ui.screen(ui.box{content}) end
|
||||
return ui.screen(ui.box{content, ui.confirm{
|
||||
title = "forget network?", ok = "forget",
|
||||
on_ok = function() wifi.forget(); confirming = false; build() end,
|
||||
on_cancel = function() confirming = false; build() end,
|
||||
}})
|
||||
end
|
||||
```
|
||||
|
||||
Nothing has to survive a rebuild, because the tree is derived from state rather than
|
||||
mutated alongside it. `capture = true` makes a component swallow the taps its children
|
||||
missed, which is what stops a dialog being tapped through; it deliberately has no
|
||||
`on_press`, so a stray touch on a resistive panel answers nothing.
|
||||
|
||||
The toolkit owns press capture (release outside cancels), an 80 ms minimum pressed
|
||||
duration, touch slop, and per-component dirty tracking. Any table with `measure`,
|
||||
|
||||
@@ -19,8 +19,15 @@ function computeCalibration(s1, s2, w, h, inset)
|
||||
math.floor(s2.x + sx * inset), math.floor(s2.y + sy * inset)
|
||||
end
|
||||
|
||||
-- When set, a dialog node the current screen is rebuilt with. It is state like `mode`
|
||||
-- and `message`, not something layered on afterwards, so no screen rebuild can lose it.
|
||||
local dialog
|
||||
|
||||
local function themed(items)
|
||||
screen = ui.screen(ui.box(items)) -- colors come from the theme by inheritance
|
||||
-- The root carries no padding so a dialog can cover the whole panel; the padded box
|
||||
-- is the content it covers. Colors come from the theme by inheritance.
|
||||
local content = ui.box(items)
|
||||
screen = ui.screen(dialog and ui.box{content, dialog} or ui.box{content})
|
||||
end
|
||||
|
||||
local function target(n)
|
||||
@@ -125,10 +132,26 @@ local function requestScan()
|
||||
end
|
||||
|
||||
local function forgetNetwork()
|
||||
dialog = nil
|
||||
message = wifi.forget() and "wifi forgotten" or "save failed"
|
||||
buildWifi()
|
||||
end
|
||||
|
||||
-- Destructive and one tap away from the row above it, which is what a confirm is for.
|
||||
local function confirmForget()
|
||||
dialog = ui.confirm{
|
||||
title = "forget network?",
|
||||
message = wifi.status().ssid,
|
||||
ok = "forget",
|
||||
on_ok = forgetNetwork,
|
||||
on_cancel = function()
|
||||
dialog = nil
|
||||
buildWifi()
|
||||
end,
|
||||
}
|
||||
buildWifi()
|
||||
end
|
||||
|
||||
function buildWifi()
|
||||
mode = "wifi"
|
||||
local status = wifi.status()
|
||||
@@ -138,7 +161,7 @@ function buildWifi()
|
||||
end
|
||||
items[#items + 1] = ui.button{label = "scan networks", on_press = requestScan}
|
||||
if status.ssid ~= "" then
|
||||
items[#items + 1] = ui.button{label = "forget network", on_press = forgetNetwork}
|
||||
items[#items + 1] = ui.button{label = "forget network", on_press = confirmForget}
|
||||
end
|
||||
items[#items + 1] = ui.button{label = "back", on_press = buildMenu}
|
||||
themed(items)
|
||||
|
||||
+61
-1
@@ -1,5 +1,6 @@
|
||||
-- Layout borrowed from CSS block flow: nesting plus box model, no cascade.
|
||||
-- Sizes are pixels (>= 1), a fraction of the parent content box (< 1), or "auto".
|
||||
-- Sizes are pixels (>= 1), a fraction of the parent content box (< 1), "fill" for all
|
||||
-- of it, or "auto" to size to content.
|
||||
|
||||
local ui = {}
|
||||
|
||||
@@ -82,6 +83,14 @@ ui.reloadTheme()
|
||||
|
||||
local function resolve(value, span, axis)
|
||||
if value == nil or value == "auto" then return nil end
|
||||
-- "fill" exists because a fraction cannot say 1.0: any number >= 1 is a pixel count,
|
||||
-- so w = 1.0 asks for a single pixel. Covering a parent needs a word, not a number.
|
||||
if value == "fill" then
|
||||
if span == nil then
|
||||
error("fill " .. axis .. " inside an auto-sized parent", 3)
|
||||
end
|
||||
return span
|
||||
end
|
||||
if value < 1 then
|
||||
if span == nil then
|
||||
error("fractional " .. axis .. " inside an auto-sized parent", 3)
|
||||
@@ -223,6 +232,10 @@ function Component:hit(x, y)
|
||||
local found = self.children[index]:hit(x, y)
|
||||
if found then return found end
|
||||
end
|
||||
-- A capturing component swallows the taps its children missed, so what it covers
|
||||
-- cannot be tapped through. Without it the hit walk falls back to earlier siblings,
|
||||
-- which is how a dialog would let you press the button underneath it.
|
||||
if self.capture then return self end
|
||||
return self.on_press and self or nil
|
||||
end
|
||||
|
||||
@@ -231,6 +244,16 @@ function Component:invalidate()
|
||||
end
|
||||
|
||||
local function component(spec)
|
||||
-- A bordered box is drawn as a rounded rect over its own background fill. The fill is
|
||||
-- square and the border is not, but both are the same color as whatever sits behind a
|
||||
-- box on this panel, so the corners have nothing to give away.
|
||||
if spec.border then
|
||||
spec.paint = function(self)
|
||||
local r = self.rect
|
||||
gui.roundRect(r.x, r.y, r.w, r.h, self.radius or ui.theme.radius, self.bg, self.bg,
|
||||
self.bg, self.border)
|
||||
end
|
||||
end
|
||||
spec.children = spec.children or {}
|
||||
for index, child in ipairs(spec) do
|
||||
spec.children[index] = child
|
||||
@@ -299,6 +322,38 @@ function ui.button(spec)
|
||||
return node
|
||||
end
|
||||
|
||||
-- A dialog is not a layer the toolkit manages: it is a node an app includes when its
|
||||
-- state calls for one, placed absolutely so it covers the flow instead of joining it.
|
||||
-- Dismissing it means rebuilding without it, the same way every other state change works.
|
||||
function ui.confirm(spec)
|
||||
local card = {
|
||||
w = spec.w or 0.85,
|
||||
pad = 16,
|
||||
gap = 12,
|
||||
bg = ui.theme.bg, -- opaque: this is what hides the content behind
|
||||
border = spec.border or ui.theme.muted,
|
||||
ui.text(spec.title),
|
||||
}
|
||||
if spec.message then card[#card + 1] = ui.text(spec.message, {color = ui.theme.muted}) end
|
||||
|
||||
local buttons = {row = true, gap = 8, justify = "end"}
|
||||
if spec.cancel ~= false then
|
||||
buttons[#buttons + 1] = ui.button{label = spec.cancel or "cancel", on_press = spec.on_cancel}
|
||||
end
|
||||
buttons[#buttons + 1] = ui.button{label = spec.ok or "ok", on_press = spec.on_ok}
|
||||
card[#card + 1] = ui.box(buttons)
|
||||
|
||||
-- bg = false leaves the app's own screen visible around the card; capture stops it
|
||||
-- being tapped. on_press is deliberately absent, so a stray touch answers nothing.
|
||||
return ui.box{
|
||||
at = {x = 0, y = 0}, w = "fill", h = "fill",
|
||||
bg = false, capture = true,
|
||||
align = "center", justify = "center",
|
||||
on_press = spec.on_outside,
|
||||
ui.box(card),
|
||||
}
|
||||
end
|
||||
|
||||
-- Screen -------------------------------------------------------------------
|
||||
|
||||
local Screen = {}
|
||||
@@ -312,6 +367,11 @@ local ROOT_STYLE = {
|
||||
|
||||
function ui.screen(root, style)
|
||||
local screen = setmetatable({root = root, captured = nil, pressedAt = 0}, Screen)
|
||||
-- The root is placed at the full panel rect, so it must measure that way too. Leaving
|
||||
-- it auto made its height unknown to its own children, and a child asking for a
|
||||
-- fraction of the screen failed inside the one component whose size is never in doubt.
|
||||
if root.w == nil then root.w = "fill" end
|
||||
if root.h == nil then root.h = "fill" end
|
||||
for key, role in pairs(ROOT_STYLE) do
|
||||
if root[key] == nil then root[key] = (style and style[key]) or ui.theme[role] end
|
||||
end
|
||||
|
||||
@@ -87,6 +87,38 @@ 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)
|
||||
|
||||
-- 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})
|
||||
|
||||
Reference in New Issue
Block a user