diff --git a/README.md b/README.md index edc9661..97bc862 100644 --- a/README.md +++ b/README.md @@ -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`, diff --git a/sdcard/apps/settings/main.lua b/sdcard/apps/settings/main.lua index 95dae9f..a6a86ff 100644 --- a/sdcard/apps/settings/main.lua +++ b/sdcard/apps/settings/main.lua @@ -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) diff --git a/sdcard/lib/ui.lua b/sdcard/lib/ui.lua index dadb57a..682b542 100644 --- a/sdcard/lib/ui.lua +++ b/sdcard/lib/ui.lua @@ -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 diff --git a/test/ui_layout.lua b/test/ui_layout.lua index 7574e21..37f6c82 100644 --- a/test/ui_layout.lua +++ b/test/ui_layout.lua @@ -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})