diff --git a/.gitignore b/.gitignore index 5ed29ae..e0c4148 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ Accordion.lua Stepper.lua Drawer.lua Breadcrumb.lua -Dialog.lua \ No newline at end of file +Dialog.lua +DockLayout.lua +ContextMenu.lua \ No newline at end of file diff --git a/src/elementManager.lua b/src/elementManager.lua index caaf986..6800c84 100644 --- a/src/elementManager.lua +++ b/src/elementManager.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 \ No newline at end of file diff --git a/src/elements/BaseElement.lua b/src/elements/BaseElement.lua index 65709d7..fd003be 100644 --- a/src/elements/BaseElement.lua +++ b/src/elements/BaseElement.lua @@ -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 diff --git a/src/elements/Button.lua b/src/elements/Button.lua index a15619e..d41102b 100644 --- a/src/elements/Button.lua +++ b/src/elements/Button.lua @@ -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 \ No newline at end of file diff --git a/src/elements/CheckBox.lua b/src/elements/CheckBox.lua index ef5c579..9cc3e08 100644 --- a/src/elements/CheckBox.lua +++ b/src/elements/CheckBox.lua @@ -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 \ No newline at end of file diff --git a/src/elements/Collection.lua b/src/elements/Collection.lua new file mode 100644 index 0000000..0f74deb --- /dev/null +++ b/src/elements/Collection.lua @@ -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 \ No newline at end of file diff --git a/src/elements/ComboBox.lua b/src/elements/ComboBox.lua index 3ddeaef..1fd6239 100644 --- a/src/elements/ComboBox.lua +++ b/src/elements/ComboBox.lua @@ -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 \ No newline at end of file diff --git a/src/elements/Container.lua b/src/elements/Container.lua index eaf2a67..90ac870 100644 --- a/src/elements/Container.lua +++ b/src/elements/Container.lua @@ -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)) diff --git a/src/elements/DropDown.lua b/src/elements/DropDown.lua index d5e6b58..75e07b0 100644 --- a/src/elements/DropDown.lua +++ b/src/elements/DropDown.lua @@ -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 \ No newline at end of file diff --git a/src/elements/Input.lua b/src/elements/Input.lua index c65c97e..cc101f1 100644 --- a/src/elements/Input.lua +++ b/src/elements/Input.lua @@ -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 \ No newline at end of file diff --git a/src/elements/List.lua b/src/elements/List.lua index 099de8a..c629525 100644 --- a/src/elements/List.lua +++ b/src/elements/List.lua @@ -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 diff --git a/src/elements/Program.lua b/src/elements/Program.lua index e752a93..a92c4d0 100644 --- a/src/elements/Program.lua +++ b/src/elements/Program.lua @@ -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()) diff --git a/src/elements/TextBox.lua b/src/elements/TextBox.lua index 83a3880..b5da16e 100644 --- a/src/elements/TextBox.lua +++ b/src/elements/TextBox.lua @@ -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 diff --git a/src/elements/VisualElement.lua b/src/elements/VisualElement.lua index 6e7dc19..94fee64 100644 --- a/src/elements/VisualElement.lua +++ b/src/elements/VisualElement.lua @@ -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 \ No newline at end of file diff --git a/src/libraries/collectionentry.lua b/src/libraries/collectionentry.lua new file mode 100644 index 0000000..c81b095 --- /dev/null +++ b/src/libraries/collectionentry.lua @@ -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 \ No newline at end of file diff --git a/src/main.lua b/src/main.lua index ebcaea1..3e57229 100644 --- a/src/main.lua +++ b/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 \ No newline at end of file diff --git a/src/plugins/state.lua b/src/plugins/state.lua deleted file mode 100644 index 9f15a90..0000000 --- a/src/plugins/state.lua +++ /dev/null @@ -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 -} diff --git a/src/plugins/theme.lua b/src/plugins/theme.lua index c6e45e5..f151fab 100644 --- a/src/plugins/theme.lua +++ b/src/plugins/theme.lua @@ -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}" - } }, } } diff --git a/src/propertySystem.lua b/src/propertySystem.lua index ba283ee..0c85b0b 100644 --- a/src/propertySystem.lua +++ b/src/propertySystem.lua @@ -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