- Added a new system to dynamically require source from multiple locations (including web)

- Added the Collection Element and moved parts of the List logic to collection
- Added a State Management System
- Added a better system to change the position/size of elements
- Removed the state plugin
This commit is contained in:
Robert Jelic
2025-10-27 08:25:58 +01:00
parent 565209d63e
commit b96875a3e9
19 changed files with 1699 additions and 521 deletions

4
.gitignore vendored
View File

@@ -15,4 +15,6 @@ Accordion.lua
Stepper.lua
Drawer.lua
Breadcrumb.lua
Dialog.lua
Dialog.lua
DockLayout.lua
ContextMenu.lua

View File

@@ -17,6 +17,16 @@ local ElementManager = {}
ElementManager._elements = {}
ElementManager._plugins = {}
ElementManager._APIs = {}
ElementManager._config = {
autoLoadMissing = false,
allowRemoteLoading = false,
allowDiskLoading = true,
remoteSources = {},
diskMounts = {},
useGlobalCache = false,
globalCacheName = "_BASALT_ELEMENT_CACHE"
}
local elementsDirectory = fs.combine(dir, "elements")
local pluginsDirectory = fs.combine(dir, "plugins")
@@ -29,7 +39,9 @@ if fs.exists(elementsDirectory) then
ElementManager._elements[name] = {
class = nil,
plugins = {},
loaded = false
loaded = false,
source = "local",
path = nil
}
end
end
@@ -66,7 +78,9 @@ if(minified)then
ElementManager._elements[name:gsub(".lua", "")] = {
class = nil,
plugins = {},
loaded = false
loaded = false,
source = "local",
path = nil
}
end
if(minified_pluginDirectory==nil)then
@@ -90,20 +104,225 @@ if(minified)then
end
end
local function saveToGlobalCache(name, element)
if not ElementManager._config.useGlobalCache then return end
if not _G[ElementManager._config.globalCacheName] then
_G[ElementManager._config.globalCacheName] = {}
end
_G[ElementManager._config.globalCacheName][name] = element
log.debug("Cached element in _G: "..name)
end
local function loadFromGlobalCache(name)
if not ElementManager._config.useGlobalCache then return nil end
if _G[ElementManager._config.globalCacheName] and
_G[ElementManager._config.globalCacheName][name] then
log.debug("Loaded element from _G cache: "..name)
return _G[ElementManager._config.globalCacheName][name]
end
return nil
end
--- Configures the ElementManager
--- @param config table Configuration options
function ElementManager.configure(config)
for k, v in pairs(config) do
if ElementManager._config[k] ~= nil then
ElementManager._config[k] = v
end
end
end
--- Registers a disk mount point for loading elements
--- @param mountPath string The path to the disk mount
function ElementManager.registerDiskMount(mountPath)
if not fs.exists(mountPath) then
error("Disk mount path does not exist: "..mountPath)
end
table.insert(ElementManager._config.diskMounts, mountPath)
log.info("Registered disk mount: "..mountPath)
local elementsPath = fs.combine(mountPath, "elements")
if fs.exists(elementsPath) then
for _, file in ipairs(fs.list(elementsPath)) do
local name = file:match("(.+).lua")
if name then
if not ElementManager._elements[name] then
log.debug("Found element on disk: "..name)
ElementManager._elements[name] = {
class = nil,
plugins = {},
loaded = false,
source = "disk",
path = fs.combine(elementsPath, file)
}
end
end
end
end
end
--- Registers a remote source for an element
--- @param elementName string The name of the element
--- @param url string The URL to load the element from
function ElementManager.registerRemoteSource(elementName, url)
if not ElementManager._config.allowRemoteLoading then
error("Remote loading is disabled. Enable with ElementManager.configure({allowRemoteLoading = true})")
end
ElementManager._config.remoteSources[elementName] = url
if not ElementManager._elements[elementName] then
ElementManager._elements[elementName] = {
class = nil,
plugins = {},
loaded = false,
source = "remote",
path = url
}
else
ElementManager._elements[elementName].source = "remote"
ElementManager._elements[elementName].path = url
end
log.info("Registered remote source for "..elementName..": "..url)
end
local function loadFromRemote(url)
if not http then
error("HTTP API is not available. Enable it in your CC:Tweaked config.")
end
log.info("Loading element from remote: "..url)
local response = http.get(url)
if not response then
error("Failed to download from: "..url)
end
local content = response.readAll()
response.close()
if not content or content == "" then
error("Empty response from: "..url)
end
local func, err = load(content, url, "t", _ENV)
if not func then
error("Failed to load element from "..url..": "..tostring(err))
end
local element = func()
return element
end
local function loadFromDisk(path)
if not fs.exists(path) then
error("Element file does not exist: "..path)
end
log.info("Loading element from disk: "..path)
local func, err = loadfile(path)
if not func then
error("Failed to load element from "..path..": "..tostring(err))
end
local element = func()
return element
end
--- Tries to load an element from any available source
--- @param name string The element name
--- @return boolean success Whether the element was loaded
function ElementManager.tryAutoLoad(name)
-- Try disk mounts first
if ElementManager._config.allowDiskLoading then
for _, mountPath in ipairs(ElementManager._config.diskMounts) do
local elementsPath = fs.combine(mountPath, "elements")
local filePath = fs.combine(elementsPath, name..".lua")
if fs.exists(filePath) then
ElementManager._elements[name] = {
class = nil,
plugins = {},
loaded = false,
source = "disk",
path = filePath
}
ElementManager.loadElement(name)
return true
end
end
end
if ElementManager._config.allowRemoteLoading and ElementManager._config.remoteSources[name] then
ElementManager.loadElement(name)
return true
end
return false
end
--- Loads an element by name. This will load the element and apply any plugins to it.
--- @param name string The name of the element to load
--- @usage ElementManager.loadElement("Button")
function ElementManager.loadElement(name)
if not ElementManager._elements[name] then
-- Try to auto-load if enabled
if ElementManager._config.autoLoadMissing then
local success = ElementManager.tryAutoLoad(name)
if not success then
error("Element '"..name.."' not found and could not be auto-loaded")
end
else
error("Element '"..name.."' not found")
end
end
if not ElementManager._elements[name].loaded then
package.path = main.."rom/?"
local element = require(fs.combine("elements", name))
package.path = defaultPath
local source = ElementManager._elements[name].source or "local"
local element
local loadedFromCache = false
element = loadFromGlobalCache(name)
if element then
loadedFromCache = true
log.info("Loaded element from _G cache: "..name)
elseif source == "local" then
package.path = main.."rom/?"
element = require(fs.combine("elements", name))
package.path = defaultPath
elseif source == "disk" then
if not ElementManager._config.allowDiskLoading then
error("Disk loading is disabled for element: "..name)
end
element = loadFromDisk(ElementManager._elements[name].path)
saveToGlobalCache(name, element)
elseif source == "remote" then
if not ElementManager._config.allowRemoteLoading then
error("Remote loading is disabled for element: "..name)
end
element = loadFromRemote(ElementManager._elements[name].path)
saveToGlobalCache(name, element)
else
error("Unknown source type: "..source)
end
ElementManager._elements[name] = {
class = element,
plugins = element.plugins,
loaded = true
loaded = true,
source = loadedFromCache and "cache" or source,
path = ElementManager._elements[name].path
}
log.debug("Loaded element: "..name)
if not loadedFromCache then
log.debug("Loaded element: "..name.." from "..source)
end
if(ElementManager._plugins[name]~=nil)then
for _, plugin in pairs(ElementManager._plugins[name]) do
@@ -148,6 +367,17 @@ end
--- @param name string The name of the element to get
--- @return table Element The element class
function ElementManager.getElement(name)
if not ElementManager._elements[name] then
if ElementManager._config.autoLoadMissing then
local success = ElementManager.tryAutoLoad(name)
if not success then
error("Element '"..name.."' not found")
end
else
error("Element '"..name.."' not found")
end
end
if not ElementManager._elements[name].loaded then
ElementManager.loadElement(name)
end
@@ -167,4 +397,55 @@ function ElementManager.getAPI(name)
return ElementManager._APIs[name]
end
--- Checks if an element exists (is registered)
--- @param name string The element name
--- @return boolean exists Whether the element exists
function ElementManager.hasElement(name)
return ElementManager._elements[name] ~= nil
end
--- Checks if an element is loaded
--- @param name string The element name
--- @return boolean loaded Whether the element is loaded
function ElementManager.isElementLoaded(name)
return ElementManager._elements[name] and ElementManager._elements[name].loaded or false
end
--- Clears the global cache (_G)
--- @usage ElementManager.clearGlobalCache()
function ElementManager.clearGlobalCache()
if _G[ElementManager._config.globalCacheName] then
_G[ElementManager._config.globalCacheName] = nil
log.info("Cleared global element cache")
end
end
--- Gets cache statistics
--- @return table stats Cache statistics with size and element names
function ElementManager.getCacheStats()
if not _G[ElementManager._config.globalCacheName] then
return {size = 0, elements = {}}
end
local elements = {}
for name, _ in pairs(_G[ElementManager._config.globalCacheName]) do
table.insert(elements, name)
end
return {
size = #elements,
elements = elements
}
end
--- Preloads elements into the global cache
--- @param elementNames table List of element names to preload
function ElementManager.preloadElements(elementNames)
for _, name in ipairs(elementNames) do
if ElementManager._elements[name] and not ElementManager._elements[name].loaded then
ElementManager.loadElement(name)
end
end
end
return ElementManager

View File

