From 6dfa554523979a60cc4ccf089dcdf72c1e040d3c Mon Sep 17 00:00:00 2001 From: Robert Jelic <36573031+NoryiE@users.noreply.github.com> Date: Thu, 13 Feb 2025 10:51:12 +0100 Subject: [PATCH] - Created Plugin loading system - Added lazy loading system for elements (optional feature) - Improved rendering performance - Added ID system which is separated from Eement Names - Added Focussystem for container - Improved container performance by only rendering and handling events from visible childrens instead of all - Added label and input - Added animation and xml --- .gitignore | 5 +- src/LuaLS.lua | 222 ---------------------- src/benchmark.lua | 33 ++++ src/elementManager.lua | 123 ++++++------- src/elements/BaseElement.lua | 124 +++++++++---- src/elements/BaseFrame.lua | 44 +++-- src/elements/Button.lua | 17 +- src/elements/Container.lua | 234 +++++++++++++++++------ src/elements/Flexbox.lua | 297 ++++++++++++++++++++++++++++++ src/elements/Frame.lua | 18 +- src/elements/Input.lua | 111 +++++++++++ src/elements/Label.lua | 38 ++++ src/elements/VisualElement.lua | 102 +++++++--- src/errorManager.lua | 2 +- src/init.lua | 5 +- src/libraries/utils.lua | 28 +++ src/main.lua | 78 ++++++-- src/plugins/animation.lua | 327 +++++++++++++++++++++++++++++++++ src/plugins/pluginTemplate.lua | 23 +++ src/plugins/reactive.lua | 33 ++++ src/plugins/xml.lua | 184 +++++++++++++++++++ src/propertySystem.lua | 173 +++++++++++++++-- src/render.lua | 106 +++++++++-- 23 files changed, 1833 insertions(+), 494 deletions(-) delete mode 100644 src/LuaLS.lua create mode 100644 src/benchmark.lua create mode 100644 src/elements/Flexbox.lua create mode 100644 src/elements/Input.lua create mode 100644 src/elements/Label.lua create mode 100644 src/plugins/animation.lua create mode 100644 src/plugins/pluginTemplate.lua create mode 100644 src/plugins/reactive.lua create mode 100644 src/plugins/xml.lua diff --git a/.gitignore b/.gitignore index ecbf039..ed2c9f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -test.lua \ No newline at end of file +test.lua +test2.lua +lua-ls-cc-tweaked-main +test.xml \ No newline at end of file diff --git a/src/LuaLS.lua b/src/LuaLS.lua deleted file mode 100644 index 10da3c9..0000000 --- a/src/LuaLS.lua +++ /dev/null @@ -1,222 +0,0 @@ ----@class Button ----@field text string -local Button = {} - ---- Gets the Button text ----@generic Element: Button ----@param self Element ----@return string -function Button:getText() - return self.text -end - ---- Sets the Button text ----@generic Element: Button ----@param self Element ----@param text string ----@return Element -function Button:setText(text) - self.text = text - return self -end - ---- The event that is triggered when the button is clicked ----@generic Element: Button ----@param self Element ----@param callback function ----@return Element -function Button:onMouseClick(callback) - return self -end - - ----@class Container -local Container = {} - ---- Adds a new Button to the container ----@generic Element: Container ----@param self Element ----@return Button -function Container:addButton() - return self -end - ---- Adds a new Container to the container ----@generic Element: Container ----@param self Element ----@return Container -function Container:addContainer() - return self -end - ---- Adds a new Frame to the container ----@generic Element: Container ----@param self Element ----@return Frame -function Container:addFrame() - return self -end - ---- Adds a new VisualElement to the container ----@generic Element: Container ----@param self Element ----@return VisualElement -function Container:addVisualElement() - return self -end - - ----@class VisualElement ----@field x number ----@field y number ----@field z number ----@field width number ----@field height number ----@field background color ----@field foreground color ----@field clicked boolean -local VisualElement = {} - ---- Gets the x position of the element ----@generic Element: VisualElement ----@param self Element ----@return number -function VisualElement:getX() - return self.x -end - ---- Sets the x position of the element ----@generic Element: VisualElement ----@param self Element ----@param x number ----@return Element -function VisualElement:setX(x) - self.x = x - return self -end - ---- Gets the y position of the element ----@generic Element: VisualElement ----@param self Element ----@return number -function VisualElement:getY() - return self.y -end - ---- Sets the y position of the element ----@generic Element: VisualElement ----@param self Element ----@param y number ----@return Element -function VisualElement:setY(y) - self.y = y - return self -end - ---- Gets the z position of the element ----@generic Element: VisualElement ----@param self Element ----@return number -function VisualElement:getZ() - return self.z -end - ---- Sets the z position of the element ----@generic Element: VisualElement ----@param self Element ----@param z number ----@return Element -function VisualElement:setZ(z) - self.z = z - return self -end - ---- Gets the width of the element ----@generic Element: VisualElement ----@param self Element ----@return number -function VisualElement:getWidth() - return self.width -end - ---- Sets the width of the element ----@generic Element: VisualElement ----@param self Element ----@param width number ----@return Element -function VisualElement:setWidth(width) - self.width = width - return self -end - ---- Gets the height of the element ----@generic Element: VisualElement ----@param self Element ----@return number -function VisualElement:getHeight() - return self.height -end - ---- Sets the height of the element ----@generic Element: VisualElement ----@param self Element ----@param height number ----@return Element -function VisualElement:setHeight(height) - self.height = height - return self -end - ---- Gets the background color of the element ----@generic Element: VisualElement ----@param self Element ----@return color -function VisualElement:getBackground() - return self.background -end - ---- Sets the background color of the element ----@generic Element: VisualElement ----@param self Element ----@param background color ----@return Element -function VisualElement:setBackground(background) - self.background = background - return self -end - ---- Gets the foreground color of the element ----@generic Element: VisualElement ----@param self Element ----@return color -function VisualElement:getForeground() - return self.foreground -end - ---- Sets the foreground color of the element ----@generic Element: VisualElement ----@param self Element ----@param foreground color ----@return Element -function VisualElement:setForeground(foreground) - self.foreground = foreground - return self -end - ---- Gets the element is currently clicked ----@generic Element: VisualElement ----@param self Element ----@return boolean -function VisualElement:getClicked() - return self.clicked -end - ---- Sets the element is currently clicked ----@generic Element: VisualElement ----@param self Element ----@param clicked boolean ----@return Element -function VisualElement:setClicked(clicked) - self.clicked = clicked - return self -end diff --git a/src/benchmark.lua b/src/benchmark.lua new file mode 100644 index 0000000..8052a49 --- /dev/null +++ b/src/benchmark.lua @@ -0,0 +1,33 @@ +-- Will temporary exist while developing, maybe i will create a benchmark plugin in future + +local log = require("log") + +local Benchmark = {} + +function Benchmark.start(name) + Benchmark[name] = { + startTime = os.epoch("local"), + updates = 0 + } +end + +function Benchmark.update(name) + if Benchmark[name] then + Benchmark[name].updates = Benchmark[name].updates + 1 + end +end + +function Benchmark.stop(name) + if Benchmark[name] then + local endTime = os.epoch("local") + local duration = endTime - Benchmark[name].startTime + local updates = Benchmark[name].updates + + log.debug(string.format("[Benchmark] %s: %dms, %d updates, avg: %.2fms per update", + name, duration, updates, duration/updates)) + + return duration, updates + end +end + +return Benchmark diff --git a/src/elementManager.lua b/src/elementManager.lua index 72fb8ea..6359cc1 100644 --- a/src/elementManager.lua +++ b/src/elementManager.lua @@ -1,10 +1,14 @@ local args = table.pack(...) local dir = fs.getDir(args[2] or "basalt") +local subDir = args[1] if(dir==nil)then error("Unable to find directory "..args[2].." please report this bug to our discord.") end local log = require("log") +local defaultPath = package.path +local format = "path;/path/?.lua;/path/?/init.lua;" +local main = format:gsub("path", dir) local ElementManager = {} ElementManager._elements = {} @@ -27,55 +31,19 @@ if fs.exists(elementsDirectory) then end end -function ElementManager.extendMethod(element, methodName, newMethod, originalMethod) - if not originalMethod then - element[methodName] = newMethod - return - end - element[methodName] = function(self, ...) - if newMethod.before then - newMethod.before(self, ...) - end - - local results - if newMethod.override then - results = {newMethod.override(self, originalMethod, ...)} - else - results = {originalMethod(self, ...)} - end - - if newMethod.after then - newMethod.after(self, ...) - end - - return table.unpack(results) - end -end - -function ElementManager.loadPlugin(name) - local plugin = require("plugins/"..name) - - -- Apply plugin to each targeted element - for elementName, pluginData in pairs(plugin) do - local element = ElementManager._elements[elementName] - if element then - -- Register properties - if pluginData.properties then - element.class.initialize(elementName.."Plugin") - for propName, config in pairs(pluginData.properties) do - element.class.registerProperty(propName, config) - end - end - - -- Register/extend methods - if pluginData.methods then - for methodName, methodData in pairs(pluginData.methods) do - ElementManager.extendMethod( - element.class, - methodName, - methodData, - element.class[methodName] - ) +log.info("Loading plugins from "..pluginsDirectory) +if fs.exists(pluginsDirectory) then + for _, file in ipairs(fs.list(pluginsDirectory)) do + local name = file:match("(.+).lua") + if name then + log.debug("Found plugin: "..name) + local plugin = require(fs.combine("plugins", name)) + if type(plugin) == "table" then + for k,v in pairs(plugin) do + if(ElementManager._plugins[k]==nil)then + ElementManager._plugins[k] = {} + end + table.insert(ElementManager._plugins[k], v) end end end @@ -84,7 +52,9 @@ end function ElementManager.loadElement(name) if not ElementManager._elements[name].loaded then - local element = require("elements/"..name) + package.path = main.."rom/?" + local element = require(fs.combine("elements", name)) + package.path = defaultPath ElementManager._elements[name] = { class = element, plugins = element.plugins, @@ -92,22 +62,46 @@ function ElementManager.loadElement(name) } log.debug("Loaded element: "..name) - -- Load element's required plugins - if element.requires then - for pluginName, _ in pairs(element.requires) do - --ElementManager.loadPlugin(pluginName) + if(ElementManager._plugins[name]~=nil)then + for _, plugin in pairs(ElementManager._plugins[name]) do + if(plugin.setup)then + plugin.setup(element) + end + + if(plugin.hooks)then + for methodName, hooks in pairs(plugin.hooks) do + local original = element[methodName] + if(type(original)~="function")then + error("Element "..name.." does not have a method "..methodName) + end + if(type(hooks)=="function")then + element[methodName] = function(self, ...) + original(self, ...) + return hooks(self, ...) + end + elseif(type(hooks)=="table")then + element[methodName] = function(self, ...) + if hooks.pre then hooks.pre(self, ...) end + local result = original(self, ...) + if hooks.post then hooks.post(self, ...) end + return result + end + end + end + end + + for funcName, func in pairs(plugin) do + if funcName ~= "setup" and funcName ~= "hooks" then + element[funcName] = function(self, ...) + return func(self, ...) + end + end + end end end end end -function ElementManager.registerPlugin(name, plugin) - if not plugin.provides then - error("Plugin must specify what it provides") - end - ElementManager._plugins[name] = plugin -end - function ElementManager.getElement(name) if not ElementManager._elements[name].loaded then ElementManager.loadElement(name) @@ -119,11 +113,4 @@ function ElementManager.getElementList() return ElementManager._elements end -function ElementManager.generateId() - return string.format('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - math.random(0, 0xffff), math.random(0, 0xffff), math.random(0, 0xffff), - math.random(0, 0x0fff) + 0x4000, math.random(0, 0x3fff) + 0x8000, - math.random(0, 0xffff), math.random(0, 0xffff), math.random(0, 0xffff)) -end - return ElementManager \ No newline at end of file diff --git a/src/elements/BaseElement.lua b/src/elements/BaseElement.lua index 63a6e1a..7ff450a 100644 --- a/src/elements/BaseElement.lua +++ b/src/elements/BaseElement.lua @@ -1,4 +1,5 @@ local PropertySystem = require("propertySystem") +local uuid = require("/libraries/utils").uuid --- The base class for all UI elements in Basalt --- @class BaseElement : PropertySystem @@ -7,45 +8,28 @@ BaseElement.__index = BaseElement BaseElement._events = {} --- @property type string BaseElement The type identifier of the element -BaseElement.defineProperty(BaseElement, "type", {default = "BaseElement", type = "string"}) +BaseElement.defineProperty(BaseElement, "type", {default = {"BaseElement"}, type = "string", setter=function(self, value) + if type(value) == "string" then + table.insert(self._values.type, 1, value) + return self._values.type + end + return value +end, getter = function(self, _, index) + if index~= nil and index < 1 then + return self._values.type + end + return self._values.type[index or 1] +end}) + +--- @property id string BaseElement The unique identifier for the element +BaseElement.defineProperty(BaseElement, "id", {default = "", type = "string", readonly = true}) + +--- @property name string BaseElement The name of the element +BaseElement.defineProperty(BaseElement, "name", {default = "", type = "string"}) --- @property eventCallbacks table {} Table containing all registered event callbacks BaseElement.defineProperty(BaseElement, "eventCallbacks", {default = {}, type = "table"}) ---- Creates a new BaseElement instance ---- @param id string The unique identifier for this element ---- @param basalt table The basalt instance ---- @return table The newly created BaseElement instance ---- @usage local element = BaseElement.new("myId", basalt) -function BaseElement.new(id, basalt) - local self = setmetatable({}, BaseElement):__init() - self:init(id, basalt) - self.set("type", "BaseElement") - return self -end - ---- Initializes the BaseElement instance ---- @param id string The unique identifier for this element ---- @param basalt table The basalt instance ---- @return table self The initialized instance -function BaseElement:init(id, basalt) - self.id = id - self.basalt = basalt - self._registeredEvents = {} - if BaseElement._events then - for event in pairs(BaseElement._events) do - self._registeredEvents[event] = true - local handlerName = "on" .. event:gsub("_(%l)", function(c) - return c:upper() - end):gsub("^%l", string.upper) - self[handlerName] = function(self, ...) - self:registerCallback(event, ...) - end - end - end - return self -end - --- Registers an event that this class can listen to --- @param class table The class to add the event to --- @param eventName string The name of the event to register @@ -57,6 +41,57 @@ function BaseElement.listenTo(class, eventName) class._events[eventName] = true end +--- Creates a new BaseElement instance +--- @param props table The properties to initialize the element with +--- @param basalt table The basalt instance +--- @return table The newly created BaseElement instance +--- @usage local element = BaseElement.new("myId", basalt) +function BaseElement.new(props, basalt) + local self = setmetatable({}, BaseElement):__init() + self:init(props, basalt) + return self +end + +--- Initializes the BaseElement instance +--- @param props table The properties to initialize the element with +--- @param basalt table The basalt instance +--- @return table self The initialized instance +function BaseElement:init(props, basalt) + if(type(props) == "table")then + for k,v in pairs(props)do + self[k] = v + end + end + self._values.id = uuid() + self.basalt = basalt + self._registeredEvents = {} + if BaseElement._events then + for event in pairs(BaseElement._events) do + self._registeredEvents[event] = true + local handlerName = "on" .. event:gsub("_(%l)", function(c) + return c:upper() + end):gsub("^%l", string.upper) + self[handlerName] = function(self, ...) + self:registerCallback(event, ...) + return self + end + end + end + return self +end + +--- Checks if the element is a specific type +--- @param type string The type to check for +--- @return boolean Whether the element is of the specified type +function BaseElement:isType(type) + for _, t in ipairs(self._values.type) do + if t == type then + return true + end + end + return false +end + --- Enables or disables event listening for a specific event --- @param eventName string The name of the event to listen for --- @param enable? boolean Whether to enable or disable the event (default: true) @@ -113,6 +148,25 @@ function BaseElement:fireEvent(event, ...) return self end +--- Handles all events +--- @param event string The event to handle +--- @vararg any The arguments for the event +--- @return boolean? handled Whether the event was handled +function BaseElement:dispatchEvent(event, ...) + if self[event] then + return self[event](self, ...) + end + return self:handleEvent(event, ...) +end + +--- The default event handler for all events +--- @param event string The event to handle +--- @vararg any The arguments for the event +--- @return boolean? handled Whether the event was handled +function BaseElement:handleEvent(event, ...) + return true +end + --- Requests a render update for this element --- @usage element:updateRender() function BaseElement:updateRender() diff --git a/src/elements/BaseFrame.lua b/src/elements/BaseFrame.lua index 996816e..4b9c3fa 100644 --- a/src/elements/BaseFrame.lua +++ b/src/elements/BaseFrame.lua @@ -1,25 +1,37 @@ -local Container = require("elements/Container") +local elementManager = require("elementManager") +local Container = elementManager.getElement("Container") local Render = require("render") ---@class BaseFrame : Container local BaseFrame = setmetatable({}, Container) BaseFrame.__index = BaseFrame ----@diagnostic disable-next-line: duplicate-set-field -function BaseFrame.new(id, basalt) - local self = setmetatable({}, BaseFrame):__init() - self:init(id, basalt) - self.terminal = term.current() -- change to :setTerm later!! - self._render = Render.new(self.terminal) +---@property text term term nil text +BaseFrame.defineProperty(BaseFrame, "term", {default = nil, type = "table", setter = function(self, value) + if value == nil or value.setCursorPos == nil then + return value + end + self._render = Render.new(value) self._renderUpdate = true - local width, height = self.terminal.getSize() + local width, height = value.getSize() self.set("width", width) self.set("height", height) + return value +end}) + +function BaseFrame.new(props, basalt) + local self = setmetatable({}, BaseFrame):__init() + self:init(props, basalt) + self.set("term", term.current()) self.set("background", colors.red) - self.set("type", "BaseFrame") return self end +function BaseFrame:init(props, basalt) + Container.init(self, props, basalt) + self.set("type", "BaseFrame") +end + function BaseFrame:multiBlit(x, y, width, height, text, fg, bg) self._render:multiBlit(x, y, width, height, text, fg, bg) end @@ -28,12 +40,18 @@ function BaseFrame:textFg(x, y, text, fg) self._render:textFg(x, y, text, fg) end ----@diagnostic disable-next-line: duplicate-set-field +function BaseFrame:setCursor(x, y, blink) + local term = self.get("term") + self._render:setCursor(x, y, blink) +end + function BaseFrame:render() if(self._renderUpdate) then - Container.render(self) - self._render:render() - self._renderUpdate = false + if self._render ~= nil then + Container.render(self) + self._render:render() + self._renderUpdate = false + end end end diff --git a/src/elements/Button.lua b/src/elements/Button.lua index 1a58d2f..9d06724 100644 --- a/src/elements/Button.lua +++ b/src/elements/Button.lua @@ -1,4 +1,5 @@ -local VisualElement = require("elements/VisualElement") +local elementManager = require("elementManager") +local VisualElement = elementManager.getElement("VisualElement") local getCenteredPosition = require("libraries/utils").getCenteredPosition ---@class Button : VisualElement @@ -6,23 +7,25 @@ local Button = setmetatable({}, VisualElement) Button.__index = Button ---@property text string Button Button text -Button.defineProperty(Button, "text", {default = "Button", type = "string"}) +Button.defineProperty(Button, "text", {default = "Button", type = "string", canTriggerRender = true}) ---@event mouse_click The event that is triggered when the button is clicked Button.listenTo(Button, "mouse_click") ----@diagnostic disable-next-line: duplicate-set-field -function Button.new(id, basalt) +function Button.new(props, basalt) local self = setmetatable({}, Button):__init() - self:init(id, basalt) - self.set("type", "Button") + self:init(props, basalt) self.set("width", 10) self.set("height", 3) self.set("z", 5) return self end ----@diagnostic disable-next-line: duplicate-set-field +function Button:init(props, basalt) + VisualElement.init(self, props, basalt) + self.set("type", "Button") +end + function Button:render() VisualElement.render(self) local text = self.get("text") diff --git a/src/elements/Container.lua b/src/elements/Container.lua index 155a440..a218d50 100644 --- a/src/elements/Container.lua +++ b/src/elements/Container.lua @@ -1,6 +1,7 @@ -local VisualElement = require("elements/VisualElement") local elementManager = require("elementManager") +local VisualElement = elementManager.getElement("VisualElement") local expect = require("libraries/expect") +local split = require("libraries/utils").split local max = math.max @@ -9,65 +10,129 @@ local Container = setmetatable({}, VisualElement) Container.__index = Container Container.defineProperty(Container, "children", {default = {}, type = "table"}) +Container.defineProperty(Container, "childrenSorted", {default = true, type = "boolean"}) +Container.defineProperty(Container, "childrenEventsSorted", {default = true, type = "boolean"}) Container.defineProperty(Container, "childrenEvents", {default = {}, type = "table"}) Container.defineProperty(Container, "eventListenerCount", {default = {}, type = "table"}) +Container.defineProperty(Container, "focusedChild", {default = nil, type = "table", setter = function(self, value, internal) + local oldChild = self._values.focusedChild + + if value == oldChild then return value end + + if oldChild then + if oldChild:isType("Container") then + oldChild.set("focusedChild", nil, true) + end + oldChild.set("focused", false, true) + end + + if value and not internal then + value.set("focused", true, true) + if self.parent then + self.parent:setFocusedChild(self) + end + end + + return value +end}) + +Container.defineProperty(Container, "visibleChildren", {default = {}, type = "table"}) +Container.defineProperty(Container, "visibleChildrenEvents", {default = {}, type = "table"}) + +function Container:isChildVisible(child) + local childX, childY = child.get("x"), child.get("y") + local childW, childH = child.get("width"), child.get("height") + local containerW, containerH = self.get("width"), self.get("height") + + return childX <= containerW and + childY <= containerH and + childX + childW > 0 and + childY + childH > 0 +end for k, _ in pairs(elementManager:getElementList()) do local capitalizedName = k:sub(1,1):upper() .. k:sub(2) - --if not capitalizedName == "BaseFrame" then + if capitalizedName ~= "BaseFrame" then Container["add"..capitalizedName] = function(self, ...) expect(1, self, "table") local element = self.basalt.create(k, ...) - self.basalt.LOGGER.debug(capitalizedName.." created with ID: " .. element.id) self:addChild(element) return element end - --end + Container["addDelayed"..capitalizedName] = function(self, prop) + expect(1, self, "table") + local element = self.basalt.create(k, prop, true, self) + return element + end + end end ----@diagnostic disable-next-line: duplicate-set-field -function Container.new(id, basalt) +function Container.new(props, basalt) local self = setmetatable({}, Container):__init() - self:init(id, basalt) - self.set("type", "Container") + self:init(props, basalt) return self end +function Container:init(props, basalt) + VisualElement.init(self, props, basalt) + self.set("type", "Container") +end + function Container:addChild(child) if child == self then error("Cannot add container to itself") end - local childZ = child.get("z") - local pos = 1 - for i, existing in ipairs(self._values.children) do - if existing.get("z") > childZ then - break - end - pos = i + 1 - end - - table.insert(self._values.children, pos, child) + table.insert(self._values.children, child) child.parent = self + self.set("childrenSorted", false) self:registerChildrenEvents(child) return self end +local function sortAndFilterChildren(self, children) + local visibleChildren = {} + + for _, child in ipairs(children) do + if self:isChildVisible(child) then + table.insert(visibleChildren, child) + end + end + + for i = 2, #visibleChildren do + local current = visibleChildren[i] + local currentZ = current.get("z") + local j = i - 1 + + while j > 0 do + local compare = visibleChildren[j].get("z") + if compare > currentZ then + visibleChildren[j + 1] = visibleChildren[j] + j = j - 1 + else + break + end + end + visibleChildren[j + 1] = current + end + + return visibleChildren +end + function Container:sortChildren() - table.sort(self._values.children, function(a, b) - return a.get("z") < b.get("z") - end) + self.set("visibleChildren", sortAndFilterChildren(self, self._values.children)) + self.set("childrenSorted", true) end function Container:sortChildrenEvents(eventName) if self._values.childrenEvents[eventName] then - table.sort(self._values.childrenEvents[eventName], function(a, b) - return a.get("z") > b.get("z") - end) + self._values.visibleChildrenEvents[eventName] = sortAndFilterChildren(self, self._values.childrenEvents[eventName]) end + self.set("childrenEventsSorted", true) end function Container:registerChildrenEvents(child) + if(child._registeredEvents == nil)then return end for event in pairs(child._registeredEvents) do self:registerChildEvent(child, event) end @@ -89,20 +154,13 @@ function Container:registerChildEvent(child, eventName) end end - local childZ = child.get("z") - local pos = 1 - for i, existing in ipairs(self._values.childrenEvents[eventName]) do - if existing.get("z") < childZ then - break - end - pos = i + 1 - end - - table.insert(self._values.childrenEvents[eventName], pos, child) + self.set("childrenEventsSorted", false) + table.insert(self._values.childrenEvents[eventName], child) self._values.eventListenerCount[eventName] = self._values.eventListenerCount[eventName] + 1 end function Container:removeChildrenEvents(child) + if(child._registeredEvents == nil)then return end for event in pairs(child._registeredEvents) do self:unregisterChildEvent(child, event) end @@ -130,45 +188,98 @@ function Container:unregisterChildEvent(child, eventName) end function Container:removeChild(child) - for i,v in ipairs(self.children) do + for i,v in ipairs(self._values.children) do if v == child then table.remove(self._values.children, i) child.parent = nil break end end + self:removeChildrenEvents(child) return self end +function Container:getChild(path) + if type(path) == "string" then + local parts = split(path, "/") + for _,v in pairs(self._values.children) do + if v.get("name") == parts[1] then + if #parts == 1 then + return v + else + if(v:isType("Container"))then + return v:find(table.concat(parts, "/", 2)) + end + end + end + end + end + return nil +end + +local function convertMousePosition(self, event, ...) + local args = {...} + if event:find("mouse_") then + local button, absX, absY = ... + local relX, relY = self:getRelativePosition(absX, absY) + args = {button, relX, relY} + end + return args +end + +local function callChildrenEvents(self, visibleOnly, event, ...) + local children = visibleOnly and self.get("visibleChildrenEvents") or self.get("childrenEvents") + if children[event] then + local events = children[event] + for i = #events, 1, -1 do + local child = events[i] + if(child:dispatchEvent(event, ...))then + return true, child + end + end + end + return false +end + function Container:handleEvent(event, ...) if(VisualElement.handleEvent(self, event, ...))then - local args = {...} - if event:find("mouse_") then - local button, absX, absY = ... - local relX, relY = self:getRelativePosition(absX, absY) - args = {button, relX, relY} - end - if self._values.childrenEvents[event] then - for _, child in ipairs(self._values.childrenEvents[event]) do - if(child:handleEvent(event, table.unpack(args)))then - return true - end - end - end + local args = convertMousePosition(self, event, ...) + return callChildrenEvents(self, false, event, table.unpack(args)) end end ---[[function Container:mouse_click(button, x, y) +function Container:mouse_click(button, x, y) if VisualElement.mouse_click(self, button, x, y) then - if self._values.childrenEvents.mouse_click then - for _, child in ipairs(self._values.childrenEvents.mouse_click) do - if child:mouse_click(button, x, y) then - return true - end - end + local args = convertMousePosition(self, "mouse_click", button, x, y) + local success, child = callChildrenEvents(self, true, "mouse_click", table.unpack(args)) + if(success)then + self.set("focusedChild", child) + return true end + self.set("focusedChild", nil) end -end]] +end + +function Container:key(key) + if self.get("focusedChild") then + return self.get("focusedChild"):dispatchEvent("key", key) + end + return true +end + +function Container:char(char) + if self.get("focusedChild") then + return self.get("focusedChild"):dispatchEvent("char", char) + end + return true +end + +function Container:key_up(key) + if self.get("focusedChild") then + return self.get("focusedChild"):dispatchEvent("key_up", key) + end + return true +end function Container:multiBlit(x, y, width, height, text, fg, bg) local w, h = self.get("width"), self.get("height") @@ -194,10 +305,17 @@ function Container:textFg(x, y, text, fg) VisualElement.textFg(self, math.max(1, x), math.max(1, y), text:sub(textStart, textStart + textLen - 1), fg) end ----@diagnostic disable-next-line: duplicate-set-field function Container:render() VisualElement.render(self) - for _, child in ipairs(self._values.children) do + if not self.get("childrenSorted")then + self:sortChildren() + end + if not self.get("childrenEventsSorted")then + for event in pairs(self._values.childrenEvents) do + self:sortChildrenEvents(event) + end + end + for _, child in ipairs(self.get("visibleChildren")) do if child == self then self.basalt.LOGGER.error("CIRCULAR REFERENCE DETECTED!") return diff --git a/src/elements/Flexbox.lua b/src/elements/Flexbox.lua new file mode 100644 index 0000000..15bc0ee --- /dev/null +++ b/src/elements/Flexbox.lua @@ -0,0 +1,297 @@ +local elementManager = require("elementManager") +local Container = elementManager.getElement("Container") + +---@class Flexbox : Container +local Flexbox = setmetatable({}, Container) +Flexbox.__index = Flexbox + +Flexbox.defineProperty(Flexbox, "flexDirection", {default = "row", type = "string"}) +Flexbox.defineProperty(Flexbox, "flexSpacing", {default = 1, type = "number"}) +Flexbox.defineProperty(Flexbox, "flexJustifyContent", { + default = "flex-start", + type = "string", + setter = function(self, value) + if not value:match("^flex%-") then + value = "flex-" .. value + end + return value + end +}) +Flexbox.defineProperty(Flexbox, "flexWrap", {default = false, type = "boolean"}) +Flexbox.defineProperty(Flexbox, "flexUpdateLayout", {default = false, type = "boolean"}) + +local lineBreakElement = { + getHeight = function(self) return 0 end, + getWidth = function(self) return 0 end, + getZ = function(self) return 1 end, + getPosition = function(self) return 0, 0 end, + getSize = function(self) return 0, 0 end, + isType = function(self) return false end, + getType = function(self) return "lineBreak" end, + getName = function(self) return "lineBreak" end, + setPosition = function(self) end, + setParent = function(self) end, + setSize = function(self) end, + getFlexGrow = function(self) return 0 end, + getFlexShrink = function(self) return 0 end, + getFlexBasis = function(self) return 0 end, + init = function(self) end, + getVisible = function(self) return true end, +} + + +local function sortElements(self, direction, spacing, wrap) + local elements = self.get("children") + local sortedElements = {} + if not(wrap)then + local index = 1 + local lineSize = 1 + local lineOffset = 1 + for _,v in pairs(elements)do + if(sortedElements[index]==nil)then sortedElements[index]={offset=1} end + + local childHeight = direction == "row" and v.get("height") or v.get("width") + if childHeight > lineSize then + lineSize = childHeight + end + if(v == lineBreakElement)then + lineOffset = lineOffset + lineSize + spacing + lineSize = 1 + index = index + 1 + sortedElements[index] = {offset=lineOffset} + else + table.insert(sortedElements[index], v) + end + end + elseif(wrap)then + local lineSize = 1 + local lineOffset = 1 + + local maxSize = direction == "row" and self.get("width") or self.get("height") + local usedSize = 0 + local index = 1 + + for _,v in pairs(elements) do + if(sortedElements[index]==nil) then sortedElements[index]={offset=1} end + + if v:getType() == "lineBreak" then + lineOffset = lineOffset + lineSize + spacing + usedSize = 0 + lineSize = 1 + index = index + 1 + sortedElements[index] = {offset=lineOffset} + else + local objSize = direction == "row" and v.get("width") or v.get("height") + if(objSize+usedSize<=maxSize) then + table.insert(sortedElements[index], v) + usedSize = usedSize + objSize + spacing + else + lineOffset = lineOffset + lineSize + spacing + lineSize = direction == "row" and v.get("height") or v.get("width") + index = index + 1 + usedSize = objSize + spacing + sortedElements[index] = {offset=lineOffset, v} + end + + local childHeight = direction == "row" and v.get("height") or v.get("width") + if childHeight > lineSize then + lineSize = childHeight + end + end + end + end + return sortedElements +end + +local function calculateRow(self, children, spacing, justifyContent) + local containerWidth = self.get("width") + + local usedSpace = spacing * (#children - 1) + local totalFlexGrow = 0 + + for _, child in ipairs(children) do + if child ~= lineBreakElement then + usedSpace = usedSpace + child.get("width") + totalFlexGrow = totalFlexGrow + child.get("flexGrow") + end + end + + local remainingSpace = containerWidth - usedSpace + local extraSpacePerUnit = totalFlexGrow > 0 and (remainingSpace / totalFlexGrow) or 0 + local distributedSpace = 0 + + local currentX = 1 + for i, child in ipairs(children) do + if child ~= lineBreakElement then + local childWidth = child.get("width") + + if child.get("flexGrow") > 0 then + + if i == #children then + local extraSpace = remainingSpace - distributedSpace + childWidth = childWidth + extraSpace + else + local extraSpace = math.floor(extraSpacePerUnit * child.get("flexGrow")) + childWidth = childWidth + extraSpace + distributedSpace = distributedSpace + extraSpace + end + end + + child.set("x", currentX) + child.set("y", children.offset or 1) + child.set("width", childWidth) + currentX = currentX + childWidth + spacing + end + end + + if justifyContent == "flex-end" then + local offset = containerWidth - (currentX - spacing - 1) + for _, child in ipairs(children) do + child.set("x", child.get("x") + offset) + end + elseif justifyContent == "flex-center" or justifyContent == "center" then -- Akzeptiere beide Formate + local offset = math.floor((containerWidth - (currentX - spacing - 1)) / 2) + for _, child in ipairs(children) do + child.set("x", child.get("x") + offset) + end + end +end + +local function calculateColumn(self, children, spacing, justifyContent) + local containerHeight = self.get("height") + + local usedSpace = spacing * (#children - 1) + local totalFlexGrow = 0 + + for _, child in ipairs(children) do + if child ~= lineBreakElement then + usedSpace = usedSpace + child.get("height") + totalFlexGrow = totalFlexGrow + child.get("flexGrow") + end + end + + local remainingSpace = containerHeight - usedSpace + local extraSpacePerUnit = totalFlexGrow > 0 and (remainingSpace / totalFlexGrow) or 0 + local distributedSpace = 0 + + local currentY = 1 + for i, child in ipairs(children) do + if child ~= lineBreakElement then + local childHeight = child.get("height") + + if child.get("flexGrow") > 0 then + + if i == #children then + local extraSpace = remainingSpace - distributedSpace + childHeight = childHeight + extraSpace + else + local extraSpace = math.floor(extraSpacePerUnit * child.get("flexGrow")) + childHeight = childHeight + extraSpace + distributedSpace = distributedSpace + extraSpace + end + end + + child.set("x", children.offset or 1) + child.set("y", currentY) + child.set("height", childHeight) + currentY = currentY + childHeight + spacing + end + end + + if justifyContent == "flex-end" then + local offset = containerHeight - (currentY - spacing - 1) + for _, child in ipairs(children) do + child.set("y", child.get("y") + offset) + end + elseif justifyContent == "flex-center" or justifyContent == "center" then -- Akzeptiere beide Formate + local offset = math.floor((containerHeight - (currentY - spacing - 1)) / 2) + for _, child in ipairs(children) do + child.set("y", child.get("y") + offset) + end + end +end + +local function updateLayout(self, direction, spacing, justifyContent, wrap) + local elements = sortElements(self, direction, spacing, wrap) + if direction == "row" then + for _,v in pairs(elements)do + calculateRow(self, v, spacing, justifyContent) + end + else + for _,v in pairs(elements)do + calculateColumn(self, v, spacing, justifyContent) + end + end + self.set("flexUpdateLayout", false) +end + +--- Creates a new Flexbox instance +--- @param props table The properties to initialize the element with +--- @param basalt table The basalt instance +--- @return Flexbox object The newly created Flexbox instance +--- @usage local element = Flexbox.new("myId", basalt) +function Flexbox.new(props, basalt) + local self = setmetatable({}, Flexbox):__init() + self:init(props, basalt) + self.set("width", 12) + self.set("height", 6) + self.set("background", colors.blue) + self.set("z", 10) + self:observe("width", function() self.set("flexUpdateLayout", true) end) + self:observe("height", function() self.set("flexUpdateLayout", true) end) + return self +end + +function Flexbox:init(props, basalt) + Container.init(self, props, basalt) + self.set("type", "Flexbox") +end + +function Flexbox:addChild(element) + Container.addChild(self, element) + + if(element~=lineBreakElement)then + element:instanceProperty("flexGrow", {default = 0, type = "number"}) + element:instanceProperty("flexShrink", {default = 0, type = "number"}) + element:instanceProperty("flexBasis", {default = 0, type = "number"}) + end + + self.set("flexUpdateLayout", true) + return self +end + +function Flexbox:removeChild(element) + Container.removeChild(self, element) + + if(element~=lineBreakElement)then + element.setFlexGrow = nil + element.setFlexShrink = nil + element.setFlexBasis = nil + element.getFlexGrow = nil + element.getFlexShrink = nil + element.getFlexBasis = nil + element.set("flexGrow", nil) + element.set("flexShrink", nil) + element.set("flexBasis", nil) + end + + self.set("flexUpdateLayout", true) + return self +end + +--- Adds a new line break to the flexbox. +---@param self Flexbox The element itself +---@return Flexbox +function Flexbox:addLineBreak() + self:addChild(lineBreakElement) + return self +end + +function Flexbox:render() + if(self.get("flexUpdateLayout"))then + updateLayout(self, self.get("flexDirection"), self.get("flexSpacing"), self.get("flexJustifyContent"), self.get("flexWrap")) + end + Container.render(self) +end + +return Flexbox \ No newline at end of file diff --git a/src/elements/Frame.lua b/src/elements/Frame.lua index 2dd7459..9a955c9 100644 --- a/src/elements/Frame.lua +++ b/src/elements/Frame.lua @@ -1,18 +1,28 @@ -local Container = require("elements/Container") +local elementManager = require("elementManager") +local Container = elementManager.getElement("Container") ---@class Frame : Container local Frame = setmetatable({}, Container) Frame.__index = Frame -function Frame.new(id, basalt) +--- Creates a new Frame instance +--- @param props table The properties to initialize the element with +--- @param basalt table The basalt instance +--- @return Frame object The newly created Frame instance +--- @usage local element = Frame.new("myId", basalt) +function Frame.new(props, basalt) local self = setmetatable({}, Frame):__init() - self:init(id, basalt) + self:init(props, basalt) self.set("width", 12) self.set("height", 6) self.set("background", colors.blue) - self.set("type", "Frame") self.set("z", 10) return self end +function Frame:init(props, basalt) + Container.init(self, props, basalt) + self.set("type", "Frame") +end + return Frame \ No newline at end of file diff --git a/src/elements/Input.lua b/src/elements/Input.lua new file mode 100644 index 0000000..d7d2936 --- /dev/null +++ b/src/elements/Input.lua @@ -0,0 +1,111 @@ +local VisualElement = require("elements/VisualElement") + +---@class Input : VisualElement +local Input = setmetatable({}, VisualElement) +Input.__index = Input + +---@property text string Input - text to be displayed +Input.defineProperty(Input, "text", {default = "", type = "string", canTriggerRender = true}) + +---@property cursorPos number Input - current cursor position +Input.defineProperty(Input, "cursorPos", {default = 1, type = "number"}) + +---@property viewOffset number Input - offset für Text-Viewport +Input.defineProperty(Input, "viewOffset", {default = 0, type = "number"}) + +Input.listenTo(Input, "mouse_click") +Input.listenTo(Input, "key") +Input.listenTo(Input, "char") + +--- Creates a new Input instance +--- @param id string The unique identifier for this element +--- @param basalt table The basalt instance +--- @return Input object The newly created Input instance +--- @usage local element = Input.new("myId", basalt) +function Input.new(id, basalt) + local self = setmetatable({}, Input):__init() + self:init(id, basalt) + self.set("width", 8) + self.set("z", 3) + return self +end + +function Input:init(id, basalt) + VisualElement.init(self, id, basalt) + self.set("type", "Input") +end + +function Input:char(char) + if not self.get("focused") then return end + local text = self.get("text") + local pos = self.get("cursorPos") + self.set("text", text:sub(1, pos-1) .. char .. text:sub(pos)) + self.set("cursorPos", pos + 1) + self:updateViewport() +end + +function Input:key(key) + if not self.get("focused") then return end + local pos = self.get("cursorPos") + local text = self.get("text") + + if key == keys.left and pos > 1 then + self.set("cursorPos", pos - 1) + self:setCursor(pos - 1,1, true) + elseif key == keys.right and pos <= #text then + self.set("cursorPos", pos + 1) + self:setCursor(pos + 1,1, true) + elseif key == keys.backspace and pos > 1 then + self.set("text", text:sub(1, pos-2) .. text:sub(pos)) + self.set("cursorPos", pos - 1) + self:setCursor(pos - 1,1, true) + end + self:updateViewport() +end + +function Input:focus() + VisualElement.focus(self) + self.set("background", colors.blue) + self:setCursor(1,1, true) +end + +function Input:blur() + VisualElement.blur(self) + self.set("background", colors.green) +end + +function Input:updateViewport() + local width = self.get("width") + local text = self.get("text") + local cursorPos = self.get("cursorPos") + local viewOffset = self.get("viewOffset") + + -- Wenn Cursor außerhalb des sichtbaren Bereichs nach rechts + if cursorPos - viewOffset > width then + self.set("viewOffset", cursorPos - width) + end + + -- Wenn Cursor außerhalb des sichtbaren Bereichs nach links + if cursorPos <= viewOffset then + self.set("viewOffset", cursorPos - 1) + end +end + +function Input:render() + VisualElement.render(self) + local text = self.get("text") + local viewOffset = self.get("viewOffset") + local width = self.get("width") + + -- Nur den sichtbaren Teil des Textes rendern + local visibleText = text:sub(viewOffset + 1, viewOffset + width) + self:textFg(1, 1, visibleText, self.get("foreground")) + + if self.get("focused") then + local cursorPos = self.get("cursorPos") + -- Cursor relativ zum Viewport positionieren + self:setCursor(cursorPos - viewOffset, 1, true) + end +end + +return Input \ No newline at end of file diff --git a/src/elements/Label.lua b/src/elements/Label.lua new file mode 100644 index 0000000..93cd6a6 --- /dev/null +++ b/src/elements/Label.lua @@ -0,0 +1,38 @@ +local elementManager = require("elementManager") +local VisualElement = elementManager.getElement("VisualElement") + +---@class Label : VisualElement +local Label = setmetatable({}, VisualElement) +Label.__index = Label + +---@property text string Label Label text to be displayed +Label.defineProperty(Label, "text", {default = "Label", type = "string", setter = function(self, value) + self.set("width", #value) + return value +end}) + +--- Creates a new Label instance +--- @param name table The properties to initialize the element with +--- @param basalt table The basalt instance +--- @return Label object The newly created Label instance +--- @usage local element = Label.new("myId", basalt) +function Label.new(props, basalt) + local self = setmetatable({}, Label):__init() + self:init(props, basalt) + self.set("z", 3) + self.set("backgroundEnabled", false) + return self +end + +function Label:init(props, basalt) + VisualElement.init(self, props, basalt) + self.set("type", "Label") +end + +function Label:render() + VisualElement.render(self) + local text = self.get("text") + self:textFg(1, 1, text, self.get("foreground")) +end + +return Label \ No newline at end of file diff --git a/src/elements/VisualElement.lua b/src/elements/VisualElement.lua index 96043bf..dd44b1d 100644 --- a/src/elements/VisualElement.lua +++ b/src/elements/VisualElement.lua @@ -1,4 +1,5 @@ -local BaseElement = require("elements/BaseElement") +local elementManager = require("elementManager") +local BaseElement = elementManager.getElement("BaseElement") ---@alias color number @@ -8,40 +9,70 @@ VisualElement.__index = VisualElement local tHex = require("libraries/colorHex") ---@property x number 1 x position of the element -BaseElement.defineProperty(VisualElement, "x", {default = 1, type = "number", canTriggerRender = true}) +VisualElement.defineProperty(VisualElement, "x", {default = 1, type = "number", canTriggerRender = true}) ---@property y number 1 y position of the element -BaseElement.defineProperty(VisualElement, "y", {default = 1, type = "number", canTriggerRender = true}) +VisualElement.defineProperty(VisualElement, "y", {default = 1, type = "number", canTriggerRender = true}) ---@property z number 1 z position of the element -BaseElement.defineProperty(VisualElement, "z", {default = 1, type = "number", canTriggerRender = true, setter = function(self, value) - self.basalt.LOGGER.debug("Setting z to " .. value) +VisualElement.defineProperty(VisualElement, "z", {default = 1, type = "number", canTriggerRender = true, setter = function(self, value) if self.parent then self.parent:sortChildren() end return value end}) + ---@property width number 1 width of the element -BaseElement.defineProperty(VisualElement, "width", {default = 1, type = "number", canTriggerRender = true}) +VisualElement.defineProperty(VisualElement, "width", {default = 1, type = "number", canTriggerRender = true}) ---@property height number 1 height of the element -BaseElement.defineProperty(VisualElement, "height", {default = 1, type = "number", canTriggerRender = true}) +VisualElement.defineProperty(VisualElement, "height", {default = 1, type = "number", canTriggerRender = true}) ---@property background color black background color of the element -BaseElement.defineProperty(VisualElement, "background", {default = colors.black, type = "number", canTriggerRender = true}) +VisualElement.defineProperty(VisualElement, "background", {default = colors.black, type = "number", canTriggerRender = true}) ---@property foreground color white foreground color of the element -BaseElement.defineProperty(VisualElement, "foreground", {default = colors.white, type = "number", canTriggerRender = true}) ----@property clicked boolean false element is currently clicked -BaseElement.defineProperty(VisualElement, "clicked", {default = false, type = "boolean"}) +VisualElement.defineProperty(VisualElement, "foreground", {default = colors.white, type = "number", canTriggerRender = true}) +---@property clicked boole an false element is currently clicked +VisualElement.defineProperty(VisualElement, "clicked", {default = false, type = "boolean"}) +---@property backgroundEnabled boolean true whether the background is enabled +VisualElement.defineProperty(VisualElement, "backgroundEnabled", {default = true, type = "boolean", canTriggerRender = true}) +---@property focused boolean false whether the element is focused +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}) + +VisualElement.listenTo(VisualElement, "focus") +VisualElement.listenTo(VisualElement, "blur") --- Creates a new VisualElement instance ---- @param id string The unique identifier for this element +--- @param props table The properties to initialize the element with --- @param basalt table The basalt instance --- @return VisualElement object The newly created VisualElement instance --- @usage local element = VisualElement.new("myId", basalt) -function VisualElement.new(id, basalt) +function VisualElement.new(props, basalt) local self = setmetatable({}, VisualElement):__init() - self:init(id, basalt) + self:init(props, basalt) self.set("type", "VisualElement") return self end +function VisualElement:init(props, basalt) + BaseElement.init(self, props, basalt) + self.set("type", "VisualElement") +end + --- Draws a text character/fg/bg at the specified position with a certain size, used in the rendering system --- @param x number The x position to draw --- @param y number The y position to draw @@ -87,9 +118,10 @@ end function VisualElement:mouse_click(button, x, y) if self:isInBounds(x, y) then self.set("clicked", true) - self:fireEvent("mouse_click", button, x, y) + self:fireEvent("mouse_click", button, self:getRelativePosition(x, y)) return true end + return false end function VisualElement:mouse_up(button, x, y) @@ -98,40 +130,43 @@ function VisualElement:mouse_up(button, x, y) self:fireEvent("mouse_up", button, x, y) return true end - self:fireEvent("mouse_release", button, x, y) + self:fireEvent("mouse_release", button, self:getRelativePosition(x, y)) end function VisualElement:mouse_release() self.set("clicked", false) end ---- Handles all events ---- @param event string The event to handle ---- @vararg any The arguments for the event ---- @return boolean? handled Whether the event was handled -function VisualElement:handleEvent(event, ...) - if(self[event])then - return self[event](self, ...) - end +function VisualElement:focus() + self:fireEvent("focus") +end + +function VisualElement:blur() + self:fireEvent("blur") + self:setCursor(1,1, false) end --- Returns the absolute position of the element or the given coordinates. ---@param x? number x position ---@param y? number y position function VisualElement:getAbsolutePosition(x, y) - if (x == nil) or (y == nil) then - x, y = self.get("x"), self.get("y") + local xPos, yPos = self.get("x"), self.get("y") + if(x ~= nil) then + xPos = xPos + x - 1 + end + if(y ~= nil) then + yPos = yPos + y - 1 end local parent = self.parent while parent do local px, py = parent.get("x"), parent.get("y") - x = x + px - 1 - y = y + py - 1 + xPos = xPos + px - 1 + yPos = yPos + py - 1 parent = parent.parent end - return x, y + return xPos, yPos end --- Returns the relative position of the element or the given coordinates. @@ -155,10 +190,19 @@ function VisualElement:getRelativePosition(x, y) y - (elementY - 1) - (parentY - 1) end +function VisualElement:setCursor(x, y, blink) + if self.parent then + local absX, absY = self:getAbsolutePosition(x, y) + return self.parent:setCursor(absX, absY, blink) + end +end --- Renders the element --- @usage element:render() function VisualElement:render() + if(not self.get("backgroundEnabled"))then + return + end local width, height = self.get("width"), self.get("height") self:multiBlit(1, 1, width, height, " ", tHex[self.get("foreground")], tHex[self.get("background")]) end diff --git a/src/errorManager.lua b/src/errorManager.lua index 140d693..6b50e32 100644 --- a/src/errorManager.lua +++ b/src/errorManager.lua @@ -95,7 +95,7 @@ function errorHandler.error(errMsg) file.close() end end - + term.setBackgroundColor(colors.black) LOGGER.error(errMsg) error() diff --git a/src/init.lua b/src/init.lua index 99f9e0b..1ff35cb 100644 --- a/src/init.lua +++ b/src/init.lua @@ -1,7 +1,5 @@ - local args = {...} - -local basaltPath = args[1] or "basalt" +local basaltPath = fs.getDir(args[2]) local defaultPath = package.path local format = "path;/path/?.lua;/path/?/init.lua;" @@ -18,6 +16,7 @@ end -- Use xpcall with error handler local ok, result = pcall(require, "main") +package.path = defaultPath if not ok then errorHandler(result) else diff --git a/src/libraries/utils.lua b/src/libraries/utils.lua index 3c7d137..7b3206e 100644 --- a/src/libraries/utils.lua +++ b/src/libraries/utils.lua @@ -24,4 +24,32 @@ function utils.deepCopy(obj) return copy end +function utils.uuid() + return string.format('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + math.random(0, 0xffff), math.random(0, 0xffff), math.random(0, 0xffff), + math.random(0, 0x0fff) + 0x4000, math.random(0, 0x3fff) + 0x8000, + math.random(0, 0xffff), math.random(0, 0xffff), math.random(0, 0xffff)) +end + +function utils.split(str, sep) + local parts = {} + local start = 1 + local len = len(str) + local splitIndex = 1 + + while true do + local index = str:find(sep, start, true) + if not index then + parts[splitIndex] = str:sub(start, len) + break + end + + parts[splitIndex] = str:sub(start, index - 1) + start = index + 1 + splitIndex = splitIndex + 1 + end + + return parts +end + return utils \ No newline at end of file diff --git a/src/main.lua b/src/main.lua index db16f7f..39c9e92 100644 --- a/src/main.lua +++ b/src/main.lua @@ -1,5 +1,9 @@ +local benchmark = require("benchmark") +benchmark.start("Basalt Initialization") local elementManager = require("elementManager") local errorManager = require("errorManager") +local propertySystem = require("propertySystem") + --- This is the UI Manager and the starting point for your project. The following functions allow you to influence the default behavior of Basalt. --- @@ -13,24 +17,65 @@ basalt._events = {} basalt._schedule = {} basalt._plugins = {} basalt.LOGGER = require("log") +basalt.path = fs.getDir(select(2, ...)) local mainFrame = nil local updaterActive = false +local _type = type + +local lazyElements = {} +local lazyElementCount = 10 +local lazyElementsTimer = 0 +local isLazyElementsTimerActive = false + +local function queueLazyElements() + if(isLazyElementsTimerActive)then return end + lazyElementsTimer = os.startTimer(0.2) + isLazyElementsTimerActive = true +end + +local function loadLazyElements(count) + for _=1,count do + local blueprint = lazyElements[1] + if(blueprint)then + blueprint:create() + end + table.remove(lazyElements, 1) + end +end + +local function lazyElementsEventHandler(event, timerId) + if(event=="timer")then + if(timerId==lazyElementsTimer)then + loadLazyElements(lazyElementCount) + isLazyElementsTimerActive = false + lazyElementsTimer = 0 + if(#lazyElements>0)then + queueLazyElements() + end + return true + end + end +end --- Creates and returns a new UI element of the specified type. --- @shortDescription Creates a new UI element --- @param type string The type of element to create (e.g. "Button", "Label", "BaseFrame") ---- @param id? string Optional unique identifier for the element +--- @param properties? string|table Optional name for the element or a table with properties to initialize the element with --- @return table element The created element instance --- @usage local button = basalt.create("Button") -function basalt.create(type, id) - if(id==nil)then id = elementManager.generateId() end - local element = elementManager.getElement(type).new(id, basalt) - local ok, result = pcall(require, "main") - if not ok then - errorManager(false, result) +function basalt.create(type, properties, lazyLoading, parent) + if(_type(properties)=="string")then properties = {name=properties} end + if(properties == nil)then properties = {name = type} end + local elementClass = elementManager.getElement(type) + if(lazyLoading)then + local blueprint = propertySystem.blueprint(elementClass, properties, basalt, parent) + table.insert(lazyElements, blueprint) + queueLazyElements() + return blueprint + else + return elementClass.new(properties, basalt) end - return element end --- Creates and returns a new frame @@ -87,18 +132,11 @@ end --- @local Internal event handler local function updateEvent(event, ...) if(event=="terminate")then basalt.stop() end + if lazyElementsEventHandler(event, ...) then return end - if event:find("mouse") then - if mainFrame then - mainFrame:handleEvent(event, ...) - end - end - - if event:find("key") then - if mainFrame then - mainFrame:handleEvent(event, ...) - end - end + if(mainFrame:dispatchEvent(event, ...))then + return + end if basalt._events[event] then for _, callback in ipairs(basalt._events[event]) do @@ -137,12 +175,14 @@ end --- @usage basalt.run() --- @usage basalt.run(false) function basalt.run(isActive) + benchmark.stop("Basalt Initialization") updaterActive = isActive if(isActive==nil)then updaterActive = true end local function f() renderFrames() while updaterActive do updateEvent(os.pullEventRaw()) + renderFrames() end end while updaterActive do diff --git a/src/plugins/animation.lua b/src/plugins/animation.lua new file mode 100644 index 0000000..4c41fbd --- /dev/null +++ b/src/plugins/animation.lua @@ -0,0 +1,327 @@ +local Animation = {} +Animation.__index = Animation + +local registeredAnimations = {} + +function Animation.registerAnimation(name, handlers) + registeredAnimations[name] = handlers + + Animation[name] = function(self, ...) + local args = {...} + local easing = "linear" + if(type(args[#args]) == "string") then + easing = table.remove(args, #args) + end + local duration = table.remove(args, #args) + return self:addAnimation(name, args, duration, easing) + end +end + +local easings = { + linear = function(progress) + return progress + end, + + easeInQuad = function(progress) + return progress * progress + end, + + easeOutQuad = function(progress) + return 1 - (1 - progress) * (1 - progress) + end, + + easeInOutQuad = function(progress) + if progress < 0.5 then + return 2 * progress * progress + end + return 1 - (-2 * progress + 2)^2 / 2 + end +} + +function Animation.registerEasing(name, func) + easings[name] = func +end + +local AnimationInstance = {} +AnimationInstance.__index = AnimationInstance + +function AnimationInstance.new(element, animType, args, duration, easing) + local self = setmetatable({}, AnimationInstance) + self.element = element + self.type = animType + self.args = args + self.duration = duration + self.startTime = 0 + self.isPaused = false + self.handlers = registeredAnimations[animType] + self.easing = easing + return self +end + +function AnimationInstance:start() + self.startTime = os.epoch("local") / 1000 + if self.handlers.start then + self.handlers.start(self) + end +end + +function AnimationInstance:update(elapsed) + local rawProgress = math.min(1, elapsed / self.duration) + local progress = easings[self.easing](rawProgress) + return self.handlers.update(self, progress) +end + +function AnimationInstance:complete() + if self.handlers.complete then + self.handlers.complete(self) + end +end + +function Animation.new(element) + local self = {} + self.element = element + self.sequences = {{}} + self.sequenceCallbacks = {} + self.currentSequence = 1 + self.timer = nil + setmetatable(self, Animation) + return self +end + +function Animation:sequence() + table.insert(self.sequences, {}) + self.currentSequence = #self.sequences + self.sequenceCallbacks[self.currentSequence] = { + start = nil, + update = nil, + complete = nil + } + return self +end + +function Animation:onStart(callback) + if not self.sequenceCallbacks[self.currentSequence] then + self.sequenceCallbacks[self.currentSequence] = {} + end + self.sequenceCallbacks[self.currentSequence].start = callback + return self +end + +function Animation:onUpdate(callback) + if not self.sequenceCallbacks[self.currentSequence] then + self.sequenceCallbacks[self.currentSequence] = {} + end + self.sequenceCallbacks[self.currentSequence].update = callback + return self +end + +function Animation:onComplete(callback) + if not self.sequenceCallbacks[self.currentSequence] then + self.sequenceCallbacks[self.currentSequence] = {} + end + self.sequenceCallbacks[self.currentSequence].complete = callback + return self +end + +function Animation:addAnimation(type, args, duration, easing) + local anim = AnimationInstance.new(self.element, type, args, duration, easing) + table.insert(self.sequences[self.currentSequence], anim) + return self +end + +function Animation:start() + + self.currentSequence = 1 + if(self.sequenceCallbacks[self.currentSequence])then + if(self.sequenceCallbacks[self.currentSequence].start) then + self.sequenceCallbacks[self.currentSequence].start(self.element) + end + end + if #self.sequences[self.currentSequence] > 0 then + self.timer = os.startTimer(0.05) + for _, anim in ipairs(self.sequences[self.currentSequence]) do + anim:start() + end + end + return self +end + +function Animation:event(event, timerId) + if event == "timer" and timerId == self.timer then + local currentTime = os.epoch("local") / 1000 + local sequenceFinished = true + local remaining = {} + local callbacks = self.sequenceCallbacks[self.currentSequence] + + for _, anim in ipairs(self.sequences[self.currentSequence]) do + local elapsed = currentTime - anim.startTime + local progress = elapsed / anim.duration + local finished = anim:update(elapsed) + + if callbacks and callbacks.update then + callbacks.update(self.element, progress) + end + + if not finished then + table.insert(remaining, anim) + sequenceFinished = false + else + anim:complete() + end + end + + if sequenceFinished then + if callbacks and callbacks.complete then + callbacks.complete(self.element) + end + + if self.currentSequence < #self.sequences then + self.currentSequence = self.currentSequence + 1 + remaining = {} + + local nextCallbacks = self.sequenceCallbacks[self.currentSequence] + if nextCallbacks and nextCallbacks.start then + nextCallbacks.start(self.element) + end + + for _, anim in ipairs(self.sequences[self.currentSequence]) do + anim:start() + table.insert(remaining, anim) + end + end + end + + if #remaining > 0 then + self.timer = os.startTimer(0.05) + end + end +end + +Animation.registerAnimation("move", { + start = function(anim) + anim.startX = anim.element.get("x") + anim.startY = anim.element.get("y") + end, + + update = function(anim, progress) + local x = anim.startX + (anim.args[1] - anim.startX) * progress + local y = anim.startY + (anim.args[2] - anim.startY) * progress + anim.element.set("x", math.floor(x)) + anim.element.set("y", math.floor(y)) + return progress >= 1 + end, + + complete = function(anim) + anim.element.set("x", anim.args[1]) + anim.element.set("y", anim.args[2]) + end +}) + +Animation.registerAnimation("morphText", { + start = function(anim) + local startText = anim.element.get(anim.args[1]) + local targetText = anim.args[2] + local maxLength = math.max(#startText, #targetText) + local startSpace = string.rep(" ", math.floor(maxLength - #startText)/2) + anim.startText = startSpace .. startText .. startSpace + anim.targetText = targetText .. string.rep(" ", maxLength - #targetText) + anim.length = maxLength + end, + + update = function(anim, progress) + local currentText = "" + + for i = 1, anim.length do + local startChar = anim.startText:sub(i,i) + local targetChar = anim.targetText:sub(i,i) + + if progress < 0.5 then + currentText = currentText .. (math.random() > progress*2 and startChar or " ") + else + currentText = currentText .. (math.random() > (progress-0.5)*2 and " " or targetChar) + end + end + + anim.element.set(anim.args[1], currentText) + return progress >= 1 + end, + + complete = function(anim) + anim.element.set(anim.args[1], anim.targetText:gsub("%s+$", "")) -- Entferne trailing spaces + end +}) + +Animation.registerAnimation("typewrite", { + start = function(anim) + anim.targetText = anim.args[2] + anim.element.set(anim.args[1], "") + end, + + update = function(anim, progress) + local length = math.floor(#anim.targetText * progress) + anim.element.set(anim.args[1], anim.targetText:sub(1, length)) + return progress >= 1 + end +}) + +Animation.registerAnimation("fadeText", { + start = function(anim) + anim.chars = {} + for i=1, #anim.args[2] do + anim.chars[i] = {char = anim.args[2]:sub(i,i), visible = false} + end + end, + + update = function(anim, progress) + local text = "" + for i, charData in ipairs(anim.chars) do + if math.random() < progress then + charData.visible = true + end + text = text .. (charData.visible and charData.char or " ") + end + anim.element.set(anim.args[1], text) + return progress >= 1 + end +}) + +Animation.registerAnimation("scrollText", { + start = function(anim) + anim.width = anim.element.get("width") + anim.targetText = anim.args[2] + anim.element.set(anim.args[1], "") + end, + + update = function(anim, progress) + local offset = math.floor(anim.width * (1-progress)) + local spaces = string.rep(" ", offset) + anim.element.set(anim.args[1], spaces .. anim.targetText) + return progress >= 1 + end +}) + +local VisualElement = {hooks={}} + +function VisualElement.hooks.dispatchEvent(self, event, ...) + if event == "timer" then + local animation = self.get("animation") + if animation then + animation:event(event, ...) + end + end +end + +function VisualElement.setup(element) + element.defineProperty(element, "animation", {default = nil, type = "table"}) + element.listenTo(element, "timer") +end + +function VisualElement:animate() + local animation = Animation.new(self) + self.set("animation", animation) + return animation +end + +return { + VisualElement = VisualElement +} \ No newline at end of file diff --git a/src/plugins/pluginTemplate.lua b/src/plugins/pluginTemplate.lua new file mode 100644 index 0000000..bc56f00 --- /dev/null +++ b/src/plugins/pluginTemplate.lua @@ -0,0 +1,23 @@ +-- Will temporary exist so that we don't lose track of how the plugin system works + +local VisualElement = {hooks={init={}}} + +-- Called on Class level to define properties and setup before instance is created +function VisualElement.setup(element) + element.defineProperty(element, "testProp", {default = 5, type = "number"}) +end + +-- Hooks into existing methods (you can also use init.pre or init.post) +function VisualElement.hooks.init(self) + --self.basalt.LOGGER.debug("VisualElement initialized") +end + +-- Adds a new method to the class +function VisualElement:testFunc() + --self.basalt.LOGGER.debug("Hello World", self.get("testProp")) +end + +return { + VisualElement = VisualElement +} + diff --git a/src/plugins/reactive.lua b/src/plugins/reactive.lua new file mode 100644 index 0000000..ef3907e --- /dev/null +++ b/src/plugins/reactive.lua @@ -0,0 +1,33 @@ +local function setupReactiveProperty(element, propertyName, expression) + +end + +local function createReactiveFunction(expression, scope) + local code = expression:gsub( + "(%w+)%s*%?%s*([^:]+)%s*:%s*([^}]+)", + "%1 and %2 or %3" + ) + + return load(string.format([[ + return function(self) + return %s + end + ]], code), "reactive", "t", scope)() +end + +local BaseElement = {} + +function BaseElement:setReactiveProperty(propertyName, expression) + setupReactiveProperty(self, propertyName, expression) + return self +end + +function BaseElement:setReactive(propertyName, expression) + local reactiveFunc = createReactiveFunction(expression, self) + self.set(propertyName, reactiveFunc) + return self +end + +return { + BaseElement = BaseElement +} diff --git a/src/plugins/xml.lua b/src/plugins/xml.lua new file mode 100644 index 0000000..bd7eefa --- /dev/null +++ b/src/plugins/xml.lua @@ -0,0 +1,184 @@ +local errorManager = require("errorManager") + +local function parseTag(str) + local tag = { + attributes = {} + } + tag.name = str:match("<(%w+)") + for k,v in str:gmatch('%s(%w+)="([^"]-)"') do + tag.attributes[k] = v + end + return tag +end + +local function parseXML(self, xmlString) + local stack = {} + local root = {children = {}} + local current = root + local inCDATA = false + local cdataContent = "" + + for line in xmlString:gmatch("[^\r\n]+") do + line = line:match("^%s*(.-)%s*$") + self.basalt.LOGGER.debug("Parsing line: " .. line) + + if line:match("^$") and inCDATA then + inCDATA = false + current.content = cdataContent + elseif inCDATA then + cdataContent = cdataContent .. line .. "\n" + elseif line:match("^<[^/]") then + local tag = parseTag(line) + tag.children = {} + tag.content = "" + table.insert(current.children, tag) + + if not line:match("/>$") then + table.insert(stack, current) + current = tag + end + elseif line:match("^ 0 then + createElements(node, element, scope) + end + end + end + end + + createElements(tree, self, scope) + return self +end + +return { + BaseElement = BaseElement, + Container = Container +} + diff --git a/src/propertySystem.lua b/src/propertySystem.lua index 82f1156..6c39a78 100644 --- a/src/propertySystem.lua +++ b/src/propertySystem.lua @@ -1,11 +1,14 @@ local deepCopy = require("libraries/utils").deepCopy local expect = require("libraries/expect") +local errorManager = require("errorManager") +local log = require("log") --- @class PropertySystem local PropertySystem = {} PropertySystem.__index = PropertySystem PropertySystem._properties = {} +local blueprintTemplates = {} function PropertySystem.defineProperty(class, name, config) if not rawget(class, '_properties') then @@ -22,43 +25,162 @@ function PropertySystem.defineProperty(class, name, config) local capitalizedName = name:sub(1,1):upper() .. name:sub(2) - class["get" .. capitalizedName] = function(self) + class["get" .. capitalizedName] = function(self, ...) expect(1, self, "element") local value = self._values[name] - return config.getter and config.getter(value) or value + return config.getter and config.getter(self, value, ...) or value end - class["set" .. capitalizedName] = function(self, value) + class["set" .. capitalizedName] = function(self, value, ...) expect(1, self, "element") expect(2, value, config.type) if config.setter then - value = config.setter(self, value) + value = config.setter(self, value, ...) end - self:_updateProperty(name, value) return self end end +--- Creates a blueprint of an element class with all its properties +--- @param elementClass table The element class to create a blueprint from +--- @return table blueprint A table containing all property definitions +function PropertySystem.blueprint(elementClass, properties, basalt, parent) + if not blueprintTemplates[elementClass] then + local template = { + basalt = basalt, + __isBlueprint = true, + _values = properties or {}, + _events = {}, + render = function() end, + dispatchEvent = function() end, + init = function() end, + } + + template.loaded = function(self, callback) + self.loadedCallback = callback + return template + end + + template.create = function(self) + local element = elementClass.new({}, basalt) + for name, value in pairs(self._values) do + element._values[name] = value + end + for name, callbacks in pairs(self._events) do + for _, callback in ipairs(callbacks) do + element[name](element, callback) + end + end + if(parent~=nil)then + parent:addChild(element) + end + element:updateRender() + self.loadedCallback(element) + return element + end + + local currentClass = elementClass + while currentClass do + if rawget(currentClass, '_properties') then + for name, config in pairs(currentClass._properties) do + if type(config.default) == "table" then + template._values[name] = deepCopy(config.default) + else + template._values[name] = config.default + end + end + end + currentClass = getmetatable(currentClass) and rawget(getmetatable(currentClass), '__index') + end + + blueprintTemplates[elementClass] = template + end + + local blueprint = { + _values = {}, + _events = {}, + loadedCallback = function() end, + } + + blueprint.get = function(name) + return blueprint._values[name] + end + blueprint.set = function(name, value) + blueprint._values[name] = value + return blueprint + end + + setmetatable(blueprint, { + __index = function(self, k) + if k:match("^on%u") then + return function(_, callback) + self._events[k] = self._events[k] or {} + table.insert(self._events[k], callback) + return self + end + end + if k:match("^get%u") then + local propName = k:sub(4,4):lower() .. k:sub(5) + return function() + return self._values[propName] + end + end + if k:match("^set%u") then + local propName = k:sub(4,4):lower() .. k:sub(5) + return function(_, value) + self._values[propName] = value + return self + end + end + return blueprintTemplates[elementClass][k] + end + }) + + return blueprint +end + +function PropertySystem.createFromBlueprint(elementClass, blueprint, basalt) + local element = elementClass.new({}, basalt) + for name, value in pairs(blueprint._values) do + if type(value) == "table" then + element._values[name] = deepCopy(value) + else + element._values[name] = value + end + end + + return element +end + function PropertySystem:__init() self._values = {} self._observers = {} - self.set = function(name, value) + self.set = function(name, value, ...) local oldValue = self._values[name] - self._values[name] = value - if(self._properties[name].setter) then - value = self._properties[name].setter(self, value) - end - if oldValue ~= value and self._observers[name] then - for _, callback in ipairs(self._observers[name]) do - callback(self, value, oldValue) + local config = self._properties[name] + if(config~=nil)then + if(config.setter) then + value = config.setter(self, value, ...) + end + if config.canTriggerRender then + self:updateRender() + end + self._values[name] = value + if oldValue ~= value and self._observers[name] then + for _, callback in ipairs(self._observers[name]) do + callback(self, value, oldValue) + end end end end - self.get = function(name) - return self._values[name] + self.get = function(name, ...) + local value = self._values[name] + local config = self._properties[name] + if(config==nil)then errorManager.error("Property not found: "..name) return end + return config.getter and config.getter(self, value, ...) or value end local properties = {} @@ -139,4 +261,25 @@ function PropertySystem:observe(name, callback) return self end +function PropertySystem:instanceProperty(name, config) + PropertySystem.defineProperty(self, name, config) + self._values[name] = config.default + return self +end + +function PropertySystem:removeProperty(name) + self._values[name] = nil + self._properties[name] = nil + self._observers[name] = nil + + local capitalizedName = name:sub(1,1):upper() .. name:sub(2) + self["get" .. capitalizedName] = nil + self["set" .. capitalizedName] = nil + return self +end + +function PropertySystem:getPropertyConfig(name) + return self._properties[name] +end + return PropertySystem \ No newline at end of file diff --git a/src/render.lua b/src/render.lua index d16bf37..d1f2779 100644 --- a/src/render.lua +++ b/src/render.lua @@ -1,7 +1,6 @@ local Render = {} Render.__index = Render local colorChars = require("libraries/colorHex") -local log = require("log") function Render.new(terminal) local self = setmetatable({}, Render) @@ -12,19 +11,27 @@ function Render.new(terminal) text = {}, fg = {}, bg = {}, - changed = {} + dirtyRects = {} } for y=1, self.height do self.buffer.text[y] = string.rep(" ", self.width) self.buffer.fg[y] = string.rep("0", self.width) self.buffer.bg[y] = string.rep("f", self.width) - self.buffer.changed[y] = false end return self end +function Render:addDirtyRect(x, y, width, height) + table.insert(self.buffer.dirtyRects, { + x = x, + y = y, + width = width, + height = height + }) +end + function Render:blit(x, y, text, fg, bg) if y < 1 or y > self.height then return self end if(#text ~= #fg or #text ~= #bg)then @@ -34,7 +41,7 @@ function Render:blit(x, y, text, fg, bg) self.buffer.text[y] = self.buffer.text[y]:sub(1,x-1) .. text .. self.buffer.text[y]:sub(x+#text) self.buffer.fg[y] = self.buffer.fg[y]:sub(1,x-1) .. fg .. self.buffer.fg[y]:sub(x+#fg) self.buffer.bg[y] = self.buffer.bg[y]:sub(1,x-1) .. bg .. self.buffer.bg[y]:sub(x+#bg) - self.buffer.changed[y] = true + self:addDirtyRect(x, y, #text, 1) return self end @@ -55,10 +62,10 @@ function Render:multiBlit(x, y, width, height, text, fg, bg) self.buffer.text[cy] = self.buffer.text[cy]:sub(1,x-1) .. text .. self.buffer.text[cy]:sub(x+#text) self.buffer.fg[cy] = self.buffer.fg[cy]:sub(1,x-1) .. fg .. self.buffer.fg[cy]:sub(x+#fg) self.buffer.bg[cy] = self.buffer.bg[cy]:sub(1,x-1) .. bg .. self.buffer.bg[cy]:sub(x+#bg) - self.buffer.changed[cy] = true end end + self:addDirtyRect(x, y, width, height) return self end @@ -68,7 +75,7 @@ function Render:textFg(x, y, text, fg) self.buffer.text[y] = self.buffer.text[y]:sub(1,x-1) .. text .. self.buffer.text[y]:sub(x+#text) self.buffer.fg[y] = self.buffer.fg[y]:sub(1,x-1) .. fg:rep(#text) .. self.buffer.fg[y]:sub(x+#text) - self.buffer.changed[y] = true + self:addDirtyRect(x, y, #text, 1) return self end @@ -77,7 +84,7 @@ function Render:text(x, y, text) if y < 1 or y > self.height then return self end self.buffer.text[y] = self.buffer.text[y]:sub(1,x-1) .. text .. self.buffer.text[y]:sub(x+#text) - self.buffer.changed[y] = true + self:addDirtyRect(x, y, #text, 1) return self end @@ -86,7 +93,7 @@ function Render:fg(x, y, fg) if y < 1 or y > self.height then return self end self.buffer.fg[y] = self.buffer.fg[y]:sub(1,x-1) .. fg .. self.buffer.fg[y]:sub(x+#fg) - self.buffer.changed[y] = true + self:addDirtyRect(x, y, #fg, 1) return self end @@ -95,7 +102,7 @@ function Render:bg(x, y, bg) if y < 1 or y > self.height then return self end self.buffer.bg[y] = self.buffer.bg[y]:sub(1,x-1) .. bg .. self.buffer.bg[y]:sub(x+#bg) - self.buffer.changed[y] = true + self:addDirtyRect(x, y, #bg, 1) return self end @@ -106,23 +113,84 @@ function Render:clear(bg) self.buffer.text[y] = string.rep(" ", self.width) self.buffer.fg[y] = string.rep("0", self.width) self.buffer.bg[y] = string.rep(bgChar, self.width) - self.buffer.changed[y] = true + self:addDirtyRect(1, y, self.width, 1) end return self end function Render:render() - for y=1, self.height do - if self.buffer.changed[y] then - self.terminal.setCursorPos(1, y) - self.terminal.blit( - self.buffer.text[y], - self.buffer.fg[y], - self.buffer.bg[y] - ) - self.buffer.changed[y] = false + local benchmark = require("benchmark") + benchmark.start("render") + + local mergedRects = {} + for _, rect in ipairs(self.buffer.dirtyRects) do + local merged = false + for _, existing in ipairs(mergedRects) do + if self:rectOverlaps(rect, existing) then + self:mergeRects(existing, rect) + merged = true + break + end + end + if not merged then + table.insert(mergedRects, rect) end end + + -- Nur die Dirty Rectangles rendern + for _, rect in ipairs(mergedRects) do + for y = rect.y, rect.y + rect.height - 1 do + if y >= 1 and y <= self.height then + self.terminal.setCursorPos(rect.x, y) + self.terminal.blit( + self.buffer.text[y]:sub(rect.x, rect.x + rect.width - 1), + self.buffer.fg[y]:sub(rect.x, rect.x + rect.width - 1), + self.buffer.bg[y]:sub(rect.x, rect.x + rect.width - 1) + ) + end + end + end + + benchmark.update("render") + self.buffer.dirtyRects = {} + + if self.blink then + self.terminal.setCursorPos(self.xCursor, self.yCursor) + self.terminal.setCursorBlink(true) + else + self.terminal.setCursorBlink(false) + end + + --benchmark.stop("render") + return self +end + +-- Hilfsfunktionen für Rectangle-Management +function Render:rectOverlaps(r1, r2) + return not (r1.x + r1.width <= r2.x or + r2.x + r2.width <= r1.x or + r1.y + r1.height <= r2.y or + r2.y + r2.height <= r1.y) +end + +function Render:mergeRects(target, source) + local x1 = math.min(target.x, source.x) + local y1 = math.min(target.y, source.y) + local x2 = math.max(target.x + target.width, source.x + source.width) + local y2 = math.max(target.y + target.height, source.y + source.height) + + target.x = x1 + target.y = y1 + target.width = x2 - x1 + target.height = y2 - y1 +end + +function Render:setCursor(x, y, blink) + self.terminal.setCursorPos(x, y) + self.terminal.setCursorBlink(blink) + self.xCursor = x + self.yCursor = y + self.blink = blink return self end