Skip to content

Commit 2ca7ad1

Browse files
committed
- Added comprehensive state management with conditional states, priority-based resolution, and property overrides
- Added responsive.lua with fluent builder API (:when()/:apply()/:otherwise()) for creating responsive layouts that react to parent size or custom conditions - All elements now use getResolved() to check active states, enabling multiple responsive rules to coexist
1 parent 083a3b0 commit 2ca7ad1

37 files changed

Lines changed: 1050 additions & 740 deletions

src/elements/Accordion.lua

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -174,14 +174,14 @@ end
174174
--- @param expanded boolean Whether the panel starts expanded (default: false)
175175
--- @return table panelContainer The container for this panel
176176
function Accordion:newPanel(title, expanded)
177-
local panels = self.get("panels") or {}
177+
local panels = self.getResolved("panels") or {}
178178
local panelId = #panels + 1
179179

180180
local panelContainer = self:addContainer()
181181
panelContainer.set("x", 1)
182182
panelContainer.set("y", 1)
183-
panelContainer.set("width", self.get("width"))
184-
panelContainer.set("height", self.get("height"))
183+
panelContainer.set("width", self.getResolved("width"))
184+
panelContainer.set("height", self.getResolved("height"))
185185
panelContainer.set("visible", expanded or false)
186186
panelContainer.set("ignoreOffset", true)
187187

@@ -202,11 +202,11 @@ Accordion.addPanel = Accordion.newPanel
202202
--- @shortDescription Updates the layout of all panels (positions and visibility)
203203
--- @private
204204
function Accordion:updatePanelLayout()
205-
local panels = self.get("panels") or {}
206-
local headerHeight = self.get("panelHeaderHeight") or 1
205+
local panels = self.getResolved("panels") or {}
206+
local headerHeight = self.getResolved("panelHeaderHeight") or 1
207207
local currentY = 1
208-
local width = self.get("width")
209-
local accordionHeight = self.get("height")
208+
local width = self.getResolved("width")
209+
local accordionHeight = self.getResolved("height")
210210

211211
for _, panel in ipairs(panels) do
212212
local contentY = currentY + headerHeight
@@ -238,7 +238,7 @@ function Accordion:updatePanelLayout()
238238

239239
local totalHeight = currentY - 1
240240
local maxOffset = math.max(0, totalHeight - accordionHeight)
241-
local currentOffset = self.get("offsetY")
241+
local currentOffset = self.getResolved("offsetY")
242242

243243
if currentOffset > maxOffset then
244244
self.set("offsetY", maxOffset)
@@ -251,8 +251,8 @@ end
251251
--- @param panelId number The ID of the panel to toggle
252252
--- @return Accordion self For method chaining
253253
function Accordion:togglePanel(panelId)
254-
local panels = self.get("panels") or {}
255-
local allowMultiple = self.get("allowMultiple")
254+
local panels = self.getResolved("panels") or {}
255+
local allowMultiple = self.getResolved("allowMultiple")
256256

257257
for i, panel in ipairs(panels) do
258258
if panel.id == panelId then
@@ -279,8 +279,8 @@ end
279279
--- @param panelId number The ID of the panel to expand
280280
--- @return Accordion self For method chaining
281281
function Accordion:expandPanel(panelId)
282-
local panels = self.get("panels") or {}
283-
local allowMultiple = self.get("allowMultiple")
282+
local panels = self.getResolved("panels") or {}
283+
local allowMultiple = self.getResolved("allowMultiple")
284284

285285
for i, panel in ipairs(panels) do
286286
if panel.id == panelId then
@@ -309,7 +309,7 @@ end
309309
--- @param panelId number The ID of the panel to collapse
310310
--- @return Accordion self For method chaining
311311
function Accordion:collapsePanel(panelId)
312-
local panels = self.get("panels") or {}
312+
local panels = self.getResolved("panels") or {}
313313

314314
for _, panel in ipairs(panels) do
315315
if panel.id == panelId then
@@ -329,7 +329,7 @@ end
329329
--- @param panelId number The ID of the panel
330330
--- @return table? container The panel's container or nil
331331
function Accordion:getPanel(panelId)
332-
local panels = self.get("panels") or {}
332+
local panels = self.getResolved("panels") or {}
333333
for _, panel in ipairs(panels) do
334334
if panel.id == panelId then
335335
return panel.container
@@ -342,8 +342,8 @@ end
342342
--- @return table metrics Panel layout information
343343
--- @private
344344
function Accordion:_getPanelMetrics()
345-
local panels = self.get("panels") or {}
346-
local headerHeight = self.get("panelHeaderHeight") or 1
345+
local panels = self.getResolved("panels") or {}
346+
local headerHeight = self.getResolved("panelHeaderHeight") or 1
347347

