diff --git a/README.md b/README.md index 97bc862..48dff4f 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,14 @@ mutated alongside it. `capture = true` makes a component swallow the taps its ch 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. +`dimmed = true` on a node darkens it and everything it contains, and `ui.confirm` opts +its card back out, so a dialog sits lit over dimmed content. There is no alpha and no +framebuffer to blend against: the whole palette is re-derived from seeds mixed toward +black, and the content behind is simply repainted in those colors. Two color roles keep +this honest — `bg` is the fill a node paints, `surface` is what sits underneath it, and +anti-aliased edges blend into `surface`. A rounded card that blends into its own `bg` +leaves square corners, which only shows once something behind it is a different color. + 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`, `place`, `draw` and `hit` drops into the tree, so custom components need no buy-in. diff --git a/sdcard/apps/settings/main.lua b/sdcard/apps/settings/main.lua index a6a86ff..8d3c46b 100644 --- a/sdcard/apps/settings/main.lua +++ b/sdcard/apps/settings/main.lua @@ -27,7 +27,9 @@ local function themed(items) -- 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}) + -- dimmed on the root, not the content: the root is the node that covers the panel, so + -- the scrim reaches the margins the content box does not. + screen = ui.screen(dialog and ui.box{dimmed = true, content, dialog} or ui.box{content}) end local function target(n) diff --git a/sdcard/lib/ui.lua b/sdcard/lib/ui.lua index 682b542..8850302 100644 --- a/sdcard/lib/ui.lua +++ b/sdcard/lib/ui.lua @@ -58,6 +58,22 @@ local function palette(seed) return theme end +-- Dimming is a black scrim at this strength. The panel has no alpha and there is no +-- framebuffer to blend against, so instead of compositing pixels the whole palette is +-- re-derived from darkened seeds: content behind a dialog is repainted in those colors, +-- which is what a scrim would have produced anyway. +local SCRIM = 0.45 + +local function dimSeed(seed) + local out = {} + for role, value in pairs(seed) do + if type(value) ~= "table" then out[role] = value + elseif type(value[1]) == "table" then out[role] = {mix(value[1], BLACK, SCRIM), mix(value[2], BLACK, SCRIM)} + else out[role] = mix(value, BLACK, SCRIM) end + end + return out +end + local function themes() local ok, loaded = pcall(require, "theme") return ok and type(loaded) == "table" and loaded or {} @@ -75,7 +91,9 @@ end function ui.reloadTheme() ui.themeName = sys.getTheme and sys.getTheme() or "light" local available = themes() - ui.theme = palette(available[ui.themeName] or available.light or FALLBACK) + local seed = available[ui.themeName] or available.light or FALLBACK + ui.theme = palette(seed) + ui.theme.dim = palette(dimSeed(seed)) return ui.theme end @@ -108,12 +126,32 @@ end -- The palette set on the root reaches every descendant, so styling is one place. local INHERITED = {"color", "bg", "size", "button_bg", "press_bg", "press_color", "radius", - "gradient", "press_gradient"} + "gradient", "press_gradient", "dimmed"} + +-- The root seeds the tree, so an app that names no colors is themed by inheritance. +local ROOT_STYLE = { + color = "fg", bg = "bg", press_bg = "accent", press_color = "accent_fg", + gradient = "face", press_gradient = "face_pressed", radius = "radius", +} + +local function seedFrom(node, palette, style) + for key, role in pairs(ROOT_STYLE) do + if node[key] == nil then node[key] = (style and style[key]) or palette[role] end + end +end local function inherit(child, parent) + -- Crossing into or out of a dimmed region restyles from the matching palette instead + -- of inheriting its neighbour's, which is how a dialog stays lit over dimmed content. + if child.dimmed ~= nil and child.dimmed ~= parent.dimmed then + seedFrom(child, child.dimmed and ui.theme.dim or ui.theme) + end for _, key in ipairs(INHERITED) do if child[key] == nil then child[key] = parent[key] end end + -- What this node sits on, which is not the same as what it fills. Anti-aliased edges + -- blend into the surface, so a rounded card needs the color behind it and not its own. + if child.surface == nil then child.surface = parent.bg or parent.surface end end local function contains(rect, x, y) @@ -217,7 +255,9 @@ end function Component:draw() if self.dirty then - if self.bg then gui.fillRect(self.rect.x, self.rect.y, self.rect.w, self.rect.h, self.bg) end + if self.bg and not self.paintsBackground then + gui.fillRect(self.rect.x, self.rect.y, self.rect.w, self.rect.h, self.bg) + end if self.paint then self:paint() end self.dirty = false for _, child in ipairs(self.children) do child.dirty = true end @@ -247,11 +287,15 @@ 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. + -- A bordered box paints its own background as a rounded rect, so the square fill in + -- Component:draw is suppressed. Filling first would leave square corners outside the + -- border, which is invisible against a matching surface and obvious against any other. if spec.border then + spec.paintsBackground = true 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) + gui.roundRect(r.x, r.y, r.w, r.h, self.radius or ui.theme.radius, self.surface, + self.bg, self.bg, self.border) end end spec.children = spec.children or {} @@ -347,7 +391,7 @@ function ui.confirm(spec) -- 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, + bg = false, capture = true, dimmed = false, -- the card is lit, whatever is behind it align = "center", justify = "center", on_press = spec.on_outside, ui.box(card), @@ -359,12 +403,6 @@ end local Screen = {} Screen.__index = Screen --- The root seeds the tree, so an app that names no colors is themed by inheritance. -local ROOT_STYLE = { - color = "fg", bg = "bg", press_bg = "accent", press_color = "accent_fg", - gradient = "face", press_gradient = "face_pressed", radius = "radius", -} - 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 @@ -372,9 +410,7 @@ function ui.screen(root, style) -- 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 + seedFrom(root, root.dimmed and ui.theme.dim or ui.theme, style) screen:relayout() return screen end diff --git a/test/ui_layout.lua b/test/ui_layout.lua index 37f6c82..c179b09 100644 --- a/test/ui_layout.lua +++ b/test/ui_layout.lua @@ -107,6 +107,26 @@ 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)