- 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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -15,4 +15,6 @@ Accordion.lua
|
||||
Stepper.lua
|
||||
Drawer.lua
|
||||
Breadcrumb.lua
|
||||
Dialog.lua
|
||||
Dialog.lua
|
||||
DockLayout.lua
|
||||
ContextMenu.lua
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
182
src/elements/Collection.lua
Normal 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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
144
src/libraries/collectionentry.lua
Normal file
144
src/libraries/collectionentry.lua
Normal 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
|
||||
171
src/main.lua
171
src/main.lua
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user