From 41bd5bdf041f6e73d056b3b306b6e371039b064b Mon Sep 17 00:00:00 2001 From: Robert Jelic <36573031+NoryiE@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:10:23 +0100 Subject: [PATCH] - Added DropDown Scrollbar - Added List Scrollbar - Added Statemanagementsystem for XML --- src/elements/Collection.lua | 59 ++++++++ src/elements/DropDown.lua | 151 ++++++++++---------- src/elements/List.lua | 277 ++++++++++++++++++++++++++++++++---- src/plugins/xml.lua | 122 +++++++++++++--- 4 files changed, 487 insertions(+), 122 deletions(-) diff --git a/src/elements/Collection.lua b/src/elements/Collection.lua index 0f74deb..2b9ecf5 100644 --- a/src/elements/Collection.lua +++ b/src/elements/Collection.lua @@ -166,6 +166,65 @@ function Collection:clearItemSelection() for i, item in ipairs(items) do item.selected = false end + self:updateRender() + return self +end + +--- Gets the index of the first selected item +--- @shortDescription Gets the index of the first selected item +--- @return number? index The index of the first selected item, or nil if none selected +--- @usage local index = Collection:getSelectedIndex() +function Collection:getSelectedIndex() + local items = self.get("items") + for i, item in ipairs(items) do + if type(item) == "table" and item.selected then + return i + end + end + return nil +end + +--- Selects the next item in the collection +--- @shortDescription Selects the next item +--- @return Collection self The Collection instance +function Collection:selectNext() + local items = self.get("items") + local currentIndex = self:getSelectedIndex() + + if not currentIndex then + if #items > 0 then + self:selectItem(1) + end + elseif currentIndex < #items then + if not self.get("multiSelection") then + self:clearItemSelection() + end + self:selectItem(currentIndex + 1) + end + + self:updateRender() + return self +end + +--- Selects the previous item in the collection +--- @shortDescription Selects the previous item +--- @return Collection self The Collection instance +function Collection:selectPrevious() + local items = self.get("items") + local currentIndex = self:getSelectedIndex() + + if not currentIndex then + if #items > 0 then + self:selectItem(#items) + end + elseif currentIndex > 1 then + if not self.get("multiSelection") then + self:clearItemSelection() + end + self:selectItem(currentIndex - 1) + end + + self:updateRender() return self end diff --git a/src/elements/DropDown.lua b/src/elements/DropDown.lua index 75e07b0..acc64b5 100644 --- a/src/elements/DropDown.lua +++ b/src/elements/DropDown.lua @@ -1,6 +1,5 @@ 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 @@ -107,42 +106,80 @@ function DropDown:mouse_click(button, x, y) self:setState("opened") end return true - elseif isOpen and relY > 1 and self.get("selectable") then - local itemIndex = (relY - 1) + self.get("offset") - local items = self.get("items") - - if itemIndex <= #items then - local item = items[itemIndex] - if type(item) == "string" then - item = {text = item} - items[itemIndex] = item - end - - if not self.get("multiSelection") then - for _, otherItem in ipairs(items) do - if type(otherItem) == "table" then - otherItem.selected = false - end - end - end - - item.selected = not item.selected - - if item.callback then - item.callback(self) - end - - self:fireEvent("select", itemIndex, item) - self:unsetState("opened") - self:unsetState("clicked") - self.set("height", 1) - self:updateRender() - return true - end + elseif isOpen and relY > 1 then + -- Forward to List handler for scrollbar handling + return List.mouse_click(self, button, x, y - 1) end return false end +--- @shortDescription Handles mouse drag events for scrollbar +--- @param button number The mouse button being dragged +--- @param x number The x-coordinate of the drag +--- @param y number The y-coordinate of the drag +--- @return boolean Whether the event was handled +--- @protected +function DropDown:mouse_drag(button, x, y) + if self:hasState("opened") then + return List.mouse_drag(self, button, x, y - 1) + end + return VisualElement.mouse_drag and VisualElement.mouse_drag(self, button, x, y) or false +end + +--- @shortDescription Handles mouse up events to stop scrollbar dragging +--- @param button number The mouse button that was released +--- @param x number The x-coordinate of the release +--- @param y number The y-coordinate of the release +--- @return boolean Whether the event was handled +--- @protected +function DropDown:mouse_up(button, x, y) + if self:hasState("opened") then + local relX, relY = self:getRelativePosition(x, y) + + -- Only handle item selection in mouse_up (relY > 1 = list area) + if relY > 1 and self.get("selectable") and not self._scrollBarDragging then + local itemIndex = (relY - 1) + self.get("offset") + local items = self.get("items") + + if itemIndex <= #items then + local item = items[itemIndex] + if type(item) == "string" then + item = {text = item} + items[itemIndex] = item + end + + if not self.get("multiSelection") then + for _, otherItem in ipairs(items) do + if type(otherItem) == "table" then + otherItem.selected = false + end + end + end + + item.selected = not item.selected + + if item.callback then + item.callback(self) + end + + self:fireEvent("select", itemIndex, item) + self:unsetState("opened") + self:unsetState("clicked") + self.set("height", 1) + self:updateRender() + + return true + end + end + + -- Always forward to List for cleanup and unset clicked state + List.mouse_up(self, button, x, y - 1) + self:unsetState("clicked") + return true + end + return VisualElement.mouse_up and VisualElement.mouse_up(self, button, x, y) or false +end + --- @shortDescription Renders the DropDown --- @protected function DropDown:render() @@ -157,53 +194,13 @@ function DropDown:render() text = text:sub(1, self.get("width") - 2) end + if isOpen then + List.render(self, 1) + end + 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 isOpen then - local items = self.get("items") - local height = self.get("height") - 1 - local offset = self.get("offset") - local width = self.get("width") - - 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.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) - else - local text = item.text - local isSelected = item.selected - text = text:sub(1, width) - - local bg = isSelected and - (item.selectedBg or self.getResolved("selectedBackground")) or - (item.bg or self.getResolved("background")) - - local fg = isSelected and - (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) - end - end - end - end end --- Called when the DropDown gains focus diff --git a/src/elements/List.lua b/src/elements/List.lua index c629525..55ec63d 100644 --- a/src/elements/List.lua +++ b/src/elements/List.lua @@ -1,4 +1,5 @@ local Collection = require("elements/Collection") +local tHex = require("libraries/colorHex") ---@configDescription A scrollable list of selectable items --- This is the list class. It provides a scrollable list of selectable items with support for @@ -8,12 +9,40 @@ local List = setmetatable({}, Collection) List.__index = List ---@property offset number 0 Current scroll offset for viewing long lists -List.defineProperty(List, "offset", {default = 0, type = "number", canTriggerRender = true}) +List.defineProperty(List, "offset", { + default = 0, + type = "number", + canTriggerRender = true, + setter = function(self, value) + local maxOffset = math.max(0, #self.get("items") - self.get("height")) + return math.min(maxOffset, math.max(0, value)) + end +}) + +---@property emptyText string "No items" Text to display when the list is empty +List.defineProperty(List, "emptyText", {default = "No items", type = "string", canTriggerRender = true}) + +---@property showScrollBar boolean true Whether to show the scrollbar when items exceed height +List.defineProperty(List, "showScrollBar", {default = true, type = "boolean", canTriggerRender = true}) + +---@property scrollBarSymbol string " " Symbol used for the scrollbar handle +List.defineProperty(List, "scrollBarSymbol", {default = " ", type = "string", canTriggerRender = true}) + +---@property scrollBarBackground string "\127" Symbol used for the scrollbar background +List.defineProperty(List, "scrollBarBackground", {default = "\127", type = "string", canTriggerRender = true}) + +---@property scrollBarColor color lightGray Color of the scrollbar handle +List.defineProperty(List, "scrollBarColor", {default = colors.lightGray, type = "color", canTriggerRender = true}) + +---@property scrollBarBackgroundColor color gray Background color of the scrollbar +List.defineProperty(List, "scrollBarBackgroundColor", {default = colors.gray, type = "color", canTriggerRender = true}) ---@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_drag") List.defineEvent(List, "mouse_scroll") +List.defineEvent(List, "key") local entrySchema = { text = { type = "string", default = "Entry" }, @@ -47,6 +76,21 @@ function List:init(props, basalt) Collection.init(self, props, basalt) self._entrySchema = entrySchema self.set("type", "List") + + self:observe("items", function() + local maxOffset = math.max(0, #self.get("items") - self.get("height")) + if self.get("offset") > maxOffset then + self.set("offset", maxOffset) + end + end) + + self:observe("height", function() + local maxOffset = math.max(0, #self.get("items") - self.get("height")) + if self.get("offset") > maxOffset then + self.set("offset", maxOffset) + end + end) + return self end @@ -57,34 +101,98 @@ end --- @return boolean Whether the event was handled --- @protected function List:mouse_click(button, x, y) - 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") + if Collection.mouse_click(self, button, x, y) then + local relX, relY = self:getRelativePosition(x, y) + local width = self.get("width") local items = self.get("items") + local height = self.get("height") + local showScrollBar = self.get("showScrollBar") - if adjustedIndex <= #items then - local item = items[adjustedIndex] - if not self.get("multiSelection") then - for _, otherItem in ipairs(items) do - if type(otherItem) == "table" then - otherItem.selected = false + if showScrollBar and #items > height and relX == width then + local maxOffset = #items - height + local handleSize = math.max(1, math.floor((height / #items) * height)) + + local currentPercent = maxOffset > 0 and (self.get("offset") / maxOffset * 100) or 0 + local handlePos = math.floor((currentPercent / 100) * (height - handleSize)) + 1 + + if relY >= handlePos and relY < handlePos + handleSize then + self._scrollBarDragging = true + self._scrollBarDragOffset = relY - handlePos + else + local newPercent = ((relY - 1) / (height - handleSize)) * 100 + local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5) + self.set("offset", math.max(0, math.min(maxOffset, newOffset))) + end + return true + end + + if self.get("selectable") then + local adjustedIndex = relY + self.get("offset") + + if adjustedIndex <= #items then + local item = items[adjustedIndex] + if not self.get("multiSelection") then + for _, otherItem in ipairs(items) do + if type(otherItem) == "table" then + otherItem.selected = false + end end end - end - item.selected = not item.selected + item.selected = not item.selected - if item.callback then - item.callback(self) + if item.callback then + item.callback(self) + end + self:fireEvent("select", adjustedIndex, item) + self:updateRender() end - self:fireEvent("select", adjustedIndex, item) - self:updateRender() end return true end return false end +--- @shortDescription Handles mouse drag events for scrollbar +--- @param button number The mouse button being dragged +--- @param x number The x-coordinate of the drag +--- @param y number The y-coordinate of the drag +--- @return boolean Whether the event was handled +--- @protected +function List:mouse_drag(button, x, y) + if self._scrollBarDragging then + local _, relY = self:getRelativePosition(x, y) + local items = self.get("items") + local height = self.get("height") + local handleSize = math.max(1, math.floor((height / #items) * height)) + local maxOffset = #items - height + relY = math.max(1, math.min(height, relY)) + + local newPos = relY - (self._scrollBarDragOffset or 0) + local newPercent = ((newPos - 1) / (height - handleSize)) * 100 + local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5) + + self.set("offset", math.max(0, math.min(maxOffset, newOffset))) + return true + end + return Collection.mouse_drag and Collection.mouse_drag(self, button, x, y) or false +end + +--- @shortDescription Handles mouse up events to stop scrollbar dragging +--- @param button number The mouse button that was released +--- @param x number The x-coordinate of the release +--- @param y number The y-coordinate of the release +--- @return boolean Whether the event was handled +--- @protected +function List:mouse_up(button, x, y) + if self._scrollBarDragging then + self._scrollBarDragging = false + self._scrollBarDragOffset = nil + return true + end + return Collection.mouse_up and Collection.mouse_up(self, button, x, y) or false +end + --- @shortDescription Handles mouse scroll events --- @param direction number The direction of the scroll (1 for down, -1 for up) --- @param x number The x-coordinate of the scroll @@ -130,9 +238,78 @@ function List:scrollToTop() return self end +--- Scrolls to make a specific item visible +--- @shortDescription Scrolls to a specific item +--- @param index number The index of the item to scroll to +--- @return List self The List instance +--- @usage list:scrollToItem(5) +function List:scrollToItem(index) + local height = self.get("height") + local offset = self.get("offset") + + if index < offset + 1 then + self.set("offset", math.max(0, index - 1)) + elseif index > offset + height then + self.set("offset", index - height) + end + + return self +end + +--- Handles key events for keyboard navigation +--- @shortDescription Handles key events +--- @param keyCode number The key code +--- @return boolean Whether the event was handled +--- @protected +function List:key(keyCode) + if Collection.key(self, keyCode) and self.get("selectable") then + local items = self.get("items") + local currentIndex = self:getSelectedIndex() + + if keyCode == keys.up then + self:selectPrevious() + if currentIndex and currentIndex > 1 then + self:scrollToItem(currentIndex - 1) + end + return true + elseif keyCode == keys.down then + self:selectNext() + if currentIndex and currentIndex < #items then + self:scrollToItem(currentIndex + 1) + end + return true + elseif keyCode == keys.home then + self:clearItemSelection() + self:selectItem(1) + self:scrollToTop() + return true + elseif keyCode == keys["end"] then + self:clearItemSelection() + self:selectItem(#items) + self:scrollToBottom() + return true + elseif keyCode == keys.pageUp then + local height = self.get("height") + local newIndex = math.max(1, (currentIndex or 1) - height) + self:clearItemSelection() + self:selectItem(newIndex) + self:scrollToItem(newIndex) + return true + elseif keyCode == keys.pageDown then + local height = self.get("height") + local newIndex = math.min(#items, (currentIndex or 1) + height) + self:clearItemSelection() + self:selectItem(newIndex) + self:scrollToItem(newIndex) + return true + end + end + return false +end + --- @shortDescription Renders the list --- @protected -function List:render() +function List:render(vOffset) Collection.render(self) local items = self.get("items") @@ -141,6 +318,25 @@ function List:render() local width = self.get("width") local listBg = self.getResolved("background") local listFg = self.getResolved("foreground") + local showScrollBar = self.get("showScrollBar") + + local needsScrollBar = showScrollBar and #items > height + local contentWidth = needsScrollBar and width - 1 or width + + if #items == 0 then + local emptyText = self.get("emptyText") + local y = math.floor(height / 2) + vOffset + local x = math.max(1, math.floor((width - #emptyText) / 2) + 1) + + for i = 1, height do + self:textBg(1, i, string.rep(" ", width), listBg) + end + + if y >= 1 and y <= height then + self:textFg(x, y + vOffset, emptyText, colors.gray) + end + return + end for i = 1, height do local itemIndex = i + offset @@ -148,15 +344,15 @@ function List:render() if item then if item.separator then - local separatorChar = (item.text or "-"):sub(1,1) - local separatorText = string.rep(separatorChar, width) + local separatorChar = ((item.text or "-") ~= "" and item.text or "-"):sub(1,1) + local separatorText = string.rep(separatorChar, contentWidth) 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) + self:textBg(1, i + vOffset, string.rep(" ", contentWidth), bg) + self:textFg(1, i + vOffset, separatorText, fg) else - local text = item.text + local text = item.text or "" local isSelected = item.selected local bg = isSelected and (item.selectedBg or self.getResolved("selectedBackground")) or @@ -165,11 +361,42 @@ function List:render() local fg = isSelected and (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) + + local displayText = text + if #displayText > contentWidth then + displayText = displayText:sub(1, contentWidth - 3) .. "..." + else + displayText = displayText .. string.rep(" ", contentWidth - #displayText) + end + + self:textBg(1, i + vOffset, string.rep(" ", contentWidth), bg) + self:textFg(1, i + vOffset, displayText, fg) end + else + self:textBg(1, i + vOffset, string.rep(" ", contentWidth), listBg) + end + end + + if needsScrollBar then + local handleSize = math.max(1, math.floor((height / #items) * height)) + local maxOffset = #items - height + + local currentPercent = maxOffset > 0 and (offset / maxOffset * 100) or 0 + local handlePos = math.floor((currentPercent / 100) * (height - handleSize)) + 1 + + local scrollBarSymbol = self.getResolved("scrollBarSymbol") + local scrollBarBg = self.getResolved("scrollBarBackground") + local scrollBarColor = self.getResolved("scrollBarColor") + local scrollBarBgColor = self.getResolved("scrollBarBackgroundColor") + + for i = 1, height do + self:blit(width, i + vOffset, scrollBarBg, tHex[listFg], tHex[scrollBarBgColor]) + end + + for i = handlePos, math.min(height, handlePos + handleSize - 1) do + self:blit(width, i + vOffset, scrollBarSymbol, tHex[scrollBarColor], tHex[scrollBarBgColor]) end end end -return List +return List \ No newline at end of file diff --git a/src/plugins/xml.lua b/src/plugins/xml.lua index daecf3b..8320990 100644 --- a/src/plugins/xml.lua +++ b/src/plugins/xml.lua @@ -20,15 +20,40 @@ local XMLNode = { } local parseAttributes = function(node, s) - local _, _ = string.gsub(s, "(%w+)=([\"'])(.-)%2", function(attribute, _, value) + local _, _ = string.gsub(s, "([%w:]+)=([\"'])(.-)%2", function(attribute, _, value) node:addAttribute(attribute, "\"" .. value .. "\"") end) - local _, _ = string.gsub(s, "(%w+)={(.-)}", function(attribute, expression) + local _, _ = string.gsub(s, "([%w:]+)={(.-)}", function(attribute, expression) node:addAttribute(attribute, expression) end) end -local XMLParser = { +local XMLParser = {} +XMLParser = { + _customTagHandlers = {}, + + --- Registers a custom tag handler + --- @param tagName string The name of the custom tag + --- @param handler function The handler function(node, parent, scope) + registerTagHandler = function(tagName, handler) + XMLParser._customTagHandlers[tagName] = handler + log.info("XMLParser: Registered custom tag handler for '" .. tagName .. "'") + end, + + --- Unregisters a custom tag handler + --- @param tagName string The name of the custom tag + unregisterTagHandler = function(tagName) + XMLParser._customTagHandlers[tagName] = nil + log.info("XMLParser: Unregistered custom tag handler for '" .. tagName .. "'") + end, + + --- Gets a custom tag handler + --- @param tagName string The name of the custom tag + --- @return function|nil handler The handler function or nil + getTagHandler = function(tagName) + return XMLParser._customTagHandlers[tagName] + end, + parseText = function(xmlText) local stack = {} local top = XMLNode.new() @@ -120,7 +145,15 @@ local function convertValue(value, scope) for k,v in pairs(scope) do env[k] = v end - return load("return " .. cdata, nil, "bt", env)() + local fn, err = load("return " .. cdata, nil, "bt", env) + if not fn then + errorManager.error("XMLParser: CDATA syntax error: " .. tostring(err)) + end + local success, result = pcall(fn) + if not success then + errorManager.error("XMLParser: CDATA execution error: " .. tostring(result)) + end + return result end if value == "true" then @@ -168,6 +201,25 @@ local function createTableFromNode(node, scope) return list end +local function parseStateAttribute(self, attribute, value, scope) + local propName, stateName = attribute:match("^(.+)State:(.+)$") + if propName and stateName then + stateName = stateName:gsub("^\"", ""):gsub("\"$", "") + + local capitalizedName = propName:sub(1,1):upper() .. propName:sub(2) + local methodName = "set"..capitalizedName.."State" + + if self[methodName] then + self[methodName](self, stateName, convertValue(value, scope)) + return true + else + log.warn("XMLParser: State method '" .. methodName .. "' not found for element '" .. self:getType() .. "'") + return true + end + end + return false +end + local BaseElement = {} function BaseElement.setup(element) @@ -183,32 +235,56 @@ end function BaseElement:fromXML(node, scope) if(node.attributes)then for k, v in pairs(node.attributes) do - if(self._properties[k])then - self.set(k, convertValue(v, scope)) - elseif self[k] then - if(k:sub(1,2)=="on")then - local val = v:gsub("\"", "") - if(scope[val])then - if(type(scope[val]) ~= "function")then - errorManager.error("XMLParser: variable '" .. val .. "' is not a function for element '" .. self:getType() .. "' "..k) + if not parseStateAttribute(self, k, v, scope) then + if(self._properties[k])then + self.set(k, convertValue(v, scope)) + elseif self[k] then + if(k:sub(1,2)=="on")then + local val = v:gsub("\"", "") + if(scope[val])then + if(type(scope[val]) ~= "function")then + errorManager.error("XMLParser: variable '" .. val .. "' is not a function for element '" .. self:getType() .. "' "..k) + end + self[k](self, scope[val]) + else + errorManager.error("XMLParser: variable '" .. val .. "' not found in scope") end - self[k](self, scope[val]) else - errorManager.error("XMLParser: variable '" .. val .. "' not found in scope") + errorManager.error("XMLParser: property '" .. k .. "' not found in element '" .. self:getType() .. "'") end else - errorManager.error("XMLParser: property '" .. k .. "' not found in element '" .. self:getType() .. "'") + local customXML = self.get("customXML") + customXML.attributes[k] = convertValue(v, scope) end - else - local customXML = self.get("customXML") - customXML.attributes[k] = convertValue(v, scope) end end end if(node.children)then for _, child in pairs(node.children) do - if(self._properties[child.tag])then + if child.tag == "state" then + local stateName = child.attributes and child.attributes.name + if not stateName then + errorManager.error("XMLParser: tag requires 'name' attribute") + end + + stateName = stateName:gsub("^\"", ""):gsub("\"$", "") + + if child.children then + for _, stateChild in ipairs(child.children) do + local propName = stateChild.tag + local value = convertValue(stateChild.value, scope) + local capitalizedName = propName:sub(1,1):upper() .. propName:sub(2) + local methodName = "set"..capitalizedName.."State" + + if self[methodName] then + self[methodName](self, stateName, value) + else + log.warn("XMLParser: State method '" .. methodName .. "' not found for element '" .. self:getType() .. "'") + end + end + end + elseif(self._properties[child.tag])then if(self._properties[child.tag].type == "table")then self.set(child.tag, createTableFromNode(child, scope)) else @@ -280,9 +356,15 @@ function Container:fromXML(nodes, scope) if(nodes.children)then for _, node in ipairs(nodes.children) do local capitalizedName = node.tag:sub(1,1):upper() .. node.tag:sub(2) - if self["add"..capitalizedName] then + + local customHandler = XMLParser.getTagHandler(node.tag) + if customHandler then + local result = customHandler(node, self, scope) + elseif self["add"..capitalizedName] then local element = self["add"..capitalizedName](self) element:fromXML(node, scope) + else + log.warn("XMLParser: Unknown tag '" .. node.tag .. "' - no handler or element found") end end end