348348
local positions = {}
349349
local currentY = 1
@@ -381,7 +381,7 @@ function Accordion:mouse_click(button, x, y)
381381
end
382382

383383
local relX, relY = VisualElement.getRelativePosition(self, x, y)
384-
local offsetY = self.get("offsetY")
384+
local offsetY = self.getResolved("offsetY")
385385
local adjustedY = relY + offsetY
386386
local metrics = self:_getPanelMetrics()
387387

@@ -400,12 +400,12 @@ end
400400
function Accordion:mouse_scroll(direction, x, y)
401401
if VisualElement.mouse_scroll(self, direction, x, y) then
402402
local metrics = self:_getPanelMetrics()
403-
local accordionHeight = self.get("height")
403+
local accordionHeight = self.getResolved("height")
404404
local totalHeight = metrics.totalHeight
405405
local maxOffset = math.max(0, totalHeight - accordionHeight)
406406

407407
if maxOffset > 0 then
408-
local currentOffset = self.get("offsetY")
408+
local currentOffset = self.getResolved("offsetY")
409409
local newOffset = currentOffset + direction
410410
newOffset = math.max(0, math.min(maxOffset, newOffset))
411411
self.set("offsetY", newOffset)
@@ -422,17 +422,17 @@ end
422422
function Accordion:render()
423423
VisualElement.render(self)
424424

425-
local width = self.get("width")
426-
local offsetY = self.get("offsetY")
425+
local width = self.getResolved("width")
426+
local offsetY = self.getResolved("offsetY")
427427
local metrics = self:_getPanelMetrics()
428428

429429
for _, panelInfo in ipairs(metrics.positions) do
430-
local bgColor = panelInfo.expanded and self.get("expandedHeaderBackground") or self.get("headerBackground")
431-
local fgColor = panelInfo.expanded and self.get("expandedHeaderTextColor") or self.get("headerTextColor")
430+
local bgColor = panelInfo.expanded and self.getResolved("expandedHeaderBackground") or self.getResolved("headerBackground")
431+
local fgColor = panelInfo.expanded and self.getResolved("expandedHeaderTextColor") or self.getResolved("headerTextColor")
432432

433433
local headerY = panelInfo.headerY - offsetY
434434

