Files
gen1recomp/libs/flexlove/modules/PropertySchema.lua
T
2026-08-03 11:50:49 -04:00

506 lines
20 KiB
Lua

-- modules/PropertySchema.lua
--
-- Declarative source of truth for every Element prop.
--
-- Each entry describes one prop that Element.new / Element:setProperty currently
-- handles inline. Downstream tasks (03 data-driven prop binding, 05 registry-driven
-- setProperty dispatch) read this metadata instead of hardcoding property names.
--
-- Design constraints (locked — tasks 03/05 depend on this API):
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
-- Normalizers/validators are small, dependency-free closures so the module is
-- unit-testable standalone. Color/^/unit/enum *defaults* that require those
-- modules are left as `nil` here and applied by construction-time special
-- handlers in Task 03; only defaults expressible as literals are stored.
-- * O(1) lookup — `get(name)` is a single table index into a pre-built registry;
-- no per-call construction.
-- * Additive — `define(specs)` merges entries by name so build profiles can
-- extend/override without rebuilding the whole table.
--
-- Metadata shape per prop (all fields present, false/nil when not applicable):
-- type string — type tag for tooling ("number"|"string"|"boolean"|
-- "table"|"function"|"color"|"any")
-- default any|nil — literal default value applied when prop is absent
-- normalizer fn|nil — pure fn(value) -> value; transforms input before
-- storage (e.g. single-value padding -> 4-side table)
-- validator fn|nil — pure fn(value) -> bool; returns false for invalid
-- input (Task 03 warns + falls back on false)
-- isDimension boolean — true for width/height: setProperty routes these
-- through _resolveDimensionProperty (unit-string
-- resolution + border-box sync). Other unit-accepting
-- props (x/y/gap/padding/etc.) are resolved at
-- construction via special handlers, NOT via this flag.
-- affectsLayout boolean — true for props in the legacy setProperty
-- `layoutProperties` table; setting one invalidates
-- layout (matches baseline behavior exactly).
-- syncsTheme boolean — true for props whose setProperty path must reach
-- ThemeManager/Renderer (disabled/active/themeComponent)
-- hasDeferred boolean — true for callbacks that have an `on<Name>Deferred`
-- boolean companion prop (auto-wired by Task 03)
-- storageKey string|nil— when set, the prop is stored on the element under
-- this key instead of its own name (prop aliases, e.g.
-- isDisabled -> stored as `disabled`)
local PropertySchema = {}
---@type table<string, table>
local registry = {}
-- ---------------------------------------------------------------------------
-- Pure normalizers (small + dependency-free; hot-pathed during construction)
-- ---------------------------------------------------------------------------
--- Expand a single value to a 4-side table. Leaves tables unchanged. nil passthrough.
--- Used by padding/margin: `padding = 5` -> `{top=5,right=5,bottom=5,left=5}`.
local function expandSides(value)
if value == nil then
return nil
end
if type(value) == "table" then
return value
end
return { top = value, right = value, bottom = value, left = value }
end
--- Normalize flex direction aliases to internal enum names.
--- "row" -> "horizontal", "column" -> "vertical",
--- "row-reverse" -> "horizontal-reverse", "column-reverse" -> "vertical-reverse";
--- everything else passes through.
local function normalizeFlexDirection(value)
if value == "row" then
return "horizontal"
elseif value == "column" then
return "vertical"
elseif value == "row-reverse" then
return "horizontal-reverse"
elseif value == "column-reverse" then
return "vertical-reverse"
end
return value
end
--- Replicate Element.new's border-shape normalization (pure).
--- * table with sides: true -> 1, number -> value, false/nil -> false; nil if no
--- truthy side remains.
--- * number / other truthy scalar: kept as-is.
--- * nil / false: nil.
local function normalizeBorder(value)
if value == nil or value == false then
return nil
end
if type(value) == "table" then
local function side(v)
if v == true then
return 1
elseif type(v) == "number" then
return v
else
return false
end
end
local t = side(value.top)
local r = side(value.right)
local b = side(value.bottom)
local l = side(value.left)
if not (t or r or b or l) then
return nil
end
return { top = t, right = r, bottom = b, left = l }
end
return value
end
--- Replicate Element.new's cornerRadius-shape normalization (pure).
--- * number: 0 -> nil, else the number.
--- * table: nil if all four sides are zero/absent, else fill zeros for absent sides.
--- * nil -> nil.
local function normalizeCornerRadius(value)
if value == nil then
return nil
end
if type(value) == "number" then
if value == 0 then
return nil
end
return value
end
if type(value) == "table" then
-- Mirrors Element.new: `or` truthiness (0 is truthy in Lua). Only an all-
-- nil/false table collapses to nil; any present side — including 0 — yields
-- the 4-side table with zero-filled absent sides.
local hasAny = value.topLeft or value.topRight or value.bottomLeft or value.bottomRight
if not hasAny then
return nil
end
return {
topLeft = value.topLeft or 0,
topRight = value.topRight or 0,
bottomLeft = value.bottomLeft or 0,
bottomRight = value.bottomRight or 0,
}
end
return value
end
-- ---------------------------------------------------------------------------
-- Pure validators (dependency-free; return boolean)
-- ---------------------------------------------------------------------------
--- Range validator factory: returns fn(v) -> bool. nil is treated as valid
--- (absence handling is the default mechanism's job).
local function rangeValidator(min, max)
return function(v)
if v == nil then
return true
end
return type(v) == "number" and v >= min and v <= max
end
end
--- Enum validator factory: returns fn(v) -> bool for membership in `set` (set may
--- be an array or a map of value->truthy).
local function enumValidator(set)
local lookup = {}
if type(set) == "table" then
for k, v in pairs(set) do
if type(k) == "number" then
lookup[v] = true
else
lookup[k] = true
end
end
end
return function(v)
if v == nil then
return true
end
return lookup[v] == true
end
end
--- Boolean validator: nil is valid (absence); otherwise must be a boolean.
local function booleanValidator(v)
return v == nil or type(v) == "boolean"
end
-- ---------------------------------------------------------------------------
-- Registry construction
-- ---------------------------------------------------------------------------
--- Build a fully-populated metadata entry, filling omitted fields with defaults.
local function entry(spec)
return {
type = spec.type or "any",
default = spec.default,
normalizer = spec.normalizer,
validator = spec.validator,
isDimension = spec.isDimension == true,
affectsLayout = spec.affectsLayout == true,
syncsTheme = spec.syncsTheme == true,
hasDeferred = spec.hasDeferred == true,
storageKey = spec.storageKey,
}
end
--- Merge prop specs into the registry (additive; later entries override earlier).
---@param specs table<string, table> map of prop-name -> spec
---@return table registry the live registry table (for chaining/inspection)
function PropertySchema.define(specs)
for name, spec in pairs(specs) do
registry[name] = entry(spec)
end
return registry
end
--- O(1) metadata lookup.
---@param name string prop name
---@return table|nil metadata nil for unknown props (no error)
function PropertySchema.get(name)
return registry[name]
end
--- Return the live registry (for inspection / coverage assertions only — not for
--- per-call construction).
---@return table
function PropertySchema.all()
return registry
end
--- True if a prop is registered.
---@param name string
---@return boolean
function PropertySchema.has(name)
return registry[name] ~= nil
end
--- True if setting this prop invalidates layout (legacy `layoutProperties` set).
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
--- matching the legacy `layoutProperties[name]` nil-lookup behavior exactly.
---@param name string prop name
---@return boolean
function PropertySchema.affectsLayout(name)
local meta = registry[name]
return meta ~= nil and meta.affectsLayout == true
end
--- True for dimension props (width/height) that `setProperty` routes through
--- `_resolveDimensionProperty` (unit-string resolution + border-box sync).
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
--- matching the legacy `dimensionProperties[name]` nil-lookup behavior exactly.
---@param name string prop name
---@return boolean
function PropertySchema.isDimension(name)
local meta = registry[name]
return meta ~= nil and meta.isDimension == true
end
--- True for props whose setProperty path must reach ThemeManager/Renderer
--- (disabled/active/themeComponent). O(1) registry lookup — no per-call table
--- construction. Unknown props return false, matching a legacy nil-lookup exactly.
---@param name string prop name
---@return boolean
function PropertySchema.syncsTheme(name)
local meta = registry[name]
return meta ~= nil and meta.syncsTheme == true
end
-- ---------------------------------------------------------------------------
-- Default schema (covers every prop handled in Element.new lines 259-1909 and
-- Element:setProperty lines 4291-4417 of the Task-01 baseline).
-- ---------------------------------------------------------------------------
local function defineDefaults()
PropertySchema.define({
-- ------------------------------------------------------------------ identity
id = { type = "string" },
userdata = { type = "any" },
parent = { type = "table", affectsLayout = true },
children = { type = "table" },
-- ------------------------------------------------------------------ callbacks
onEvent = { type = "function", hasDeferred = true },
onFocus = { type = "function", hasDeferred = true },
onBlur = { type = "function", hasDeferred = true },
onTextInput = { type = "function", hasDeferred = true },
onTextChange = { type = "function", hasDeferred = true },
onEnter = { type = "function", hasDeferred = true },
onCreate = { type = "function", hasDeferred = true },
onTouchEvent = { type = "function", hasDeferred = true },
onGesture = { type = "function", hasDeferred = true },
onImageLoad = { type = "function", hasDeferred = true },
onImageError = { type = "function", hasDeferred = true },
-- Deferred companion flags (stored directly; no further Deferred companion)
onEventDeferred = { type = "boolean", default = false },
onFocusDeferred = { type = "boolean", default = false },
onBlurDeferred = { type = "boolean", default = false },
onTextInputDeferred = { type = "boolean", default = false },
onTextChangeDeferred = { type = "boolean", default = false },
onEnterDeferred = { type = "boolean", default = false },
onCreateDeferred = { type = "boolean", default = false },
onTouchEventDeferred = { type = "boolean", default = false },
onGestureDeferred = { type = "boolean", default = false },
onImageLoadDeferred = { type = "boolean", default = false },
onImageErrorDeferred = { type = "boolean", default = false },
-- focus / touch behavior
dropFocusOnSelection = { type = "boolean" },
customDraw = { type = "function" },
touchEnabled = { type = "boolean", default = true },
multiTouchEnabled = { type = "boolean", default = false },
-- ------------------------------------------------------------------ theme
theme = { type = "table" },
themeComponent = { type = "string", syncsTheme = true },
disabled = { type = "boolean", default = false, syncsTheme = true },
isDisabled = {
type = "boolean",
default = false,
syncsTheme = true,
storageKey = "disabled",
},
active = { type = "boolean", default = false, syncsTheme = true },
disableHighlight = { type = "boolean" },
themeStateLock = { type = "boolean" },
themeComponentDisabledStates = { type = "table" },
scaleCorners = { type = "boolean" },
scalingAlgorithm = { type = "string" },
contentAutoSizingMultiplier = { type = "table" },
contentBlur = { type = "table" },
backdropBlur = { type = "table" },
-- ------------------------------------------------------------------ text editing
editable = { type = "boolean", default = false },
multiline = { type = "boolean", default = false },
passwordMode = { type = "boolean", default = false },
textWrap = { type = "string" }, -- default computed from multiline
maxLines = { type = "number" },
maxLength = { type = "number" },
placeholder = { type = "string" },
inputType = { type = "string", default = "text" },
textOverflow = { type = "string", default = "clip" },
scrollable = { type = "boolean" }, -- default = multiline
autoGrow = { type = "boolean" }, -- default = multiline
selectOnFocus = { type = "boolean", default = false },
cursorColor = { type = "color" },
selectionColor = { type = "color" },
cursorBlinkRate = { type = "number", default = 0.5 },
text = { type = "string" },
textAlign = {
type = "string",
default = "start",
validator = enumValidator({ "start", "center", "end", "justify" }),
},
-- textAlignVertical is a derived storage field split out from textAlign
-- (bindVisualState resolves table/compound-string input into H + V). Its
-- validator is exposed for bindVisualState to validate the V component; the
-- prop itself stays in SPECIAL_PROPS because compound parsing needs
-- ErrorHandler warnings (schema is pure-Lua, cannot warn).
textAlignVertical = {
type = "string",
default = "start",
validator = enumValidator({ "start", "center", "end" }),
},
textColor = { type = "color" },
fontFamily = { type = "string" },
textSize = { type = "any" }, -- number | preset string; resolved by special handler
minTextSize = { type = "number" },
maxTextSize = { type = "number" },
autoScaleText = { type = "boolean", default = true },
-- ------------------------------------------------------------------ dimensions / box model
width = { type = "any", isDimension = true, affectsLayout = true },
height = { type = "any", isDimension = true, affectsLayout = true },
x = { type = "any", affectsLayout = false },
y = { type = "any", affectsLayout = false },
minWidth = { type = "any" },
maxWidth = { type = "any" },
minHeight = { type = "any" },
maxHeight = { type = "any" },
gap = { type = "any", affectsLayout = true },
padding = {
type = "any",
affectsLayout = true,
normalizer = expandSides,
},
margin = {
type = "any",
affectsLayout = true,
normalizer = expandSides,
},
flexDirection = {
type = "string",
default = "horizontal",
affectsLayout = true,
normalizer = normalizeFlexDirection,
},
flexWrap = { type = "string", default = "nowrap", affectsLayout = true },
justifyContent = { type = "string", default = "flex-start", affectsLayout = true },
alignItems = { type = "string", default = "stretch", affectsLayout = true },
alignContent = { type = "string", default = "stretch", affectsLayout = true },
positioning = { type = "string", default = "relative", affectsLayout = true },
gridRows = { type = "number", affectsLayout = true },
gridColumns = { type = "number", affectsLayout = true },
top = { type = "any", affectsLayout = true },
right = { type = "any", affectsLayout = true },
bottom = { type = "any", affectsLayout = true },
left = { type = "any", affectsLayout = true },
columnGap = { type = "any" },
rowGap = { type = "any" },
flex = { type = "any" }, -- shorthand: expands to flexGrow/flexShrink/flexBasis
flexGrow = { type = "number", default = 0, validator = rangeValidator(0, math.huge) },
flexShrink = { type = "number", default = 1, validator = rangeValidator(0, math.huge) },
flexBasis = { type = "any", default = "auto" },
alignSelf = { type = "string", default = "auto" },
justifySelf = { type = "string" },
z = { type = "number", default = 0 },
tabIndex = { type = "number" },
-- ------------------------------------------------------------------ border / background / visual
border = { type = "any", normalizer = normalizeBorder },
borderColor = { type = "color" }, -- default Color.new(0,0,0,1) via special handler
backgroundColor = { type = "color" }, -- default transparent via special handler
opacity = {
type = "number",
default = 1,
validator = rangeValidator(0, 1),
},
visibility = { type = "string", default = "visible" },
display = {
type = "boolean",
default = true,
validator = booleanValidator,
},
transform = { type = "table" },
cornerRadius = { type = "any", normalizer = normalizeCornerRadius },
-- ------------------------------------------------------------------ image
imagePath = { type = "string" },
image = { type = "table" },
objectFit = {
type = "string",
default = "fill",
validator = enumValidator({ "fill", "contain", "cover", "scale-down", "none" }),
},
objectPosition = { type = "string", default = "center center" },
imageOpacity = {
type = "number",
default = 1,
validator = rangeValidator(0, 1),
},
imageRepeat = {
type = "string",
default = "no-repeat",
validator = enumValidator({
"no-repeat",
"repeat",
"repeat-x",
"repeat-y",
"space",
"round",
}),
},
imageTint = { type = "color" },
-- ------------------------------------------------------------------ scroll / scrollbar
overflow = { type = "string" },
overflowX = { type = "string" },
overflowY = { type = "string" },
scrollbarWidth = { type = "number" },
scrollbarColor = { type = "color" },
scrollbarTrackColor = { type = "color" },
scrollbarRadius = { type = "number" },
scrollbarPadding = { type = "number" },
scrollSpeed = { type = "number" },
invertScroll = { type = "boolean" },
smoothScrollEnabled = { type = "boolean" },
scrollBarStyle = { type = "string" },
scrollbarKnobOffset = { type = "number" },
hideScrollbars = { type = "boolean" },
scrollbarPlacement = { type = "string" },
scrollbarBalance = { type = "number" },
_scrollX = { type = "number", storageKey = "_scrollX" },
_scrollY = { type = "number", storageKey = "_scrollY" },
-- ------------------------------------------------------------------ select
selectParent = { type = "table" },
selectOption = { type = "table" },
-- ------------------------------------------------------------------ transition
transition = { type = "table", default = {} },
})
end
--- (Re)populate the default schema. Idempotent: safe to call from Element.init
--- for build profiles that re-require the module. Returns the live registry.
---@return table registry
function PropertySchema.populate()
defineDefaults()
return registry
end
-- Auto-populate on require so the registry is ready without an explicit init call
-- (pure module, no external deps — safe at load time).
PropertySchema.populate()
return PropertySchema