- Added DropDown Scrollbar

- Added List Scrollbar
- Added Statemanagementsystem for XML
This commit is contained in:
Robert Jelic
2025-10-29 09:10:23 +01:00
parent c7f63b7684
commit 41bd5bdf04
4 changed files with 487 additions and 122 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: <state> 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