435-
if headerY >= 1 and headerY <= self.get("height") then
435+
if headerY >= 1 and headerY <= self.getResolved("height") then
436436
VisualElement.multiBlit(
437437
self,
438438
1,
@@ -450,16 +450,16 @@ function Accordion:render()
450450
end
451451
end
452452

453-
if not self.get("childrenSorted") then
453+
if not self.getResolved("childrenSorted") then
454454
self:sortChildren()
455455
end
456-
if not self.get("childrenEventsSorted") then
456+
if not self.getResolved("childrenEventsSorted") then
457457
for eventName in pairs(self._values.childrenEvents or {}) do
458458
self:sortChildrenEvents(eventName)
459459
end
460460
end
461461

462-
for _, child in ipairs(self.get("visibleChildren") or {}) do
462+
for _, child in ipairs(self.getResolved("visibleChildren") or {}) do
463463
if child == self then
464464
error("CIRCULAR REFERENCE DETECTED!")
465465
return

src/elements/BarChart.lua

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,11 @@ end
5454
function BarChart:render()
5555
VisualElement.render(self)
5656

57-
local width = self.get("width")
58-
local height = self.get("height")
59-
local minVal = self.get("minValue")
60-
local maxVal = self.get("maxValue")
61-
local series = self.get("series")
57+
local width = self.getResolved("width")
58+
local height = self.getResolved("height")
59+
local minVal = self.getResolved("minValue")
60+
local maxVal = self.getResolved("maxValue")
61+
local series = self.getResolved("series")
6262

6363
local activeSeriesCount = 0
6464
local seriesList = {}

src/elements/BaseElement.lua

Lines changed: 143 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ end
225225
--- @param priority? number Optional priority override
226226
--- @return BaseElement self
227227
function BaseElement:setState(stateName, priority)
228-
local states = self.get("states")
228+
local states = self.getResolved("states")
229229

230230
if not priority and self._registeredStates[stateName] then
231231
priority = self._registeredStates[stateName].priority
@@ -309,6 +309,145 @@ function BaseElement:updateConditionalStates()
309309
return self
310310
end
311311

312+
--- Registers a responsive state that reacts to parent size changes
313+
--- @shortDescription Registers a state that responds to parent dimensions
314+
--- @param stateName string The name of the state
315+
--- @param condition string|function Condition as string expression or function: function(element) return boolean end
316+
--- @param options? table|number Options table with 'priority' and 'observe', or just priority number
317+
--- @return BaseElement self
318+
function BaseElement:registerResponsiveState(stateName, condition, options)
319+
local priority = 100
320+
local observeList = {}
321+
if type(options) == "number" then
322+
priority = options
323+
elseif type(options) == "table" then
324+
priority = options.priority or 100
325+
observeList = options.observe or {}
326+
end
327+
328+
local conditionFunc
329+
local isStringExpr = type(condition) == "string"
330+
331+
if isStringExpr then
332+
conditionFunc = self:_parseResponsiveExpression(condition)
333+
334+
local autoDeps = self:_detectDependencies(condition)
335+
for _, dep in ipairs(autoDeps) do
336+
table.insert(observeList, dep)
337+
end
338+
else
339+
conditionFunc = condition
340+
end
341+
self:registerState(stateName, conditionFunc, priority)
342+
343+
for _, observeInfo in ipairs(observeList) do
344+
local element = observeInfo.element or observeInfo[1]
345+
local property = observeInfo.property or observeInfo[2]
346+
if element and property then
347+
element:observe(property, function()
348+
self:updateConditionalStates()
349+
end)
350+
end
351+
end
352+
self:updateConditionalStates()
353+
354+
return self
355+
end
356+
357+
--- Parses a responsive expression string into a function
358+
--- @private
359+
--- @param expr string The expression to parse
360+
--- @return function conditionFunc The parsed condition function
361+
function BaseElement:_parseResponsiveExpression(expr)
362+
local protectedNames = {
363+
colors = true,
364+
math = true,
365+
clamp = true,
366+
round = true
367+
}
368+
369+
local mathEnv = {
370+
clamp = function(val, min, max)
371+
return math.min(math.max(val, min), max)
372+
end,
373+
round = function(val)
374+
return math.floor(val + 0.5)
375+
end,
376+
floor = math.floor,
377+
ceil = math.ceil,
378+
abs = math.abs
379+
}
380+
381+
expr = expr:gsub("([%w_]+)%.([%w_]+)", function(obj, prop)
382+
if protectedNames[obj] or tonumber(obj) then
383+
return obj.."."..prop
384+
end
385+
return string.format('__getProperty("%s", "%s")', obj, prop)
386+
end)
387+
388+
local element = self
389+
local env = setmetatable({
390+
colors = colors,
391+
math = math,
392+
tostring = tostring,
393+
tonumber = tonumber,
394+
__getProperty = function(objName, propName)
395+
if objName == "self" then
396+
if element._properties[propName] then
397+
return element.get(propName)
398+
end
399+
elseif objName == "parent" then
400+
if element.parent and element.parent._properties[propName] then
401+
return element.parent.get(propName)
402+
end
403+
else
404+
local target = element:getBaseFrame():getChild(objName)
405+
if target and target._properties[propName] then
406+
return target.get(propName)
407+
end
408+
end
409+
return nil
410+
end
411+
}, { __index = mathEnv })
412+
413+
local func, err = load("return "..expr, "responsive", "t", env)
414+
if not func then
415+
error("Invalid responsive expression: " .. err)
416+
end
417+
418+
return function(self)
419+
local ok, result = pcall(func)
420+
return ok and result or false
421+
end
422+
end
423+
424+
--- Detects dependencies in a responsive expression
425+
--- @private
426+
--- @param expr string The expression to analyze
427+
--- @return table dependencies List of {element, property} pairs
428+
function BaseElement:_detectDependencies(expr)
429+
local deps = {}
430+
local protectedNames = {colors = true, math = true, clamp = true, round = true}
431+
432+
for ref, prop in expr:gmatch("([%w_]+)%.([%w_]+)") do
433+
if not protectedNames[ref] and not tonumber(ref) then
434+
local element
435+
if ref == "self" then
436+
element = self
437+
elseif ref == "parent" then
438+
element = self.parent
439+
else
440+
element = self:getBaseFrame():getChild(ref)
441+
end
442+
443+
if element then
444+
table.insert(deps, {element = element, property = prop})
445+
end
446+
end
447+
end
448+
return deps
449+
end
450+
312451
--- Removes a state from the registry
313452
--- @shortDescription Removes state definition
314453
--- @param stateName string The state to remove
@@ -325,9 +464,9 @@ end
325464
--- @param ... any Additional arguments to pass to the callbacks
326465
--- @return table self The BaseElement instance
327466
function BaseElement:fireEvent(event, ...)
328-
if self.get("eventCallbacks")[event] then
467+
if self.getResolved("eventCallbacks")[event] then
329468
local lastResult
330-
for _, callback in ipairs(self.get("eventCallbacks")[event]) do
469+
for _, callback in ipairs(self.getResolved("eventCallbacks")[event]) do
331470
lastResult = callback(self, ...)
332471
end
333472
return lastResult
@@ -341,7 +480,7 @@ end
341480
--- @return boolean? handled Whether the event was handled
342481
--- @protected
343482
function BaseElement:dispatchEvent(event, ...)
344-
if self.get("enabled") == false then
483+
if self.getResolved("enabled") == false then
345484
return false
346485
end
347486
if self[event] then

src/elements/BigFont.lua

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,12 @@ BigFont.__index = BigFont
171171

172172
---@property text string BigFont The text string to display in enlarged format
173173
BigFont.defineProperty(BigFont, "text", {default = "BigFont", type = "string", canTriggerRender = true, setter=function(self, value)
174-
self.bigfontText = makeText(self.get("fontSize"), value, self.get("foreground"), self.get("background"))
174+
self.bigfontText = makeText(self.getResolved("fontSize"), value, self.getResolved("foreground"), self.getResolved("background"))
175175
return value
176176
end})
177177
---@property fontSize number 1 Scale factor for text size (1-3, where 1 is 3x3 pixels per character)
178178
BigFont.defineProperty(BigFont, "fontSize", {default = 1, type = "number", canTriggerRender = true, setter=function(self, value)
179-
self.bigfontText = makeText(value, self.get("text"), self.get("foreground"), self.get("background"))
179+
self.bigfontText = makeText(value, self.getResolved("text"), self.getResolved("foreground"), self.getResolved("background"))
180180
return value
181181
end})
182182

@@ -200,10 +200,10 @@ function BigFont:init(props, basalt)
200200
VisualElement.init(self, props, basalt)
201201
self.set("type", "BigFont")
202202
self:observe("background", function(self, value)
203-
self.bigfontText = makeText(self.get("fontSize"), self.get("text"), self.get("foreground"), value)
203+
self.bigfontText = makeText(self.getResolved("fontSize"), self.getResolved("text"), self.getResolved("foreground"), value)
204204
end)
205205
self:observe("foreground", function(self, value)
206-
self.bigfontText = makeText(self.get("fontSize"), self.get("text"), value, self.get("background"))
206+
self.bigfontText = makeText(self.getResolved("fontSize"), self.getResolved("text"), value, self.getResolved("background"))
207207
end)
208208
end
209209

@@ -212,11 +212,12 @@ end
212212
function BigFont:render()
213213
VisualElement.render(self)
214214
if(self.bigfontText)then
215-
local x, y = self.get("x"), self.get("y")
215+
local x, y = self.getResolved("x"), self.getResolved("y")
216+
local width = self.getResolved("width")
216217
for i = 1, #self.bigfontText[1] do
217-
local text = self.bigfontText[1][i]:sub(1, self.get("width"))
218-
local fg = self.bigfontText[2][i]:sub(1, self.get("width"))
219-
local bg = self.bigfontText[3][i]:sub(1, self.get("width"))
218+
local text = self.bigfontText[1][i]:sub(1, width)
219+
local fg = self.bigfontText[2][i]:sub(1, width)
220+
local bg = self.bigfontText[3][i]:sub(1, width)
220221
self:blit(x, y + i - 1, text, fg, bg)
221222
end
222223
end

0 commit comments

Comments
 (0)