@@ -34,6 +34,13 @@ BaseElement.defineProperty(BaseElement, "eventCallbacks", {default = {}, type =
--- @property enabled boolean BaseElement Controls event processing for this element
BaseElement.defineProperty(BaseElement, "enabled", {default = true, type = "boolean" })
--- @property states table {} Table of currently active states with their priorities
BaseElement.defineProperty(BaseElement, "states", {
default = {},
type = "table",
canTriggerRender = true
})
--- Registers a class-level event listener with optional dependency
--- @shortDescription Registers a new event listener for the element (on class level)
--- @param class table The class to register
@@ -93,6 +100,7 @@ function BaseElement:init(props, basalt)
self._values.id = uuid()
self.basalt = basalt
self._registeredEvents = {}
self._registeredStates = {}
local currentClass = getmetatable(self).__index
@@ -197,6 +205,120 @@ function BaseElement:registerCallback(event, callback)
return self
end
--- Registers a new state with optional auto-condition
--- @shortDescription Registers a state
--- @param stateName string The name of the state
--- @param condition? function Optional: Function that returns true if state is active: function(element) return boolean end
--- @param priority? number Priority (higher = more important, default: 0)
--- @return BaseElement self The BaseElement instance
function BaseElement:registerState(stateName, condition, priority)
self._registeredStates[stateName] = {
condition = condition,
priority = priority or 0
}
return self
end
--- Manually activates a state
--- @shortDescription Activates a state
--- @param stateName string The state to activate
--- @param priority? number Optional priority override
--- @return BaseElement self
function BaseElement:setState(stateName, priority)
local states = self.get("states")
if not priority and self._registeredStates[stateName] then
priority = self._registeredStates[stateName].priority
end
states[stateName] = priority or 0
self.set("states", states)
return self
end
--- Manually deactivates a state
--- @shortDescription Deactivates a state
--- @param stateName string The state to deactivate
--- @return BaseElement self
function BaseElement:unsetState(stateName)
local states = self.get("states")
if states[stateName] ~= nil then
states[stateName] = nil
self.set("states", states)
end
return self
end
--- Checks if a state is currently active
--- @shortDescription Checks if state is active
--- @param stateName string The state to check
--- @return boolean isActive
function BaseElement:hasState(stateName)
local states = self.get("states")
return states[stateName] ~= nil
end
--- Gets the highest priority active state
--- @shortDescription Gets current primary state
--- @return string|nil currentState The state with highest priority
function BaseElement:getCurrentState()
local states = self.get("states")
local highestPriority = -math.huge
local currentState = nil
for stateName, priority in pairs(states) do
if priority > highestPriority then
highestPriority = priority
currentState = stateName
end
end
return currentState
end
--- Gets all currently active states sorted by priority
--- @shortDescription Gets all active states
--- @return table states Array of {name, priority} sorted by priority
function BaseElement:getActiveStates()
local states = self.get("states")
local result = {}
for stateName, priority in pairs(states) do
table.insert(result, {name = stateName, priority = priority})
end
table.sort(result, function(a, b) return a.priority > b.priority end)
return result
end
--- Updates all states that have auto-conditions
--- @shortDescription Updates conditional states
--- @return BaseElement self
function BaseElement:updateConditionalStates()
for stateName, stateInfo in pairs(self._registeredStates) do
if stateInfo.condition then
if stateInfo.condition(self) then
self:setState(stateName, stateInfo.priority)
else
self:unsetState(stateName)
end
end
end
return self
end
--- Removes a state from the registry
--- @shortDescription Removes state definition
--- @param stateName string The state to remove
--- @return BaseElement self
function BaseElement:unregisterState(stateName)
self._stateRegistry[stateName] = nil
self:unsetState(stateName)
return self
end
--- Executes all registered callbacks for the specified event
--- @shortDescription Triggers event callbacks with provided arguments
--- @param event string The event to fire

View File

@@ -59,10 +59,10 @@ end
--- @protected
function Button:render()
VisualElement.render(self)
local text = self.get("text")
local text = self.getResolved("text")
text = text:sub(1, self.get("width"))
local xO, yO = getCenteredPosition(text, self.get("width"), self.get("height"))
self:textFg(xO, yO, text, self.get("foreground"))
self:textFg(xO, yO, text, self.getResolved("foreground"))
end
return Button

View File

@@ -83,12 +83,12 @@ end
function CheckBox:render()
VisualElement.render(self)
local checked = self.get("checked")
local defaultText = self.get("text")
local checkedText = self.get("checkedText")
local checked = self.getResolved("checked")
local defaultText = self.getResolved("text")
local checkedText = self.getResolved("checkedText")
local text = string.sub(checked and checkedText or defaultText, 1, self.get("width"))
self:textFg(1, 1, text, self.get("foreground"))
self:textFg(1, 1, text, self.getResolved("foreground"))
end
return CheckBox

182
src/elements/Collection.lua Normal file
View File

@@ -0,0 +1,182 @@
local VisualElement = require("elements/VisualElement")
local CollectionEntry = require("libraries/collectionentry")
---@configDescription A collection of items
--- This is the Collection class. It provides a collection of items
---@class Collection : VisualElement
local Collection = setmetatable({}, VisualElement)
Collection.__index = Collection
Collection.defineProperty(Collection, "items", {default={}, type = "table"})
---@property selectable boolean true Whether items can be selected
Collection.defineProperty(Collection, "selectable", {default = true, type = "boolean"})
---@property multiSelection boolean false Whether multiple items can be selected at once
Collection.defineProperty(Collection, "multiSelection", {default = false, type = "boolean"})
---@property selectedBackground color blue Background color for selected items
Collection.defineProperty(Collection, "selectedBackground", {default = colors.blue, type = "color"})
---@property selectedForeground color white Text color for selected items
Collection.defineProperty(Collection, "selectedForeground", {default = colors.white, type = "color"})
---@event onSelect {index number, item table} Fired when an item is selected
--- Creates a new Collection instance
--- @shortDescription Creates a new Collection instance
--- @return Collection self The newly created Collection instance
--- @private
function Collection.new()
local self = setmetatable({}, Collection):__init()
self.class = Collection
return self
end
--- @shortDescription Initializes the Collection instance
--- @param props table The properties to initialize the element with
--- @param basalt table The basalt instance
--- @return Collection self The initialized instance
--- @protected
function Collection:init(props, basalt)
VisualElement.init(self, props, basalt)
self._entrySchema = {}
self.set("type", "Collection")
return self
end
--- Adds an item to the Collection
--- @shortDescription Adds an item to the Collection
--- @param text string|table The item to add (string or item table)
--- @return Collection self The Collection instance
--- @usage Collection:addItem("New Item")
--- @usage Collection:addItem({text="Item", callback=function() end})
function Collection:addItem(itemData)
if type(itemData) == "string" then
itemData = {text = itemData}
end
if itemData.selected == nil then
itemData.selected = false
end
local entry = CollectionEntry.new(self, itemData, self._entrySchema)
table.insert(self.get("items"), entry)
self:updateRender()
return entry
end
--- Removes an item from the Collection
--- @shortDescription Removes an item from the Collection
--- @param index number The index of the item to remove
--- @return Collection self The Collection instance
--- @usage Collection:removeItem(1)
function Collection:removeItem(index)
local items = self.get("items")
if type(index) == "number" then
table.remove(items, index)
else
for k,v in pairs(items)do
if v == index then
table.remove(items, k)
break
end
end
end
self:updateRender()
return self
end
--- Clears all items from the Collection
--- @shortDescription Clears all items from the Collection
--- @return Collection self The Collection instance
--- @usage Collection:clear()
function Collection:clear()
self.set("items", {})
self:updateRender()
return self
end
-- Gets the currently selected items
--- @shortDescription Gets the currently selected items
--- @return table selected Collection of selected items
--- @usage local selected = Collection:getSelectedItems()
function Collection:getSelectedItems()
local selected = {}
for i, item in ipairs(self.get("items")) do
if type(item) == "table" and item.selected then
local selectedItem = item
selectedItem.index = i
table.insert(selected, selectedItem)
end
end
return selected
end
--- Gets first selected item
--- @shortDescription Gets first selected item
--- @return table? selected The first item
function Collection:getSelectedItem()
local items = self.get("items")
for i, item in ipairs(items) do
if type(item) == "table" and item.selected then
return item
end
end
return nil
end
function Collection:selectItem(index)
local items = self.get("items")
if type(index) == "number" then
if items[index] and type(items[index]) == "table" then
items[index].selected = true
end
else
for k,v in pairs(items)do
if v == index then
if type(v) == "table" then
v.selected = true
end
break
end
end
end
self:updateRender()
return self
end
function Collection:unselectItem(index)
local items = self.get("items")
if type(index) == "number" then
if items[index] and type(items[index]) == "table" then
items[index].selected = false
end
else
for k,v in pairs(items)do
if v == index then
if type(items[k]) == "table" then
items[k].selected = false
end
break
end
end
end
self:updateRender()
return self
end
function Collection:clearItemSelection()
local items = self.get("items")
for i, item in ipairs(items) do
item.selected = false
end
return self
end
--- Registers a callback for the select event
--- @shortDescription Registers a callback for the select event
--- @param callback function The callback function to register
--- @return Collection self The Collection instance
--- @usage Collection:onSelect(function(index, item) print("Selected item:", index, item) end)
function Collection:onSelect(callback)
self:registerCallback("select", callback)
return self
end
return Collection

View File

@@ -32,7 +32,10 @@ ComboBox.__index = ComboBox
---@property editable boolean true Enables direct text input in the field
ComboBox.defineProperty(ComboBox, "editable", {default = true, type = "boolean", canTriggerRender = true})
---@property text string "" The current text value of the input field
ComboBox.defineProperty(ComboBox, "text", {default = "", type = "string", canTriggerRender = true})
ComboBox.defineProperty(ComboBox, "text", {default = "", type = "string", canTriggerRender = true, seetter = function(self, value)
self.set("cursorPos", #self.get("text") + 1)
self:updateViewport()
end})
---@property cursorPos number 1 Current cursor position in the text input
ComboBox.defineProperty(ComboBox, "cursorPos", {default = 1, type = "number"})
---@property viewOffset number 0 Horizontal scroll position for viewing long text
@@ -41,10 +44,6 @@ ComboBox.defineProperty(ComboBox, "viewOffset", {default = 0, type = "number", c
ComboBox.defineProperty(ComboBox, "placeholder", {default = "...", type = "string"})
---@property placeholderColor color gray Color used for placeholder text
ComboBox.defineProperty(ComboBox, "placeholderColor", {default = colors.gray, type = "color"})
---@property focusedBackground color blue Background color when input is focused
ComboBox.defineProperty(ComboBox, "focusedBackground", {default = colors.blue, type = "color"})
---@property focusedForeground color white Text color when input is focused
ComboBox.defineProperty(ComboBox, "focusedForeground", {default = colors.white, type = "color"})
---@property autoComplete boolean false Enables filtering dropdown items while typing
ComboBox.defineProperty(ComboBox, "autoComplete", {default = false, type = "boolean"})
---@property manuallyOpened boolean false Indicates if dropdown was opened by user action
@@ -73,35 +72,6 @@ function ComboBox:init(props, basalt)
self.set("cursorPos", 1)
self.set("viewOffset", 0)
return self
end
--- Sets the text content of the ComboBox
--- @shortDescription Sets the text content
--- @param text string The text to set
--- @return ComboBox self
function ComboBox:setText(text)
if text == nil then text = "" end
self.set("text", tostring(text))
self.set("cursorPos", #self.get("text") + 1)
self:updateViewport()
return self
end
--- Gets the current text content
--- @shortDescription Gets the text content
--- @return string text The current text
function ComboBox:getText()
return self.get("text")
end
--- Sets whether the ComboBox is editable
--- @shortDescription Sets editable state
--- @param editable boolean Whether the ComboBox should be editable
--- @return ComboBox self
function ComboBox:setEditable(editable)
self.set("editable", editable)
return self
end
@@ -143,13 +113,13 @@ function ComboBox:updateFilteredDropdown()
local shouldOpen = #filteredItems > 0 and #self.get("text") > 0
if shouldOpen then
self.set("isOpen", true)
self:setState("opened")
self.set("manuallyOpened", false)
local dropdownHeight = self.get("dropdownHeight") or 5
local actualHeight = math.min(dropdownHeight, #filteredItems)
self.set("height", 1 + actualHeight)
else
self.set("isOpen", false)
self:unsetState("opened")
self.set("manuallyOpened", false)
self.set("height", 1)
end
@@ -183,7 +153,7 @@ end
--- @param char string The character that was typed
function ComboBox:char(char)
if not self.get("editable") then return end
if not self.get("focused") then return end
if not self:hasState("focused") then return end
local text = self.get("text")
local cursorPos = self.get("cursorPos")
@@ -206,7 +176,7 @@ end
--- @param held boolean Whether the key is being held
function ComboBox:key(key, held)
if not self.get("editable") then return end
if not self.get("focused") then return end
if not self:hasState("focused") then return end
local text = self.get("text")
local cursorPos = self.get("cursorPos")
@@ -249,7 +219,11 @@ function ComboBox:key(key, held)
self.set("cursorPos", #text + 1)
self:updateViewport()
elseif key == keys.enter then
self.set("isOpen", not self.get("isOpen"))
if self:hasState("opened") then
self:unsetState("opened")
else
self:setState("opened")
end
self:updateRender()
end
end
@@ -267,14 +241,18 @@ function ComboBox:mouse_click(button, x, y)
local relX, relY = self:getRelativePosition(x, y)
local width = self.get("width")
local dropSymbol = self.get("dropSymbol")
local isOpen = self:hasState("opened")
if relY == 1 then
if relX >= width - #dropSymbol + 1 and relX <= width then
local isCurrentlyOpen = self.get("isOpen")
self.set("isOpen", not isCurrentlyOpen)
if isOpen then
self:unsetState("opened")
else
self:setState("opened")
end
if self.get("isOpen") then
if not isOpen then
local allItems = self.get("items") or {}
local dropdownHeight = self.get("dropdownHeight") or 5
local actualHeight = math.min(dropdownHeight, #allItems)
@@ -300,7 +278,7 @@ function ComboBox:mouse_click(button, x, y)
end
return true
elseif self.get("isOpen") and relY > 1 and self.get("selectable") then
elseif isOpen and relY > 1 and self.get("selectable") then
local itemIndex = (relY - 1) + self.get("offset")
local items = self.get("items")
@@ -324,7 +302,7 @@ function ComboBox:mouse_click(button, x, y)
if item.text then
self:setText(item.text)
end
self.set("isOpen", false)
self:unsetState("opened")
self.set("height", 1)
self:updateRender()
@@ -337,19 +315,19 @@ end
--- Renders the ComboBox
--- @shortDescription Renders the ComboBox
--- @protected
function ComboBox:render()
VisualElement.render(self)
local text = self.get("text")
local width = self.get("width")
local dropSymbol = self.get("dropSymbol")
local isFocused = self.get("focused")
local isOpen = self.get("isOpen")
local viewOffset = self.get("viewOffset")
local placeholder = self.get("placeholder")
local bg = isFocused and self.get("focusedBackground") or self.get("background")
local fg = isFocused and self.get("focusedForeground") or self.get("foreground")
local text = self.getResolved("text")
local width = self.get("width")
local dropSymbol = self.getResolved("dropSymbol")
local isFocused = self:hasState("focused")
local isOpen = self:hasState("opened")
local viewOffset = self.get("viewOffset")
local placeholder = self.getResolved("placeholder")
local bg = self.getResolved("background")
local fg = self.getResolved("foreground")
local displayText = text
local textWidth = width - #dropSymbol
@@ -375,7 +353,7 @@ function ComboBox:render()
local cursorPos = self.get("cursorPos")
local cursorX = cursorPos - viewOffset
if cursorX >= 1 and cursorX <= textWidth then
self:setCursor(cursorX, 1, true, self.get("foreground"))
self:setCursor(cursorX, 1, true, fg)
end
end
@@ -398,8 +376,8 @@ function ComboBox:render()
local itemText = item.text or ""
local isSelected = item.selected or false
local itemBg = isSelected and self.get("selectedBackground") or self.get("background")
local itemFg = isSelected and self.get("selectedForeground") or self.get("foreground")
local itemBg = isSelected and self.get("selectedBackground") or bg
local itemFg = isSelected and self.get("selectedForeground") or fg
if #itemText > width then
itemText = itemText:sub(1, width)
@@ -415,20 +393,4 @@ function ComboBox:render()
end
end
--- Called when the ComboBox gains focus
--- @shortDescription Called when gaining focus
function ComboBox:focus()
DropDown.focus(self)
-- Additional focus logic for input if needed
end
--- Called when the ComboBox loses focus
--- @shortDescription Called when losing focus
function ComboBox:blur()
DropDown.blur(self)
self.set("isOpen", false)
self.set("height", 1)
self:updateRender()
end
return ComboBox
return ComboBox

View File

@@ -37,11 +37,11 @@ Container.defineProperty(Container, "focusedChild", {default = nil, type = "tabl
if oldChild:isType("Container") then
oldChild.set("focusedChild", nil, true)
end
oldChild.set("focused", false, true)
oldChild:setFocused(false, true)
end
if value and not internal then
value.set("focused", true, true)
value:setFocused(true, true)
if self.parent then
self.parent:setFocusedChild(self)
end
@@ -428,6 +428,7 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
function Container:mouse_up(button, x, y)
self:mouse_release(button, x, y)
if VisualElement.mouse_up(self, button, x, y) then
local args = convertMousePosition(self, "mouse_up", button, x, y)
local success, child = self:callChildrenEvent(true, "mouse_up", table.unpack(args))

View File

@@ -1,5 +1,6 @@
local VisualElement = require("elements/VisualElement")
local List = require("elements/List")
local ScrollBar = require("elements/ScrollBar")
local tHex = require("libraries/colorHex")
---@configDescription A DropDown menu that shows a list of selectable items
@@ -54,8 +55,6 @@ local tHex = require("libraries/colorHex")
local DropDown = setmetatable({}, List)
DropDown.__index = DropDown
---@property isOpen boolean false Controls the expanded/collapsed state
DropDown.defineProperty(DropDown, "isOpen", {default = false, type = "boolean", canTriggerRender = true})
---@property dropdownHeight number 5 Maximum visible items when expanded
DropDown.defineProperty(DropDown, "dropdownHeight", {default = 5, type = "number"})
---@property selectedText string "" Text shown when no selection made
@@ -84,6 +83,7 @@ end
function DropDown:init(props, basalt)
List.init(self, props, basalt)
self.set("type", "DropDown")
self:registerState("opened", nil, 200)
return self
end
@@ -97,16 +97,17 @@ function DropDown:mouse_click(button, x, y)
if not VisualElement.mouse_click(self, button, x, y) then return false end
local relX, relY = self:getRelativePosition(x, y)
local isOpen = self:hasState("opened")
if relY == 1 then
self.set("isOpen", not self.get("isOpen"))
if not self.get("isOpen") then
if isOpen then
self.set("height", 1)
self:unsetState("opened")
else
self.set("height", 1 + math.min(self.get("dropdownHeight"), #self.get("items")))
self:setState("opened")
end
return true
elseif self.get("isOpen") and relY > 1 and self.get("selectable") then
elseif isOpen and relY > 1 and self.get("selectable") then
local itemIndex = (relY - 1) + self.get("offset")
local items = self.get("items")
@@ -132,7 +133,8 @@ function DropDown:mouse_click(button, x, y)
end
self:fireEvent("select", itemIndex, item)
self.set("isOpen", false)
self:unsetState("opened")
self:unsetState("clicked")
self.set("height", 1)
self:updateRender()
return true
@@ -147,6 +149,7 @@ function DropDown:render()
VisualElement.render(self)
local text = self.get("selectedText")
local isOpen = self:hasState("opened")
local selectedItems = self:getSelectedItems()
if #selectedItems > 0 then
local selectedItem = selectedItems[1]
@@ -154,11 +157,11 @@ function DropDown:render()
text = text:sub(1, self.get("width") - 2)
end
self:blit(1, 1, text .. string.rep(" ", self.get("width") - #text - 1) .. (self.get("isOpen") and "\31" or "\17"),
string.rep(tHex[self.get("foreground")], self.get("width")),
string.rep(tHex[self.get("background")], self.get("width")))
self:blit(1, 1, text .. string.rep(" ", self.get("width") - #text - 1) .. (isOpen and "\31" or "\17"),
string.rep(tHex[self.getResolved("foreground")], self.get("width")),
string.rep(tHex[self.getResolved("background")], self.get("width")))
if self.get("isOpen") then
if isOpen then
local items = self.get("items")
local height = self.get("height") - 1
local offset = self.get("offset")
@@ -177,8 +180,8 @@ function DropDown:render()
if item.separator then
local separatorChar = (item.text or "-"):sub(1,1)
local separatorText = string.rep(separatorChar, width)
local fg = item.foreground or self.get("foreground")
local bg = item.background or self.get("background")
local fg = item.fg or self.getResolved("foreground")
local bg = item.bg or self.getResolved("background")
self:textBg(1, i + 1, string.rep(" ", width), bg)
self:textFg(1, i + 1, separatorText, fg)
@@ -188,12 +191,12 @@ function DropDown:render()
text = text:sub(1, width)
local bg = isSelected and
(item.selectedBackground or self.get("selectedBackground")) or
(item.background or self.get("background"))
(item.selectedBg or self.getResolved("selectedBackground")) or
(item.bg or self.getResolved("background"))
local fg = isSelected and
(item.selectedForeground or self.get("selectedForeground")) or
(item.foreground or self.get("foreground"))
(item.selectedFg or self.getResolved("selectedForeground")) or
(item.fg or self.getResolved("foreground"))
self:textBg(1, i + 1, string.rep(" ", width), bg)
self:textFg(1, i + 1, text, fg)
@@ -203,4 +206,22 @@ function DropDown:render()
end
end
return DropDown
--- Called when the DropDown gains focus
--- @shortDescription Called when gaining focus
--- @protected
function DropDown:focus()
VisualElement.focus(self)
self:setState("opened")
end
--- Called when the DropDown loses focus
--- @shortDescription Called when losing focus
--- @protected
function DropDown:blur()
VisualElement.blur(self)
self:unsetState("opened")
self.set("height", 1)
self:updateRender()
end
return DropDown

View File

@@ -20,10 +20,6 @@ Input.defineProperty(Input, "maxLength", {default = nil, type = "number"})
Input.defineProperty(Input, "placeholder", {default = "...", type = "string"})
---@property placeholderColor color gray Color of the placeholder text
Input.defineProperty(Input, "placeholderColor", {default = colors.gray, type = "color"})
---@property focusedBackground color blue Background color when input is focused
Input.defineProperty(Input, "focusedBackground", {default = colors.blue, type = "color"})
---@property focusedForeground color white Foreground color when input is focused
Input.defineProperty(Input, "focusedForeground", {default = colors.white, type = "color"})
---@property pattern string? nil Regular expression pattern for input validation
Input.defineProperty(Input, "pattern", {default = nil, type = "string"})
---@property cursorColor number nil Color of the cursor
@@ -32,6 +28,7 @@ Input.defineProperty(Input, "cursorColor", {default = nil, type = "number"})
Input.defineProperty(Input, "replaceChar", {default = nil, type = "string", canTriggerRender = true})
Input.defineEvent(Input, "mouse_click")
Input.defineEvent(Input, "mouse_up")
Input.defineEvent(Input, "key")
Input.defineEvent(Input, "char")
Input.defineEvent(Input, "paste")
@@ -74,7 +71,7 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
function Input:char(char)
if not self.get("focused") then return false end
if not self:hasState("focused") then return false end
local text = self.get("text")
local pos = self.get("cursorPos")
local maxLength = self.get("maxLength")
@@ -98,7 +95,7 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
function Input:key(key, held)
if not self.get("focused") then return false end
if not self:hasState("focused") then return false end
local pos = self.get("cursorPos")
local text = self.get("text")
local viewOffset = self.get("viewOffset")
@@ -128,7 +125,7 @@ function Input:key(key, held)
end
local relativePos = self.get("cursorPos") - self.get("viewOffset")
self:setCursor(relativePos, 1, true, self.get("cursorColor") or self.get("foreground"))
self:setCursor(relativePos, 1, true, self.getResolved("cursorColor") or self.getResolved("foreground"))
VisualElement.key(self, key, held)
return true
end
@@ -150,7 +147,7 @@ function Input:mouse_click(button, x, y)
self.set("cursorPos", targetPos)
local visualX = targetPos - viewOffset
self:setCursor(visualX, 1, true, self.get("cursorColor") or self.get("foreground"))
self:setCursor(visualX, 1, true, self.getResolved("cursorColor") or self.getResolved("foreground"))
return true
end
@@ -181,7 +178,7 @@ end
--- @protected
function Input:focus()
VisualElement.focus(self)
self:setCursor(self.get("cursorPos") - self.get("viewOffset"), 1, true, self.get("cursorColor") or self.get("foreground"))
self:setCursor(self.get("cursorPos") - self.get("viewOffset"), 1, true, self.getResolved("cursorColor") or self.getResolved("foreground"))
self:updateRender()
end
@@ -189,14 +186,14 @@ end
--- @protected
function Input:blur()
VisualElement.blur(self)
self:setCursor(1, 1, false, self.get("cursorColor") or self.get("foreground"))
self:setCursor(1, 1, false, self.getResolved("cursorColor") or self.getResolved("foreground"))
self:updateRender()
end
--- @shortDescription Handles paste events
--- @protected
function Input:paste(content)
if not self.get("focused") then return false end
if not self:hasState("focused") then return false end
local text = self.get("text")
local pos = self.get("cursorPos")
local maxLength = self.get("maxLength")
@@ -216,31 +213,28 @@ end
--- @shortDescription Renders the input element
--- @protected
function Input:render()
local text = self.get("text")
local text = self.getResolved("text")
local viewOffset = self.get("viewOffset")
local width = self.get("width")
local placeholder = self.get("placeholder")
local focusedBg = self.get("focusedBackground")
local focusedFg = self.get("focusedForeground")
local focused = self.get("focused")
local placeholder = self.getResolved("placeholder")
local focused = self:hasState("focused")
local width, height = self.get("width"), self.get("height")
local replaceChar = self.get("replaceChar")
self:multiBlit(1, 1, width, height, " ", tHex[focused and focusedFg or self.get("foreground")], tHex[focused and focusedBg or self.get("background")])
local replaceChar = self.getResolved("replaceChar")
self:multiBlit(1, 1, width, height, " ", tHex[self.getResolved("foreground")], tHex[self.getResolved("background")])
if #text == 0 and #placeholder ~= 0 and self.get("focused") == false then
self:textFg(1, 1, placeholder:sub(1, width), self.get("placeholderColor"))
if #text == 0 and #placeholder ~= 0 and not focused then
self:textFg(1, 1, placeholder:sub(1, width), self.getResolved("placeholderColor"))
return
end
if(focused) then
self:setCursor(self.get("cursorPos") - viewOffset, 1, true, self.get("cursorColor") or self.get("foreground"))
self:setCursor(self.get("cursorPos") - viewOffset, 1, true, self.getResolved("cursorColor") or self.getResolved("foreground"))
end
local visibleText = text:sub(viewOffset + 1, viewOffset + width)
if replaceChar and #replaceChar > 0 then
visibleText = replaceChar:rep(#visibleText)
end
self:textFg(1, 1, visibleText, self.get("foreground"))
self:textFg(1, 1, visibleText, self.getResolved("foreground"))
end
return Input

View File

@@ -1,29 +1,29 @@
local VisualElement = require("elements/VisualElement")
local Collection = require("elements/Collection")
---@configDescription A scrollable list of selectable items
--- This is the list class. It provides a scrollable list of selectable items with support for
--- custom item rendering, separators, and selection handling.
---@class List : VisualElement
local List = setmetatable({}, VisualElement)
---@class List : Collection
local List = setmetatable({}, Collection)
List.__index = List
---@property items table {} List of items to display. Items can be tables with properties including selected state
List.defineProperty(List, "items", {default = {}, type = "table", canTriggerRender = true})
---@property selectable boolean true Whether items in the list can be selected
List.defineProperty(List, "selectable", {default = true, type = "boolean"})
---@property multiSelection boolean false Whether multiple items can be selected at once
List.defineProperty(List, "multiSelection", {default = false, type = "boolean"})
---@property offset number 0 Current scroll offset for viewing long lists
List.defineProperty(List, "offset", {default = 0, type = "number", canTriggerRender = true})
---@property selectedBackground color blue Background color for selected items
List.defineProperty(List, "selectedBackground", {default = colors.blue, type = "color"})
---@property selectedForeground color white Text color for selected items
List.defineProperty(List, "selectedForeground", {default = colors.white, type = "color"})
---@event onSelect {index number, item table} Fired when an item is selected
List.defineEvent(List, "mouse_click")
List.defineEvent(List, "mouse_up")
List.defineEvent(List, "mouse_scroll")
local entrySchema = {
text = { type = "string", default = "Entry" },
bg = { type = "number", default = nil },
fg = { type = "number", default = nil },
selectedBg = { type = "number", default = nil },
selectedFg = { type = "number", default = nil },
callback = { type = "function", default = nil }
}
--- Creates a new List instance
--- @shortDescription Creates a new List instance
--- @return List self The newly created List instance
@@ -44,75 +44,12 @@ end
--- @return List self The initialized instance
--- @protected
function List:init(props, basalt)
VisualElement.init(self, props, basalt)
Collection.init(self, props, basalt)
self._entrySchema = entrySchema
self.set("type", "List")
return self
end
--- Adds an item to the list
--- @shortDescription Adds an item to the list
--- @param text string|table The item to add (string or item table)
--- @return List self The List instance
--- @usage list:addItem("New Item")
--- @usage list:addItem({text="Item", callback=function() end})
function List:addItem(text)
local items = self.get("items")
table.insert(items, text)
self:updateRender()
return self
end
--- Removes an item from the list
--- @shortDescription Removes an item from the list
--- @param index number The index of the item to remove
--- @return List self The List instance
--- @usage list:removeItem(1)
function List:removeItem(index)
local items = self.get("items")
table.remove(items, index)
self:updateRender()
return self
end
--- Clears all items from the list
--- @shortDescription Clears all items from the list
--- @return List self The List instance
--- @usage list:clear()
function List:clear()
self.set("items", {})
self:updateRender()
return self
end
-- Gets the currently selected items
--- @shortDescription Gets the currently selected items
--- @return table selected List of selected items
--- @usage local selected = list:getSelectedItems()
function List:getSelectedItems()
local selected = {}
for i, item in ipairs(self.get("items")) do
if type(item) == "table" and item.selected then
local selectedItem = item
selectedItem.index = i
table.insert(selected, selectedItem)
end
end
return selected
end
--- Gets first selected item
--- @shortDescription Gets first selected item
--- @return table? selected The first item
function List:getSelectedItem()
local items = self.get("items")
for i, item in ipairs(items) do
if type(item) == "table" and item.selected then
return item
end
end
return nil
end
--- @shortDescription Handles mouse click events
--- @param button number The mouse button that was clicked
--- @param x number The x-coordinate of the click
@@ -120,18 +57,13 @@ end
--- @return boolean Whether the event was handled
--- @protected
function List:mouse_click(button, x, y)
if self:isInBounds(x, y) and self.get("selectable") then
if Collection.mouse_click(self, button, x, y) and self.get("selectable") then
local _, index = self:getRelativePosition(x, y)
local adjustedIndex = index + self.get("offset")
local items = self.get("items")
if adjustedIndex <= #items then
local item = items[adjustedIndex]
if type(item) == "string" then
item = {text = item}
items[adjustedIndex] = item
end
if not self.get("multiSelection") then
for _, otherItem in ipairs(items) do
if type(otherItem) == "table" then
@@ -145,7 +77,6 @@ function List:mouse_click(button, x, y)
if item.callback then
item.callback(self)
end
self:fireEvent("mouse_click", button, x, y)
self:fireEvent("select", adjustedIndex, item)
self:updateRender()
end
@@ -161,13 +92,12 @@ end
--- @return boolean Whether the event was handled
--- @protected
function List:mouse_scroll(direction, x, y)
if self:isInBounds(x, y) then
if Collection.mouse_scroll(self, direction, x, y) then
local offset = self.get("offset")
local maxOffset = math.max(0, #self.get("items") - self.get("height"))
offset = math.min(maxOffset, math.max(0, offset + direction))
self.set("offset", offset)
self:fireEvent("mouse_scroll", direction, x, y)
return true
end
return false
@@ -203,43 +133,38 @@ end
--- @shortDescription Renders the list
--- @protected
function List:render()
VisualElement.render(self)
Collection.render(self)
local items = self.get("items")
local height = self.get("height")
local offset = self.get("offset")
local width = self.get("width")
local listBg = self.getResolved("background")
local listFg = self.getResolved("foreground")
for i = 1, height do
local itemIndex = i + offset
local item = items[itemIndex]
if item then
if type(item) == "string" then
item = {text = item}
items[itemIndex] = item
end
if item.separator then
local separatorChar = (item.text or "-"):sub(1,1)
local separatorText = string.rep(separatorChar, width)
local fg = item.foreground or self.get("foreground")
local bg = item.background or self.get("background")
local fg = item.fg or listFg
local bg = item.bg or listBg
self:textBg(1, i, string.rep(" ", width), bg)
self:textFg(1, i, separatorText:sub(1, width), fg)
else
local text = item.text
local isSelected = item.selected
local bg = isSelected and
(item.selectedBackground or self.get("selectedBackground")) or
(item.background or self.get("background"))
(item.selectedBg or self.getResolved("selectedBackground")) or
(item.bg or listBg)
local fg = isSelected and
(item.selectedForeground or self.get("selectedForeground")) or
(item.foreground or self.get("foreground"))
(item.selectedFg or self.getResolved("selectedForeground")) or
(item.fg or listFg)
self:textBg(1, i, string.rep(" ", width), bg)
self:textFg(1, i, text:sub(1, width), fg)
end

View File

@@ -282,7 +282,7 @@ function Program:dispatchEvent(event, ...)
local result = VisualElement.dispatchEvent(self, event, ...)
if program then
program:resume(event, ...)
if(self.get("focused"))then
if(self:hasState("focused"))then
local cursorBlink = program.window.getCursorBlink()
local cursorX, cursorY = program.window.getCursorPos()
self:setCursor(cursorX, cursorY, cursorBlink, program.window.getTextColor())

View File

@@ -535,7 +535,7 @@ local function refreshAutoComplete(self)
hideAutoComplete(self, true)
return
end
if not self.get("focused") then
if not self:hasState("focused") then
hideAutoComplete(self, true)
return
end
@@ -647,7 +647,7 @@ function TextBox:init(props, basalt)
self.set("type", "TextBox")
local function refreshIfEnabled()
if self.get("autoCompleteEnabled") and self.get("focused") then
if self.get("autoCompleteEnabled") and self:hasState("focused") then
refreshAutoComplete(self)
end
end
@@ -666,18 +666,19 @@ function TextBox:init(props, basalt)
self:observe("autoCompleteEnabled", function(_, value)
if not value then
hideAutoComplete(self, true)
elseif self.get("focused") then
elseif self:hasState("focused") then
refreshAutoComplete(self)
end
end)
--[[
self:observe("focused", function(_, focused)
if focused then
refreshIfEnabled()
else
hideAutoComplete(self, true)
end
end)
end)]] -- needs a REWORK
self:observe("foreground", restyle)
self:observe("background", restyle)
@@ -859,7 +860,7 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
function TextBox:char(char)
if not self.get("editable") or not self.get("focused") then return false end
if not self.get("editable") or not self:hasState("focused") then return false end
-- Auto-pair logic only triggers for single characters
local autoPair = self.get("autoPairEnabled")
if autoPair and #char == 1 then
@@ -912,7 +913,7 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
function TextBox:key(key)
if not self.get("editable") or not self.get("focused") then return false end
if not self.get("editable") or not self:hasState("focused") then return false end
if handleAutoCompleteKey(self, key) then
return true
end
@@ -1041,7 +1042,7 @@ end
--- @shortDescription Handles paste events
--- @protected
function TextBox:paste(text)
if not self.get("editable") or not self.get("focused") then return false end
if not self.get("editable") or not self:hasState("focused") then return false end
for char in text:gmatch(".") do
if char == "\n" then
@@ -1135,7 +1136,7 @@ function TextBox:render()
self:blit(1, y, text, colors, string.rep(bg, #text))
end
if self.get("focused") then
if self:hasState("focused") then
local relativeX = self.get("cursorX") - scrollX
local relativeY = self.get("cursorY") - scrollY
if relativeX >= 1 and relativeX <= width and relativeY >= 1 and relativeY <= height then

View File

@@ -22,6 +22,12 @@ VisualElement.defineProperty(VisualElement, "z", {default = 1, type = "number",
return value
end})
VisualElement.defineProperty(VisualElement, "constraints", {
default = {},
type = "table"
})
---@property width number 1 The width of the element
VisualElement.defineProperty(VisualElement, "width", {default = 1, type = "number", canTriggerRender = true})
---@property height number 1 The height of the element
@@ -30,10 +36,6 @@ VisualElement.defineProperty(VisualElement, "height", {default = 1, type = "numb
VisualElement.defineProperty(VisualElement, "background", {default = colors.black, type = "color", canTriggerRender = true})
---@property foreground color white The text/foreground color
VisualElement.defineProperty(VisualElement, "foreground", {default = colors.white, type = "color", canTriggerRender = true})
---@property clicked boolean false Whether the element is currently clicked
VisualElement.defineProperty(VisualElement, "clicked", {default = false, type = "boolean"})
---@property hover boolean false Whether the mouse is currently hover over the element (Craftos-PC only)
VisualElement.defineProperty(VisualElement, "hover", {default = false, type = "boolean"})
---@property backgroundEnabled boolean true Whether to render the background
VisualElement.defineProperty(VisualElement, "backgroundEnabled", {default = true, type = "boolean", canTriggerRender = true})
---@property borderTop boolean false Draw top border
@@ -46,26 +48,6 @@ VisualElement.defineProperty(VisualElement, "borderLeft", {default = false, type
VisualElement.defineProperty(VisualElement, "borderRight", {default = false, type = "boolean", canTriggerRender = true})
---@property borderColor color white Border color
VisualElement.defineProperty(VisualElement, "borderColor", {default = colors.white, type = "color", canTriggerRender = true})
---@property focused boolean false Whether the element has input focus
VisualElement.defineProperty(VisualElement, "focused", {default = false, type = "boolean", setter = function(self, value, internal)
local curValue = self.get("focused")
if value == curValue then return value end
if value then
self:focus()
else
self:blur()
end
if not internal and self.parent then
if value then
self.parent:setFocusedChild(self)
else
self.parent:setFocusedChild(nil)
end
end
return value
end})
---@property visible boolean true Whether the element is visible
VisualElement.defineProperty(VisualElement, "visible", {default = true, type = "boolean", canTriggerRender = true, setter=function(self, value)
@@ -74,7 +56,7 @@ VisualElement.defineProperty(VisualElement, "visible", {default = true, type = "
self.parent.set("childrenEventsSorted", false)
end
if(value==false)then
self.set("clicked", false)
self:unsetState("clicked")
end
return value
end})
@@ -136,6 +118,12 @@ end
function VisualElement:init(props, basalt)
BaseElement.init(self, props, basalt)
self.set("type", "VisualElement")
self:registerState("disabled", nil, 1000)
self:registerState("clicked", nil, 500)
self:registerState("hover", nil, 400)
self:registerState("focused", nil, 300)
self:registerState("dragging", nil, 600)
self:observe("x", function()
if self.parent then
self.parent.set("childrenSorted", false)
@@ -163,6 +151,495 @@ function VisualElement:init(props, basalt)
end)
end
--- Sets a constraint on a property relative to another element's property
--- @shortDescription Sets a constraint on a property relative to another element's property
--- @param property string The property to constrain (x, y, width, height, left, right, top, bottom, centerX, centerY)
--- @param targetElement BaseElement|string The target element or "parent"
--- @param targetProperty string The target property to constrain to (left, right, top, bottom, centerX, centerY, width, height)
--- @param offset number The offset to apply (negative = inside, positive = outside, fractional = percentage)
--- @return VisualElement self The element instance
function VisualElement:setConstraint(property, targetElement, targetProperty, offset)
local constraints = self.get("constraints")
if constraints[property] then
self:_removeConstraintObservers(property, constraints[property])
end
constraints[property] = {
element = targetElement,
property = targetProperty,
offset = offset or 0
}
self.set("constraints", constraints)
self:_addConstraintObservers(property, constraints[property])
self._constraintsDirty = true
self:updateRender()
return self
end
--- Resolves all constraints for the element
--- @shortDescription Resolves all constraints for the element
--- @return VisualElement self The element instance
function VisualElement:resolveAllConstraints()
if not self._constraintsDirty then return self end
local constraints = self.get("constraints")
if not constraints or not next(constraints) then return self end
local order = {"width", "height", "left", "right", "top", "bottom", "x", "y", "centerX", "centerY"}
for _, property in ipairs(order) do
if constraints[property] then
local value = self:_resolveConstraint(property, constraints[property])
self:_applyConstraintValue(property, value)
end
end
self._constraintsDirty = false
return self
end
--- Applies a resolved constraint value to the appropriate property
--- @private
function VisualElement:_applyConstraintValue(property, value)
if property == "x" or property == "left" then
self.set("x", value)
elseif property == "y" or property == "top" then
self.set("y", value)
elseif property == "right" then
local width = self.get("width")
self.set("x", value - width + 1)
elseif property == "bottom" then
local height = self.get("height")
self.set("y", value - height + 1)
elseif property == "centerX" then
local width = self.get("width")
self.set("x", value - math.floor(width / 2))
elseif property == "centerY" then
local height = self.get("height")
self.set("y", value - math.floor(height / 2))
elseif property == "width" then
self.set("width", value)
elseif property == "height" then
self.set("height", value)
end
end
--- Adds observers for a specific constraint to track changes in the target element
--- @private
function VisualElement:_addConstraintObservers(constraintProp, constraint)
local targetEl = constraint.element
local targetProp = constraint.property
if targetEl == "parent" then
targetEl = self.parent
end
if not targetEl then return end
local callback = function()
self._constraintsDirty = true
self:resolveAllConstraints()
self:updateRender()
end
if not self._constraintObserverCallbacks then
self._constraintObserverCallbacks = {}
end
if not self._constraintObserverCallbacks[constraintProp] then
self._constraintObserverCallbacks[constraintProp] = {}
end
local observeProps = {}
if targetProp == "left" or targetProp == "x" then
observeProps = {"x"}
elseif targetProp == "right" then
observeProps = {"x", "width"}
elseif targetProp == "top" or targetProp == "y" then
observeProps = {"y"}
elseif targetProp == "bottom" then
observeProps = {"y", "height"}
elseif targetProp == "centerX" then
observeProps = {"x", "width"}
elseif targetProp == "centerY" then
observeProps = {"y", "height"}
elseif targetProp == "width" then
observeProps = {"width"}
elseif targetProp == "height" then
observeProps = {"height"}
end
for _, prop in ipairs(observeProps) do
targetEl:observe(prop, callback)
table.insert(self._constraintObserverCallbacks[constraintProp], {
element = targetEl,
property = prop,
callback = callback
})
end
end
--- Removes observers for a specific constraint
--- @private
function VisualElement:_removeConstraintObservers(constraintProp, constraint)
if not self._constraintObserverCallbacks or not self._constraintObserverCallbacks[constraintProp] then
return
end
for _, observer in ipairs(self._constraintObserverCallbacks[constraintProp]) do
observer.element:removeObserver(observer.property, observer.callback)
end
self._constraintObserverCallbacks[constraintProp] = nil
end
--- Removes all constraint observers from the element
--- @private
function VisualElement:_removeAllConstraintObservers()
if not self._constraintObserverCallbacks then return end
for constraintProp, observers in pairs(self._constraintObserverCallbacks) do
for _, observer in ipairs(observers) do
observer.element:removeObserver(observer.property, observer.callback)
end
end
self._constraintObserverCallbacks = nil
end
--- Removes a constraint from the element
--- @shortDescription Removes a constraint from the element
--- @param property string The property of the constraint to remove
--- @return VisualElement self The element instance
function VisualElement:removeConstraint(property)
local constraints = self.get("constraints")
constraints[property] = nil
self.set("constraints", constraints)
self:updateConstraints()
return self
end
--- Updates all constraints, recalculating positions and sizes
--- @shortDescription Updates all constraints, recalculating positions and sizes
--- @return VisualElement self The element instance
function VisualElement:updateConstraints()
local constraints = self.get("constraints")
for property, constraint in pairs(constraints) do
local value = self:_resolveConstraint(property, constraint)
if property == "x" or property == "left" then
self.set("x", value)
elseif property == "y" or property == "top" then
self.set("y", value)
elseif property == "right" then
local width = self.get("width")
self.set("x", value - width + 1)
elseif property == "bottom" then
local height = self.get("height")
self.set("y", value - height + 1)
elseif property == "centerX" then
local width = self.get("width")
self.set("x", value - math.floor(width / 2))
elseif property == "centerY" then
local height = self.get("height")
self.set("y", value - math.floor(height / 2))
elseif property == "width" then
self.set("width", value)
elseif property == "height" then
self.set("height", value)
end
end
end
--- Resolves a constraint to an absolute value
--- @private
function VisualElement:_resolveConstraint(property, constraint)
local targetEl = constraint.element
local targetProp = constraint.property
local offset = constraint.offset
if targetEl == "parent" then
targetEl = self.parent
end
if not targetEl then
return self.get(property) or 1
end
local value
if targetProp == "left" or targetProp == "x" then
value = targetEl.get("x")
elseif targetProp == "right" then
value = targetEl.get("x") + targetEl.get("width") - 1
elseif targetProp == "top" or targetProp == "y" then
value = targetEl.get("y")
elseif targetProp == "bottom" then
value = targetEl.get("y") + targetEl.get("height") - 1
elseif targetProp == "centerX" then
value = targetEl.get("x") + math.floor(targetEl.get("width") / 2)
elseif targetProp == "centerY" then
value = targetEl.get("y") + math.floor(targetEl.get("height") / 2)
elseif targetProp == "width" then
value = targetEl.get("width")
elseif targetProp == "height" then
value = targetEl.get("height")
end
if type(offset) == "number" then
if offset > -1 and offset < 1 and offset ~= 0 then
return math.floor(value * offset)
else
return value + offset
end
end
return value
end
--- Aligns the element's right edge to the target's right edge with optional offset
--- @shortDescription Aligns the element's right edge to the target's right edge with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Offset from the edge (negative = inside, positive = outside, default: 0)
--- @return VisualElement self
function VisualElement:alignRight(target, offset)
offset = offset or 0
return self:setConstraint("right", target, "right", offset)
end
--- Aligns the element's left edge to the target's left edge with optional offset
--- @shortDescription Aligns the element's left edge to the target's left edge with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Offset from the edge (negative = inside, positive = outside, default: 0)
--- @return VisualElement self
function VisualElement:alignLeft(target, offset)
offset = offset or 0
return self:setConstraint("left", target, "left", offset)
end
--- Aligns the element's top edge to the target's top edge with optional offset
--- @shortDescription Aligns the element's top edge to the target's top edge with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Offset from the edge (negative = inside, positive = outside, default: 0)
--- @return VisualElement self
function VisualElement:alignTop(target, offset)
offset = offset or 0
return self:setConstraint("top", target, "top", offset)
end
--- Aligns the element's bottom edge to the target's bottom edge with optional offset
--- @shortDescription Aligns the element's bottom edge to the target's bottom edge with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Offset from the edge (negative = inside, positive = outside, default: 0)
--- @return VisualElement self
function VisualElement:alignBottom(target, offset)
offset = offset or 0
return self:setConstraint("bottom", target, "bottom", offset)
end
--- Centers the element horizontally relative to the target with optional offset
--- @shortDescription Centers the element horizontally relative to the target with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Horizontal offset from center (default: 0)
--- @return VisualElement self
function VisualElement:centerHorizontal(target, offset)
offset = offset or 0
return self:setConstraint("centerX", target, "centerX", offset)
end
--- Centers the element vertically relative to the target with optional offset
--- @shortDescription Centers the element vertically relative to the target with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Vertical offset from center (default: 0)
--- @return VisualElement self
function VisualElement:centerVertical(target, offset)
offset = offset or 0
return self:setConstraint("centerY", target, "centerY", offset)
end
--- Centers the element both horizontally and vertically relative to the target
--- @shortDescription Centers the element both horizontally and vertically relative to the target
--- @param target BaseElement|string The target element or "parent"
--- @return VisualElement self
function VisualElement:centerIn(target)
return self:centerHorizontal(target):centerVertical(target)
end
--- Positions the element to the right of the target with optional gap
--- @shortDescription Positions the element to the right of the target with optional gap
--- @param target BaseElement|string The target element or "parent"
--- @param gap? number Gap between elements (default: 0)
--- @return VisualElement self
function VisualElement:rightOf(target, gap)
gap = gap or 0
return self:setConstraint("left", target, "right", gap)
end
--- Positions the element to the left of the target with optional gap
--- @shortDescription Positions the element to the left of the target with optional gap
--- @param target BaseElement|string The target element or "parent"
--- @param gap? number Gap between elements (default: 0)
--- @return VisualElement self
function VisualElement:leftOf(target, gap)
gap = gap or 0
return self:setConstraint("right", target, "left", -gap)
end
--- Positions the element below the target with optional gap
--- @shortDescription Positions the element below the target with optional gap
--- @param target BaseElement|string The target element or "parent"
--- @param gap? number Gap between elements (default: 0)
--- @return VisualElement self
function VisualElement:below(target, gap)
gap = gap or 0
return self:setConstraint("top", target, "bottom", gap)
end
--- Positions the element above the target with optional gap
--- @shortDescription Positions the element above the target with optional gap
--- @param target BaseElement|string The target element or "parent"
--- @param gap? number Gap between elements (default: 0)
--- @return VisualElement self
function VisualElement:above(target, gap)
gap = gap or 0
return self:setConstraint("bottom", target, "top", -gap)
end
--- Stretches the element to match the target's width with optional margin
--- @shortDescription Stretches the element to match the target's width with optional margin
--- @param target BaseElement|string The target element or "parent"
--- @param margin? number Margin on each side (default: 0)
--- @return VisualElement self
function VisualElement:stretchWidth(target, margin)
margin = margin or 0
return self
:setConstraint("left", target, "left", margin)
:setConstraint("right", target, "right", -margin)
end
--- Stretches the element to match the target's height with optional margin
--- @shortDescription Stretches the element to match the target's height with optional margin
--- @param target BaseElement|string The target element or "parent"
--- @param margin? number Margin on top and bottom (default: 0)
--- @return VisualElement self
function VisualElement:stretchHeight(target, margin)
margin = margin or 0
return self
:setConstraint("top", target, "top", margin)
:setConstraint("bottom", target, "bottom", -margin)
end
--- Stretches the element to match the target's width and height with optional margin
--- @shortDescription Stretches the element to match the target's width and height with optional margin
--- @param target BaseElement|string The target element or "parent"
--- @param margin? number Margin on all sides (default: 0)
--- @return VisualElement self
function VisualElement:stretch(target, margin)
return self:stretchWidth(target, margin):stretchHeight(target, margin)
end
--- Sets the element's width as a percentage of the target's width
--- @shortDescription Sets the element's width as a percentage of the target's width
--- @param target BaseElement|string The target element or "parent"
--- @param percent number Percentage of target's width (0-100)
--- @return VisualElement self
function VisualElement:widthPercent(target, percent)
return self:setConstraint("width", target, "width", percent / 100)
end
--- Sets the element's height as a percentage of the target's height
--- @shortDescription Sets the element's height as a percentage of the target's height
--- @param target BaseElement|string The target element or "parent"
--- @param percent number Percentage of target's height (0-100)
--- @return VisualElement self
function VisualElement:heightPercent(target, percent)
return self:setConstraint("height", target, "height", percent / 100)
end
--- Matches the element's width to the target's width with optional offset
--- @shortDescription Matches the element's width to the target's width with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Offset to add to target's width (default: 0)
--- @return VisualElement self
function VisualElement:matchWidth(target, offset)
offset = offset or 0
return self:setConstraint("width", target, "width", offset)
end
--- Matches the element's height to the target's height with optional offset
--- @shortDescription Matches the element's height to the target's height with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Offset to add to target's height (default: 0)
--- @return VisualElement self
function VisualElement:matchHeight(target, offset)
offset = offset or 0
return self:setConstraint("height", target, "height", offset)
end
--- Stretches the element to fill its parent's width and height with optional margin
--- @shortDescription Stretches the element to fill its parent's width and height with optional margin
--- @param margin? number Margin on all sides (default: 0)
--- @return VisualElement self
function VisualElement:fillParent(margin)
return self:stretch("parent", margin)
end
--- Stretches the element to fill its parent's width with optional margin
--- @shortDescription Stretches the element to fill its parent's width with optional margin
--- @param margin? number Margin on left and right (default: 0)
--- @return VisualElement self
function VisualElement:fillWidth(margin)
return self:stretchWidth("parent", margin)
end
--- Stretches the element to fill its parent's height with optional margin
--- @shortDescription Stretches the element to fill its parent's height with optional margin
--- @param margin? number Margin on top and bottom (default: 0)
--- @return VisualElement self
function VisualElement:fillHeight(margin)
return self:stretchHeight("parent", margin)
end
--- Centers the element within its parent both horizontally and vertically
--- @shortDescription Centers the element within its parent both horizontally and vertically
--- @return VisualElement self
function VisualElement:center()
return self:centerIn("parent")
end
--- Aligns the element's right edge to its parent's right edge with optional gap
--- @shortDescription Aligns the element's right edge to its parent's right edge with optional gap
--- @param gap? number Gap from the edge (default: 0)
--- @return VisualElement self
function VisualElement:toRight(gap)
return self:alignRight("parent", -(gap or 0))
end
--- Aligns the element's left edge to its parent's left edge with optional gap
--- @shortDescription Aligns the element's left edge to its parent's left edge with optional gap
--- @param gap? number Gap from the edge (default: 0)
--- @return VisualElement self
function VisualElement:toLeft(gap)
return self:alignLeft("parent", gap or 0)
end
--- Aligns the element's top edge to its parent's top edge with optional gap
--- @shortDescription Aligns the element's top edge to its parent's top edge with optional gap
--- @param gap? number Gap from the edge (default: 0)
--- @return VisualElement self
function VisualElement:toTop(gap)
return self:alignTop("parent", gap or 0)
end
--- Aligns the element's bottom edge to its parent's bottom edge with optional gap
--- @shortDescription Aligns the element's bottom edge to its parent's bottom edge with optional gap
--- @param gap? number Gap from the edge (default: 0)
--- @return VisualElement self
function VisualElement:toBottom(gap)
return self:alignBottom("parent", -(gap or 0))
end
--- @shortDescription Multi-character drawing with colors
--- @param x number The x position to draw
--- @param y number The y position to draw
@@ -267,7 +744,7 @@ end
--- @protected
function VisualElement:mouse_click(button, x, y)
if self:isInBounds(x, y) then
self.set("clicked", true)
self:setState("clicked")
self:fireEvent("mouse_click", button, self:getRelativePosition(x, y))
return true
end
@@ -282,7 +759,8 @@ end
--- @protected
function VisualElement:mouse_up(button, x, y)
if self:isInBounds(x, y) then
self.set("clicked", false)
self:unsetState("clicked")
self:unsetState("dragging")
self:fireEvent("mouse_up", button, self:getRelativePosition(x, y))
return true
end
@@ -296,7 +774,8 @@ end
--- @protected
function VisualElement:mouse_release(button, x, y)
self:fireEvent("mouse_release", button, self:getRelativePosition(x, y))
self.set("clicked", false)
self:unsetState("clicked")
self:unsetState("dragging")
end
---@shortDescription Handles a mouse move event
@@ -344,13 +823,51 @@ end
--- @return boolean drag Whether the element was dragged
--- @protected
function VisualElement:mouse_drag(button, x, y)
if(self.get("clicked"))then
if(self:hasState("clicked"))then
self:fireEvent("mouse_drag", button, self:getRelativePosition(x, y))
return true
end
return false
end
--- Sets or removes focus from this element
--- @shortDescription Sets focus state
--- @param focused boolean Whether to focus or blur
--- @param internal? boolean Internal flag to prevent parent notification
--- @return VisualElement self
function VisualElement:setFocused(focused, internal)
local currentlyFocused = self:hasState("focused")
if focused == currentlyFocused then
return self
end
if focused then
self:setState("focused")
self:focus()
if not internal and self.parent then
self.parent:setFocusedChild(self)
end
else
self:unsetState("focused")
self:blur()
if not internal and self.parent then
self.parent:setFocusedChild(nil)
end
end
return self
end
--- Gets whether this element is focused
--- @shortDescription Checks if element is focused
--- @return boolean isFocused
function VisualElement:isFocused()
return self:hasState("focused")
end
--- @shortDescription Handles a focus event
--- @protected
function VisualElement:focus()
@@ -362,7 +879,14 @@ end
function VisualElement:blur()
self:fireEvent("blur")
-- Attempt to clear cursor; signature may expect (x,y,blink,fg,bg)
pcall(function() self:setCursor(1,1,false, self.get and self.get("foreground")) end)
pcall(function() self:setCursor(1,1,false, self.get and self.getResolved("foreground")) end)
end
--- Gets whether this element is focused
--- @shortDescription Checks if element is focused
--- @return boolean isFocused
function VisualElement:isFocused()
return self:hasState("focused")
end
--- Adds or updates a drawable character border around the element using the canvas plugin.
@@ -410,7 +934,7 @@ end
--- @param key number The key that was pressed
--- @protected
function VisualElement:key(key, held)
if(self.get("focused"))then
if(self:hasState("focused"))then
self:fireEvent("key", key, held)
end
end
@@ -419,7 +943,7 @@ end
--- @param key number The key that was released
--- @protected
function VisualElement:key_up(key)
if(self.get("focused"))then
if(self:hasState("focused"))then
self:fireEvent("key_up", key)
end
end
@@ -428,7 +952,7 @@ end
--- @param char string The character that was pressed
--- @protected
function VisualElement:char(char)
if(self.get("focused"))then
if(self:hasState("focused"))then
self:fireEvent("char", char)
end
end
@@ -438,6 +962,7 @@ end
--- @return number x The x position
--- @return number y The y position
function VisualElement:calculatePosition()
self:resolveAllConstraints()
local x, y = self.get("x"), self.get("y")
if not self.get("ignoreOffset") then
if self.parent ~= nil then
@@ -531,31 +1056,36 @@ end
--- @shortDescription Renders the element
--- @protected
function VisualElement:render()
if(not self.get("backgroundEnabled"))then return end
if(not self.getResolved("backgroundEnabled"))then return end
local width, height = self.get("width"), self.get("height")
local fgHex = tHex[self.get("foreground")]
local bgHex = tHex[self.get("background")]
local fgHex = tHex[self.getResolved("foreground")]
local bgHex = tHex[self.getResolved("background")]
local bTop, bBottom, bLeft, bRight =
self.getResolved("borderTop"),
self.getResolved("borderBottom"),
self.getResolved("borderLeft"),
self.getResolved("borderRight")
self:multiBlit(1, 1, width, height, " ", fgHex, bgHex)
if (self.get("borderTop") or self.get("borderBottom") or self.get("borderLeft") or self.get("borderRight")) then
local bColor = self.get("borderColor") or self.get("foreground")
if (bTop or bBottom or bLeft or bRight) then
local bColor = self.getResolved("borderColor") or self.getResolved("foreground")
local bHex = tHex[bColor] or fgHex
if self.get("borderTop") then
if bTop then
self:textFg(1,1,("\131"):rep(width), bColor)
end
if self.get("borderBottom") then
if bBottom then
self:multiBlit(1,height,width,1,"\143", bgHex, bHex)
end
if self.get("borderLeft") then
if bLeft then
self:multiBlit(1,1,1,height,"\149", bHex, bgHex)
end
if self.get("borderRight") then
if bRight then
self:multiBlit(width,1,1,height,"\149", bgHex, bHex)
end
-- Corners
if self.get("borderTop") and self.get("borderLeft") then self:blit(1,1,"\151", bHex, bgHex) end
if self.get("borderTop") and self.get("borderRight") then self:blit(width,1,"\148", bgHex, bHex) end
if self.get("borderBottom") and self.get("borderLeft") then self:blit(1,height,"\138", bgHex, bHex) end
if self.get("borderBottom") and self.get("borderRight") then self:blit(width,height,"\133", bgHex, bHex) end
if bTop and bLeft then self:blit(1,1,"\151", bHex, bgHex) end
if bTop and bRight then self:blit(width,1,"\148", bgHex, bHex) end
if bBottom and bLeft then self:blit(1,height,"\138", bgHex, bHex) end
if bBottom and bRight then self:blit(width,height,"\133", bgHex, bHex) end
end
end
@@ -565,8 +1095,9 @@ function VisualElement:postRender()
end
function VisualElement:destroy()
self:_removeAllConstraintObservers()
self.set("visible", false)
BaseElement.destroy(self)
end
return VisualElement
return VisualElement

View File

@@ -0,0 +1,144 @@
local CollectionEntry = {}
CollectionEntry.__index = function(entry, key)
local self_method = rawget(CollectionEntry, key)
if self_method then
return self_method
end
if entry._data[key] ~= nil then
return entry._data[key]
end
local parent_method = entry._parent[key]
if parent_method then
return parent_method
end
return nil
end
function CollectionEntry.new(parent, data)
local instance = {
_parent = parent,
_data = data
}
return setmetatable(instance, CollectionEntry)
end
function CollectionEntry:_findIndex()
for i, entry in ipairs(self._parent:getItems()) do
if entry == self then
return i
end
end
return nil
end
function CollectionEntry:setText(text)
self._data.text = text
self._parent:updateRender()
return self
end
function CollectionEntry:getText()
return self._data.text
end
function CollectionEntry:moveUp(amount)
local items = self._parent:getItems()
local currentIndex = self:_findIndex()
if not currentIndex then return self end
amount = amount or 1
local newIndex = math.max(1, currentIndex - amount)
if currentIndex ~= newIndex then
table.remove(items, currentIndex)
table.insert(items, newIndex, self)
self._parent:updateRender()
end
return self
end
function CollectionEntry:moveDown(amount)
local items = self._parent:getItems()
local currentIndex = self:_findIndex()
if not currentIndex then return self end
amount = amount or 1
local newIndex = math.min(#items, currentIndex + amount)
if currentIndex ~= newIndex then
table.remove(items, currentIndex)
table.insert(items, newIndex, self)
self._parent:updateRender()
end
return self
end
function CollectionEntry:moveToTop()
local items = self._parent:getItems()
local currentIndex = self:_findIndex()
if not currentIndex or currentIndex == 1 then return self end
table.remove(items, currentIndex)
table.insert(items, 1, self)
self._parent:updateRender()
return self
end
function CollectionEntry:moveToBottom()
local items = self._parent:getItems()
local currentIndex = self:_findIndex()
if not currentIndex or currentIndex == #items then return self end
table.remove(items, currentIndex)
table.insert(items, self)
self._parent:updateRender()
return self
end
function CollectionEntry:getIndex()
return self:_findIndex()
end
function CollectionEntry:swapWith(otherEntry)
local items = self._parent:getItems()
local indexA = self:getIndex()
local indexB = otherEntry:getIndex()
if indexA and indexB and indexA ~= indexB then
items[indexA], items[indexB] = items[indexB], items[indexA]
self._parent:updateRender()
end
return self
end
function CollectionEntry:remove()
if self._parent and self._parent.removeItem then
self._parent:removeItem(self)
return true
end
return false
end
function CollectionEntry:select()
if self._parent and self._parent.selectItem then
self._parent:selectItem(self)
end
return self
end
function CollectionEntry:unselect()
if self._parent and self._parent.unselectItem then
self._parent:unselectItem(self)
end
end
function CollectionEntry:isSelected()
if self._parent and self._parent.getSelectedItem then
return self._parent:getSelectedItem() == self
end
return false
end
return CollectionEntry

View File

@@ -420,7 +420,6 @@ end
--- @usage basalt.triggerEvent("custom_event", "data1", "data2")
function basalt.triggerEvent(eventName, ...)
expect(1, eventName, "string")
if basalt._events[eventName] then
for _, callback in ipairs(basalt._events[eventName]) do
local ok, err = pcall(callback, ...)
@@ -432,4 +431,174 @@ function basalt.triggerEvent(eventName, ...)
end
end
--- Requires specific elements and validates they are available
--- @shortDescription Requires elements for the application
--- @param elements table|string List of element names or single element name
--- @param autoLoad? boolean Whether to automatically load missing elements (default: false)
--- @usage basalt.requireElements({"Button", "Label", "Slider"})
--- @usage basalt.requireElements("Button", true)
function basalt.requireElements(elements, autoLoad)
if type(elements) == "string" then
elements = {elements}
end
expect(1, elements, "table")
if autoLoad ~= nil then
expect(2, autoLoad, "boolean")
end
local missing = {}
local notLoaded = {}
for _, elementName in ipairs(elements) do
if not elementManager.hasElement(elementName) then
table.insert(missing, elementName)
elseif not elementManager.isElementLoaded(elementName) then
table.insert(notLoaded, elementName)
end
end
if #notLoaded > 0 then
for _, name in ipairs(notLoaded) do
local ok, err = pcall(elementManager.loadElement, name)
if not ok then
basalt.LOGGER.warn("Failed to load element "..name..": "..tostring(err))
table.insert(missing, name)
end
end
end
if #missing > 0 then
if autoLoad then
local stillMissing = {}
for _, name in ipairs(missing) do
local ok = elementManager.tryAutoLoad(name)
if not ok then
table.insert(stillMissing, name)
end
end
if #stillMissing > 0 then
local msg = "Missing required elements: " .. table.concat(stillMissing, ", ")
msg = msg .. "\n\nThese elements could not be auto-loaded."
msg = msg .. "\nPlease install them or register remote sources."
errorManager.error(msg)
end
else
local msg = "Missing required elements: " .. table.concat(missing, ", ")
msg = msg .. "\n\nSuggestions:"
msg = msg .. "\n • Use basalt.requireElements({...}, true) to auto-load"
msg = msg .. "\n • Register remote sources with elementManager.registerRemoteSource()"
msg = msg .. "\n • Register disk mounts with elementManager.registerDiskMount()"
errorManager.error(msg)
end
end
basalt.LOGGER.info("All required elements are available: " .. table.concat(elements, ", "))
return true
end
--- Loads a manifest file that describes element requirements and configuration
--- @shortDescription Loads an application manifest
--- @param path string The path to the manifest file
--- @return table manifest The loaded manifest data
--- @usage basalt.loadManifest("myapp.manifest")
function basalt.loadManifest(path)
expect(1, path, "string")
if not fs.exists(path) then
errorManager.error("Manifest file not found: " .. path)
end
local manifest
local ok, result = pcall(dofile, path)
if not ok then
errorManager.error("Failed to load manifest: " .. tostring(result))
end
manifest = result
if type(manifest) ~= "table" then
errorManager.error("Manifest must return a table")
end
if manifest.config then
elementManager.configure(manifest.config)
basalt.LOGGER.debug("Applied manifest config")
end
if manifest.diskMounts then
for _, mountPath in ipairs(manifest.diskMounts) do
elementManager.registerDiskMount(mountPath)
end
end
if manifest.remoteSources then
for elementName, url in pairs(manifest.remoteSources) do
elementManager.registerRemoteSource(elementName, url)
end
end
if manifest.requiredElements then
local autoLoad = manifest.autoLoadMissing ~= false
basalt.requireElements(manifest.requiredElements, autoLoad)
end
if manifest.optionalElements then
for _, name in ipairs(manifest.optionalElements) do
pcall(elementManager.loadElement, name)
end
end
if manifest.preloadElements then
elementManager.preloadElements(manifest.preloadElements)
end
basalt.LOGGER.info("Manifest loaded successfully: " .. (manifest.name or path))
return manifest
end
--- Installs an element interactively or from a specified source
--- @shortDescription Installs an element
--- @param elementName string The name of the element to install
--- @param source? string Optional source URL or path
--- @usage basalt.install("Slider")
--- @usage basalt.install("Slider", "https://example.com/slider.lua")
function basalt.install(elementName, source)
expect(1, elementName, "string")
if source ~= nil then
expect(2, source, "string")
end
if elementManager.hasElement(elementName) and elementManager.isElementLoaded(elementName) then
return true
end
if source then
if source:match("^https?://") then
elementManager.registerRemoteSource(elementName, source)
else
if not fs.exists(source) then
errorManager.error("Source file not found: " .. source)
end
end
end
local ok = elementManager.tryAutoLoad(elementName)
if ok then
return true
else
return false
end
end
--- Configures the ElementManager (shortcut to elementManager.configure)
--- @shortDescription Configures element loading behavior
--- @param config table Configuration options
--- @usage basalt.configure({allowRemoteLoading = true, useGlobalCache = true})
function basalt.configure(config)
expect(1, config, "table")
elementManager.configure(config)
end
return basalt

View File

@@ -1,226 +0,0 @@
local PropertySystem = require("propertySystem")
local errorManager = require("errorManager")
---@class BaseFrame : Container
local BaseFrame = {}
function BaseFrame.setup(element)
element.defineProperty(element, "states", {default = {}, type = "table"})
element.defineProperty(element, "stateObserver", {default = {}, type = "table"})
end
--- Initializes a new state for this element
--- @shortDescription Initializes a new state
--- @param self BaseFrame The element to initialize state for
--- @param name string The name of the state
--- @param default any The default value of the state
--- @param persist? boolean Whether to persist the state to disk
--- @param path? string Custom file path for persistence
--- @return BaseFrame self The element instance
function BaseFrame:initializeState(name, default, persist, path)
local states = self.get("states")
if states[name] then
errorManager.error("State '" .. name .. "' already exists")
return self
end
local file = path or "states/" .. self.get("name") .. ".state"
local persistedData = {}
if persist and fs.exists(file) then
local f = fs.open(file, "r")
persistedData = textutils.unserialize(f.readAll()) or {}
f.close()
end
states[name] = {
value = persist and persistedData[name] or default,
persist = persist,
}
return self
end
--- This is the state plugin. It provides a state management system for UI elements with support for
--- persistent states, computed states, and state sharing between elements.
---@class BaseElement
local BaseElement = {}
--- Sets the value of a state
--- @shortDescription Sets a state value
--- @param self BaseElement The element to set state for
--- @param name string The name of the state
--- @param value any The new value for the state
--- @return BaseElement self The element instance
function BaseElement:setState(name, value)
local main = self:getBaseFrame()
local states = main.get("states")
local observers = main.get("stateObserver")
if not states[name] then
errorManager.error("State '"..name.."' not initialized")
end
if states[name].persist then
local file = "states/" .. main.get("name") .. ".state"
local persistedData = {}
if fs.exists(file) then
local f = fs.open(file, "r")
persistedData = textutils.unserialize(f.readAll()) or {}
f.close()
end
persistedData[name] = value
local dir = fs.getDir(file)
if not fs.exists(dir) then
fs.makeDir(dir)
end
local f = fs.open(file, "w")
f.write(textutils.serialize(persistedData))
f.close()
end
states[name].value = value
-- Trigger observers
if observers[name] then
for _, callback in ipairs(observers[name]) do
callback(name, value)
end
end
-- Recompute all computed states
for stateName, state in pairs(states) do
if state.computed then
state.value = state.computeFn(self)
if observers[stateName] then
for _, callback in ipairs(observers[stateName]) do
callback(stateName, state.value)
end
end
end
end
return self
end
--- Gets the value of a state
--- @shortDescription Gets a state value
--- @param self BaseElement The element to get state from
--- @param name string The name of the state
--- @return any value The current state value
function BaseElement:getState(name)
local main = self:getBaseFrame()
local states = main.get("states")
if not states[name] then
errorManager.error("State '"..name.."' not initialized")
end
if states[name].computed then
return states[name].computeFn(self)
end
return states[name].value
end
--- Registers a callback for state changes
--- @shortDescription Watches for state changes
--- @param self BaseElement The element to watch
--- @param stateName string The state to watch
--- @param callback function Called with (element, newValue, oldValue)
--- @return BaseElement self The element instance
function BaseElement:onStateChange(stateName, callback)
local main = self:getBaseFrame()
local state = main.get("states")[stateName]
if not state then
errorManager.error("Cannot observe state '" .. stateName .. "': State not initialized")
return self
end
local observers = main.get("stateObserver")
if not observers[stateName] then
observers[stateName] = {}
end
table.insert(observers[stateName], callback)
return self
end
--- Removes a state change observer
--- @shortDescription Removes a state change observer
--- @param self BaseElement The element to remove observer from
--- @param stateName string The state to remove observer from
--- @param callback function The callback function to remove
--- @return BaseElement self The element instance
function BaseElement:removeStateChange(stateName, callback)
local main = self:getBaseFrame()
local observers = main.get("stateObserver")
if observers[stateName] then
for i, observer in ipairs(observers[stateName]) do
if observer == callback then
table.remove(observers[stateName], i)
break
end
end
end
return self
end
function BaseElement:computed(name, func)
local main = self:getBaseFrame()
local states = main.get("states")
if states[name] then
errorManager.error("Computed state '" .. name .. "' already exists")
return self
end
states[name] = {
computeFn = func,
value = func(self),
computed = true,
}
return self
end
--- Binds a property to a state
--- @param self BaseElement The element to bind
--- @param propertyName string The property to bind
--- @param stateName string The state to bind to (optional, uses propertyName if not provided)
--- @return BaseElement self The element instance
function BaseElement:bind(propertyName, stateName)
stateName = stateName or propertyName
local main = self:getBaseFrame()
local internalCall = false
if self.get(propertyName) ~= nil then
self.set(propertyName, main:getState(stateName))
end
self:onChange(propertyName, function(self, value)
if internalCall then return end
internalCall = true
self:setState(stateName, value)
internalCall = false
end)
self:onStateChange(stateName, function(name, value)
if internalCall then return end
internalCall = true
if self.get(propertyName) ~= nil then
self.set(propertyName, value)
end
internalCall = false
end)
return self
end
return {
BaseElement = BaseElement,
BaseFrame = BaseFrame
}

View File

@@ -18,8 +18,8 @@ local defaultTheme = {
},
},
Button = {
background = "{self.clicked and colors.black or colors.cyan}",
foreground = "{self.clicked and colors.cyan or colors.black}",
background = colors.cyan,
foreground = colors.black,
},
names = {
@@ -27,10 +27,6 @@ local defaultTheme = {
background = colors.red,
foreground = colors.white
},
test = {
background = "{self.clicked and colors.black or colors.green}",
foreground = "{self.clicked and colors.green or colors.black}"
}
},
}
}

View File

@@ -88,6 +88,17 @@ function PropertySystem.defineProperty(class, name, config)
self:_updateProperty(name, value)
return self
end
class["get" .. capitalizedName .. "State"] = function(self, state, ...)
expect(1, self, "element")
return self.getPropertyState(name, state, ...)
end
class["set" .. capitalizedName .. "State"] = function(self, state, value, ...)
expect(1, self, "element")
self.setPropertyState(name, state, value, ...)
return self
end
end
--- Combines multiple properties into a single getter and setter
@@ -251,6 +262,7 @@ end
function PropertySystem:__init()
self._values = {}
self._observers = {}
self._states = {}
self.set = function(name, value, ...)
local oldValue = self._values[name]
@@ -281,6 +293,65 @@ function PropertySystem:__init()
return config.getter and config.getter(self, value, ...) or value
end
self.setPropertyState = function(name, state, value, ...)
local config = self._properties[name]
if(config~=nil)then
if(config.setter) then
value = config.setter(self, value, ...)
end
value = applyHooks(self, name, value, config)
if not self._states[state] then
self._states[state] = {}
end
self._states[state][name] = value
local currentState = self._values.currentState
if currentState == state then
if config.canTriggerRender then
self:updateRender()
end
if self._observers[name] then
for _, callback in ipairs(self._observers[name]) do
callback(self, value, nil)
end
end
end
end
end
self.getPropertyState = function(name, state, ...)
local stateValue = self._states and self._states[state] and self._states[state][name]
local value = stateValue ~= nil and stateValue or self._values[name]
local config = self._properties[name]
if(config==nil)then errorManager.error("Property not found: "..name) return end
if type(value) == "function" and config.type ~= "function" then
value = value(self)
end
return config.getter and config.getter(self, value, ...) or value
end
self.getResolved = function(name, ...)
local currentState = self:getCurrentState()
local value
if currentState and self._states and self._states[currentState] and self._states[currentState][name] ~= nil then
value = self._states[currentState][name]
else
value = self._values[name]
end
local config = self._properties[name]
if(config==nil)then errorManager.error("Property not found: "..name) return end
if type(value) == "function" and config.type ~= "function" then
value = value(self)
end
return config.getter and config.getter(self, value, ...) or value
end
local properties = {}
local currentClass = getmetatable(self).__index
@@ -439,6 +510,8 @@ function PropertySystem:removeProperty(name)
local capitalizedName = name:sub(1,1):upper() .. name:sub(2)
self["get" .. capitalizedName] = nil
self["set" .. capitalizedName] = nil
self["get" .. capitalizedName .. "State"] = nil
self["set" .. capitalizedName .. "State"] = nil
return self
end