diff --git a/.gitignore b/.gitignore index 2f91782..672fe6c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ test.lua test2.lua lua-ls-cc-tweaked-main test.xml -ascii.lua \ No newline at end of file +ascii.lua +tests \ No newline at end of file diff --git a/README.md b/README.md index c92c316..c437523 100644 --- a/README.md +++ b/README.md @@ -1 +1,31 @@ -Basalt v2 Dev +# Basalt - A UI Framework for CC:Tweaked + +![GitHub Repo stars](https://img.shields.io/github/stars/Pyroxenium/Basalt2?style=for-the-badge) +[![Discord](https://img.shields.io/discord/976905222251233320?label=Discord&style=for-the-badge)](https://discord.gg/yNNnmBVBpE) + +This is a complete rework of Basalt. It provides an intuitive way to create complex user interfaces for your CC:Tweaked programs. + +Basalt is intended to be an easy-to-understand UI Framework designed for CC:Tweaked - a popular minecraft mod. For more information about CC:Tweaked, checkout the project's [wiki](https://tweaked.cc/) or [download](https://modrinth.com/mod/cc-tweaked). +**Note:** Basalt is still under developement and you may find bugs! + +Check out the [wiki](https://basalt.madefor.cc/) for more information. +If you have questions, feel free to join the discord server: [discord.gg/yNNnmBVBpE](https://discord.gg/yNNnmBVBpE). + +## Documentation + +For detailed documentation, examples and guides, visit [basalt.madefor.cc](https://basalt.madefor.cc/) + +## Support + +If you need help or have questions: +- Check the [documentation](https://basalt.madefor.cc/) +- Join our [Discord](https://discord.gg/yNNnmBVBpE) +- Report issues on [GitHub](https://github.com/Pyroxenium/Basalt2/issues) + +## License + +This project is licensed under the MIT License. + +## Demo + +![Demo of Basalt](https://raw.githubusercontent.com/Pyroxenium/Basalt/master/docs/_media/basaltPreview2.gif) \ No newline at end of file diff --git a/examples/benchmarks.lua b/examples/benchmarks.lua new file mode 100644 index 0000000..efb9165 --- /dev/null +++ b/examples/benchmarks.lua @@ -0,0 +1,19 @@ +local basalt = require("src") +local main = basalt.getMainFrame() + +local btn = main:addButton() + :setText("Test") + :setX(40) + :setY(5) + :onMouseClick(function() + main:logContainerBenchmarks("render") + --main:stopChildrenBenchmark("render") + end) + +local prog = main:addProgram() + :execute("../rom/programs/shell.lua") + +local frame main:addFrame():setX(30):addButton() + +main:benchmarkContainer("render") +basalt.run() \ No newline at end of file diff --git a/examples/states.lua b/examples/states.lua new file mode 100644 index 0000000..bae9717 --- /dev/null +++ b/examples/states.lua @@ -0,0 +1,111 @@ +local basalt = require("src") + +-- Create main frame +local main = basalt.getMainFrame() + +-- Create a complex form using states for managing its data +local form = main:addFrame() + :setSize("{parent.width - 4}", "{parent.height - 4}") + :setPosition(3, 3) + :setBackground(colors.lightGray) + -- Initialize form states with default values + :initializeState("username", "", true) -- true = triggers render update + :initializeState("email", "") + :initializeState("age", 0) + :initializeState("submitted", false) + -- Add computed state for form validation + :computed("isValid", function(self) + local username = self:getState("username") + local email = self:getState("email") + local age = self:getState("age") + return #username > 0 and email:match(".+@.+") and age > 0 + end) + +-- Create form title +form:addLabel() + :setText("Registration Form") + :setPosition(2, 2) + :setForeground(colors.black) + +-- Username input with state binding +local usernameInput = form:addInput() + :setPosition(2, 4) + :setSize(20, 1) + :setBackground(colors.white) + :setForeground(colors.black) + -- Update username state when input changes + :onChange(function(self, value) + form:setState("username", value) + end) + +form:addLabel() + :setText("Username:") + :setPosition(2, 3) + :setForeground(colors.black) + +-- Email input +local emailInput = form:addInput() + :setPosition(2, 6) + :setSize(20, 1) + :setBackground(colors.white) + :setForeground(colors.black) + :onChange(function(self, value) + form:setState("email", value) + end) + +form:addLabel() + :setText("Email:") + :setPosition(2, 5) + :setForeground(colors.black) + +-- Age input +local ageInput = form:addInput() + :setPosition(2, 8) + :setSize(5, 1) + :setBackground(colors.white) + :setForeground(colors.black) + :onChange(function(self, value) + -- Convert to number and update state + form:setState("age", tonumber(value) or 0) + end) + +form:addLabel() + :setText("Age:") + :setPosition(2, 7) + :setForeground(colors.black) + +-- Submit button that reacts to form validity +local submitBtn = form:addButton() + :setPosition(2, 10) + :setSize(10, 1) + :setText("Submit") + -- Button color changes based on form validity + :onStateChange("isValid", function(self, isValid) + submitBtn:setBackground(isValid and colors.lime or colors.gray) + end) + :onMouseClick(function() + if form.computedStates.isValid() then + form:setState("submitted", true) + end + end) + +-- Status message that updates when form is submitted +local statusLabel = form:addLabel() + :setPosition(2, 12) + :setForeground(colors.black) + +-- Listen for form submission +form:onStateChange("submitted", function(self, submitted) + if submitted then + local username = self:getState("username") + local email = self:getState("email") + local age = self:getState("age") + statusLabel:setText(string.format( + "Submitted: %s (%s) - Age: %d", + username, email, age + )) + end +end) + +-- Run basalt +basalt.run() diff --git a/src/benchmark.lua b/src/benchmark.lua deleted file mode 100644 index 8052a49..0000000 --- a/src/benchmark.lua +++ /dev/null @@ -1,33 +0,0 @@ --- 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 915ae70..039d25d 100644 --- a/src/elementManager.lua +++ b/src/elementManager.lua @@ -13,6 +13,7 @@ local main = format:gsub("path", dir) local ElementManager = {} ElementManager._elements = {} ElementManager._plugins = {} +ElementManager._APIs = {} local elementsDirectory = fs.combine(dir, "elements") local pluginsDirectory = fs.combine(dir, "plugins") @@ -40,10 +41,14 @@ if fs.exists(pluginsDirectory) then 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] = {} + if(k ~= "API")then + if(ElementManager._plugins[k]==nil)then + ElementManager._plugins[k] = {} + end + table.insert(ElementManager._plugins[k], v) + else + ElementManager._APIs[name] = v end - table.insert(ElementManager._plugins[k], v) end end end @@ -114,4 +119,8 @@ function ElementManager.getElementList() return ElementManager._elements end +function ElementManager.getAPI(name) + return ElementManager._APIs[name] +end + return ElementManager \ No newline at end of file diff --git a/src/elements/BaseElement.lua b/src/elements/BaseElement.lua index 25054ce..3de2a45 100644 --- a/src/elements/BaseElement.lua +++ b/src/elements/BaseElement.lua @@ -164,7 +164,7 @@ end --- @vararg any The arguments for the event --- @return boolean? handled Whether the event was handled function BaseElement:handleEvent(event, ...) - return true + return false end function BaseElement:getBaseFrame() diff --git a/src/elements/BaseFrame.lua b/src/elements/BaseFrame.lua index 0c8935e..5542dca 100644 --- a/src/elements/BaseFrame.lua +++ b/src/elements/BaseFrame.lua @@ -21,9 +21,9 @@ 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("background", colors.lightGray) + self:init(props, basalt) return self end diff --git a/src/elements/Button.lua b/src/elements/Button.lua index 9d06724..344ffb7 100644 --- a/src/elements/Button.lua +++ b/src/elements/Button.lua @@ -11,13 +11,14 @@ Button.defineProperty(Button, "text", {default = "Button", type = "string", canT ---@event mouse_click The event that is triggered when the button is clicked Button.listenTo(Button, "mouse_click") +Button.listenTo(Button, "mouse_up") function Button.new(props, basalt) local self = setmetatable({}, Button):__init() - self:init(props, basalt) self.set("width", 10) self.set("height", 3) self.set("z", 5) + self:init(props, basalt) return self end diff --git a/src/elements/Checkbox.lua b/src/elements/Checkbox.lua index 533b5c2..1df8027 100644 --- a/src/elements/Checkbox.lua +++ b/src/elements/Checkbox.lua @@ -15,9 +15,9 @@ Checkbox.listenTo(Checkbox, "mouse_click") function Checkbox.new(props, basalt) local self = setmetatable({}, Checkbox):__init() - self:init(props, basalt) self.set("width", 1) self.set("height", 1) + self:init(props, basalt) return self end diff --git a/src/elements/Container.lua b/src/elements/Container.lua index a356714..ca59d26 100644 --- a/src/elements/Container.lua +++ b/src/elements/Container.lua @@ -44,9 +44,9 @@ function Container:isChildVisible(child) 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 + return childX <= containerW and + childY <= containerH and + childX + childW > 0 and childY + childH > 0 end @@ -94,7 +94,7 @@ local function sortAndFilterChildren(self, children) local visibleChildren = {} for _, child in ipairs(children) do - if self:isChildVisible(child) then + if self:isChildVisible(child) and child.get("visible") then table.insert(visibleChildren, child) end end @@ -103,7 +103,6 @@ local function sortAndFilterChildren(self, children) 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 @@ -119,6 +118,15 @@ local function sortAndFilterChildren(self, children) return visibleChildren end +function Container:clear() + self.set("children", {}) + self.set("childrenEvents", {}) + self.set("visibleChildren", {}) + self.set("visibleChildrenEvents", {}) + self.set("childrenSorted", true) + self.set("childrenEventsSorted", true) +end + function Container:sortChildren() self.set("visibleChildren", sortAndFilterChildren(self, self._values.children)) self.set("childrenSorted", true) @@ -234,7 +242,7 @@ local function callChildrenEvents(self, visibleOnly, event, ...) for i = #events, 1, -1 do local child = events[i] if(child:dispatchEvent(event, ...))then - return true, child + return true, child end end end @@ -242,12 +250,12 @@ local function callChildrenEvents(self, visibleOnly, event, ...) end function Container:handleEvent(event, ...) - if(VisualElement.handleEvent(self, event, ...))then - local args = convertMousePosition(self, event, ...) - return callChildrenEvents(self, false, event, table.unpack(args)) - end + VisualElement.handleEvent(self, event, ...) + local args = convertMousePosition(self, event, ...) + return callChildrenEvents(self, false, event, table.unpack(args)) end + function Container:mouse_click(button, x, y) if VisualElement.mouse_click(self, button, x, y) then local args = convertMousePosition(self, "mouse_click", button, x, y) @@ -260,6 +268,16 @@ function Container:mouse_click(button, x, y) end end +function Container:mouse_up(button, x, y) + if VisualElement.mouse_up(self, button, x, y) then + local args = convertMousePosition(self, "mouse_up", button, x, y) + local success, child = callChildrenEvents(self, true, "mouse_up", table.unpack(args)) + if(success)then + return true + end + end +end + function Container:key(key) if self.get("focusedChild") then return self.get("focusedChild"):dispatchEvent("key", key) diff --git a/src/elements/Dropdown.lua b/src/elements/Dropdown.lua new file mode 100644 index 0000000..8ed2ae6 --- /dev/null +++ b/src/elements/Dropdown.lua @@ -0,0 +1,122 @@ +local VisualElement = require("elements/VisualElement") +local List = require("elements/List") +local tHex = require("libraries/colorHex") + +---@class Dropdown : List +local Dropdown = setmetatable({}, List) +Dropdown.__index = Dropdown + +Dropdown.defineProperty(Dropdown, "isOpen", {default = false, type = "boolean", canTriggerRender = true}) +Dropdown.defineProperty(Dropdown, "dropdownHeight", {default = 5, type = "number"}) +Dropdown.defineProperty(Dropdown, "selectedText", {default = "", type = "string"}) +Dropdown.defineProperty(Dropdown, "dropSymbol", {default = "\31", type = "string"}) -- ▼ Symbol + +function Dropdown.new(props, basalt) + local self = setmetatable({}, Dropdown):__init() + self.set("width", 16) + self.set("height", 1) -- Dropdown ist initial nur 1 Zeile hoch + self.set("z", 8) + self:init(props, basalt) + return self +end + +function Dropdown:init(props, basalt) + List.init(self, props, basalt) + self.set("type", "Dropdown") +end + +function Dropdown:mouse_click(button, x, y) + if not VisualElement.mouse_click(self, button, x, y) then return false end + + local relX, relY = self:getRelativePosition(x, y) + + if relY == 1 then -- Klick auf Header + self.set("isOpen", not self.get("isOpen")) + if not self.get("isOpen") then + self.set("height", 1) + else + self.set("height", 1 + math.min(self.get("dropdownHeight"), #self.get("items"))) + end + return true + elseif self.get("isOpen") and relY > 1 then + -- Offset für die Liste korrigieren (relY - 1 wegen Header) + local index = relY - 1 + self.get("offset") + local items = self.get("items") + + if index <= #items then + local item = items[index] + if type(item) == "table" and item.separator then + return false + end + + self.set("selectedIndex", index) + self.set("isOpen", false) + self.set("height", 1) + + if type(item) == "table" and item.callback then + item.callback(self) + end + + self:fireEvent("select", index, item) + return true + end + end + return false +end + +function Dropdown:render() + VisualElement.render(self) + + -- Header rendern + local text = self.get("selectedText") + if #text == 0 and self.get("selectedIndex") > 0 then + local item = self.get("items")[self.get("selectedIndex")] + text = type(item) == "table" and item.text or tostring(item) + end + + self:blit(1, 1, text .. string.rep(" ", self.get("width") - #text - 1) .. (self.get("isOpen") and "\31" or "\17"), + string.rep(tHex[self.get("foreground")], self.get("width")), + string.rep(tHex[self.get("background")], self.get("width"))) + + -- Items nur rendern wenn offen + if self.get("isOpen") then + local items = self.get("items") + local offset = self.get("offset") + local selected = self.get("selectedIndex") + local width = self.get("width") + + -- Liste ab Zeile 2 rendern (unterhalb des Headers) + for i = 2, self.get("height") do + local itemIndex = i - 1 + offset -- -1 wegen Header + local item = items[itemIndex] + + if item then + if type(item) == "table" and item.separator then + local separatorChar = (item.text or "-"):sub(1,1) + local separatorText = string.rep(separatorChar, width) + local fg = item.foreground or self.get("foreground") + local bg = item.background or self.get("background") + + self:textBg(1, i, string.rep(" ", width), bg) + self:textFg(1, i, separatorText, fg) + else + local itemText = type(item) == "table" and item.text or tostring(item) + local isSelected = itemIndex == selected + + local bg = isSelected and + (item.selectedBackground or self.get("selectedColor")) or + (item.background or self.get("background")) + + local fg = isSelected and + (item.selectedForeground or colors.white) or + (item.foreground or self.get("foreground")) + + self:textBg(1, i, string.rep(" ", width), bg) + self:textFg(1, i, itemText, fg) + end + end + end + end +end + +return Dropdown diff --git a/src/elements/Flexbox.lua b/src/elements/Flexbox.lua index 15bc0ee..f9ecd21 100644 --- a/src/elements/Flexbox.lua +++ b/src/elements/Flexbox.lua @@ -232,13 +232,13 @@ end --- @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) + self:init(props, basalt) return self end diff --git a/src/elements/Frame.lua b/src/elements/Frame.lua index 9a955c9..128e29c 100644 --- a/src/elements/Frame.lua +++ b/src/elements/Frame.lua @@ -12,11 +12,11 @@ Frame.__index = Frame --- @usage local element = Frame.new("myId", basalt) function Frame.new(props, basalt) local self = setmetatable({}, Frame):__init() - self:init(props, basalt) self.set("width", 12) self.set("height", 6) - self.set("background", colors.blue) + self.set("background", colors.gray) self.set("z", 10) + self:init(props, basalt) return self end diff --git a/src/elements/Input.lua b/src/elements/Input.lua index 5bb1e80..ac9df0c 100644 --- a/src/elements/Input.lua +++ b/src/elements/Input.lua @@ -32,9 +32,9 @@ Input.listenTo(Input, "char") --- @usage local element = Input.new("myId", basalt) function Input.new(props, basalt) local self = setmetatable({}, Input):__init() - self:init(id, basalt) self.set("width", 8) self.set("z", 3) + self:init(id, basalt) return self end diff --git a/src/elements/Label.lua b/src/elements/Label.lua index b5c7f71..778d887 100644 --- a/src/elements/Label.lua +++ b/src/elements/Label.lua @@ -7,6 +7,7 @@ Label.__index = Label ---@property text string Label Label text to be displayed Label.defineProperty(Label, "text", {default = "Label", type = "string", setter = function(self, value) + if(type(value)=="function")then value = value() end self.set("width", #value) return value end}) @@ -18,9 +19,10 @@ end}) --- @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("foreground", colors.black) self.set("backgroundEnabled", false) + self:init(props, basalt) return self end diff --git a/src/elements/List.lua b/src/elements/List.lua index b616598..5089806 100644 --- a/src/elements/List.lua +++ b/src/elements/List.lua @@ -20,9 +20,10 @@ List.listenTo(List, "mouse_scroll") function List.new(props, basalt) local self = setmetatable({}, List):__init() - self:init(props, basalt) self.set("width", 16) self.set("height", 8) + self.set("background", colors.gray) + self:init(props, basalt) return self end @@ -54,16 +55,25 @@ end function List:mouse_click(button, x, y) if button == 1 and self:isInBounds(x, y) and self.get("selectable") then - local relY = self:getRelativePosition(x, y) - local index = relY + self.get("offset") + local _, index = self:getRelativePosition(x, y) - if index <= #self.get("items") then - self.set("selectedIndex", index) - self:fireEvent("select", index, self.get("items")[index]) + local adjustedIndex = index + self.get("offset") + local items = self.get("items") + + if adjustedIndex <= #items then + local item = items[adjustedIndex] + self.set("selectedIndex", adjustedIndex) + + if type(item) == "table" and item.callback then + item.callback(self) + end + + self:fireEvent("select", adjustedIndex, item) self:updateRender() return true end end + return false end function List:mouse_scroll(direction, x, y) @@ -77,24 +87,47 @@ function List:mouse_scroll(direction, x, y) end end +function List:onSelect(callback) + self:registerCallback("select", callback) + return self +end + function List:render() VisualElement.render(self) - + local items = self.get("items") local height = self.get("height") local offset = self.get("offset") local selected = self.get("selectedIndex") - + local width = self.get("width") + for i = 1, height do local itemIndex = i + offset local item = items[itemIndex] - + if item then - if itemIndex == selected then - self:textBg(1, i, string.rep(" ", self.get("width")), self.get("selectedColor")) - self:textFg(1, i, item, colors.white) + if type(item) == "table" and item.separator then + local separatorChar = (item.text or "-"):sub(1,1) + local separatorText = string.rep(separatorChar, width) + local fg = item.foreground or self.get("foreground") + local bg = item.background or self.get("background") + + self:textBg(1, i, string.rep(" ", width), bg) + self:textFg(1, i, separatorText, fg) else - self:textFg(1, i, item, self.get("foreground")) + local text = type(item) == "table" and item.text or item + local isSelected = itemIndex == selected + + local bg = isSelected and + (item.selectedBackground or self.get("selectedColor")) or + (item.background or self.get("background")) + + local fg = isSelected and + (item.selectedForeground or colors.white) or + (item.foreground or self.get("foreground")) + + self:textBg(1, i, string.rep(" ", width), bg) + self:textFg(1, i, text, fg) end end end diff --git a/src/elements/Menu.lua b/src/elements/Menu.lua new file mode 100644 index 0000000..555f6ba --- /dev/null +++ b/src/elements/Menu.lua @@ -0,0 +1,91 @@ +local VisualElement = require("elements/VisualElement") +local List = require("elements/List") +local tHex = require("libraries/colorHex") + +---@class Menu : List +local Menu = setmetatable({}, List) +Menu.__index = Menu + +Menu.defineProperty(Menu, "separatorColor", {default = colors.gray, type = "number"}) + +function Menu.new(props, basalt) + local self = setmetatable({}, Menu):__init() + self.set("width", 30) + self.set("height", 1) + self.set("background", colors.gray) + self:init(props, basalt) + return self +end + +function Menu:init(props, basalt) + List.init(self, props, basalt) + self.set("type", "Menu") + return self +end + +function Menu:setItems(items) + local listItems = {} + local totalWidth = 0 + for _, item in ipairs(items) do + if item.separator then + table.insert(listItems, {text = item.text or "|", selectable = false}) + totalWidth = totalWidth + 1 + else + local text = " " .. item.text .. " " + item.text = text + table.insert(listItems, item) + totalWidth = totalWidth + #text + end + end + self.set("width", totalWidth) + return List.setItems(self, listItems) +end + +function Menu:render() + VisualElement.render(self) + local currentX = 1 + + for i, item in ipairs(self.get("items")) do + local isSelected = i == self.get("selectedIndex") + + local fg = item.selectable == false and self.get("separatorColor") or + (isSelected and (item.selectedForeground or self.get("foreground")) or + (item.foreground or self.get("foreground"))) + + local bg = isSelected and + (item.selectedBackground or self.get("selectedColor")) or + (item.background or self.get("background")) + + self:blit(currentX, 1, item.text, + string.rep(tHex[fg], #item.text), + string.rep(tHex[bg], #item.text)) + + currentX = currentX + #item.text + end +end + +function Menu:mouse_click(button, x, y) + if not VisualElement.mouse_click(self, button, x, y) then return false end + if(self.get("selectable") == false) then return false end + local relX = select(1, self:getRelativePosition(x, y)) + local currentX = 1 + + for i, item in ipairs(self.get("items")) do + if relX >= currentX and relX < currentX + #item.text then + if item.selectable ~= false then + self.set("selectedIndex", i) + if type(item) == "table" then + if item.callback then + item.callback(self) + end + end + self:fireEvent("select", i, item) + end + return true + end + currentX = currentX + #item.text + end + return false +end + +return Menu diff --git a/src/elements/ProgressBar.lua b/src/elements/ProgressBar.lua index 5ed803d..f305b85 100644 --- a/src/elements/ProgressBar.lua +++ b/src/elements/ProgressBar.lua @@ -13,9 +13,9 @@ ProgressBar.defineProperty(ProgressBar, "progressColor", {default = colors.lime, function ProgressBar.new(props, basalt) local self = setmetatable({}, ProgressBar):__init() - self:init(props, basalt) self.set("width", 10) self.set("height", 1) + self:init(props, basalt) return self end diff --git a/src/elements/Slider.lua b/src/elements/Slider.lua index a21ffa1..0320c8f 100644 --- a/src/elements/Slider.lua +++ b/src/elements/Slider.lua @@ -21,10 +21,10 @@ Slider.listenTo(Slider, "mouse_up") function Slider.new(props, basalt) local self = setmetatable({}, Slider):__init() - self:init(props, basalt) self.set("width", 8) self.set("height", 1) self.set("backgroundEnabled", false) + self:init(props, basalt) return self end diff --git a/src/elements/Table.lua b/src/elements/Table.lua new file mode 100644 index 0000000..8a9a893 --- /dev/null +++ b/src/elements/Table.lua @@ -0,0 +1,182 @@ +local VisualElement = require("elements/VisualElement") +local tHex = require("libraries/colorHex") + +---@class Table : VisualElement +local Table = setmetatable({}, VisualElement) +Table.__index = Table + +Table.defineProperty(Table, "columns", {default = {}, type = "table"}) +Table.defineProperty(Table, "data", {default = {}, type = "table", canTriggerRender = true}) +Table.defineProperty(Table, "selectedRow", {default = nil, type = "number", canTriggerRender = true}) +Table.defineProperty(Table, "headerColor", {default = colors.blue, type = "number"}) +Table.defineProperty(Table, "selectedColor", {default = colors.lightBlue, type = "number"}) +Table.defineProperty(Table, "gridColor", {default = colors.gray, type = "number"}) +Table.defineProperty(Table, "sortColumn", {default = nil, type = "number"}) +Table.defineProperty(Table, "sortDirection", {default = "asc", type = "string"}) +Table.defineProperty(Table, "scrollOffset", {default = 0, type = "number", canTriggerRender = true}) + +Table.listenTo(Table, "mouse_click") +Table.listenTo(Table, "mouse_scroll") + +function Table.new(props, basalt) + local self = setmetatable({}, Table):__init() + self.set("width", 30) + self.set("height", 10) + self.set("z", 5) + self:init(props, basalt) + return self +end + +function Table:init(props, basalt) + VisualElement.init(self, props, basalt) + self.set("type", "Table") + return self +end + +function Table:setColumns(columns) + -- Columns Format: {{name="ID", width=4}, {name="Name", width=10}} + self.set("columns", columns) + return self +end + +function Table:setData(data) + -- Data Format: {{"1", "Item One"}, {"2", "Item Two"}} + self.set("data", data) + return self +end + +function Table:sortData(columnIndex) + local data = self.get("data") + local direction = self.get("sortDirection") + + table.sort(data, function(a, b) + if direction == "asc" then + return a[columnIndex] < b[columnIndex] + else + return a[columnIndex] > b[columnIndex] + end + end) + + self.set("data", data) + return self +end + +function Table:mouse_click(button, x, y) + if not VisualElement.mouse_click(self, button, x, y) then return false end + + local relX, relY = self:getRelativePosition(x, y) + + -- Header-Click für Sorting + if relY == 1 then + local currentX = 1 + for i, col in ipairs(self.get("columns")) do + if relX >= currentX and relX < currentX + col.width then + if self.get("sortColumn") == i then + self.set("sortDirection", self.get("sortDirection") == "asc" and "desc" or "asc") + else + self.set("sortColumn", i) + self.set("sortDirection", "asc") + end + self:sortData(i) + break + end + currentX = currentX + col.width + end + end + + -- Row-Selection (berücksichtigt Scroll-Offset) + if relY > 1 then + local rowIndex = relY - 2 + self.get("scrollOffset") + if rowIndex >= 0 and rowIndex < #self.get("data") then + self.set("selectedRow", rowIndex + 1) + end + end + + return true +end + +function Table:mouse_scroll(direction, x, y) + local data = self.get("data") + local height = self.get("height") + local visibleRows = height - 2 + local maxScroll = math.max(0, #data - visibleRows + 1) -- +1 korrigiert den Scroll-Bereich + local newOffset = math.min(maxScroll, math.max(0, self.get("scrollOffset") + direction)) + + self.set("scrollOffset", newOffset) + return true +end + +function Table:render() + VisualElement.render(self) + + local columns = self.get("columns") + local data = self.get("data") + local selected = self.get("selectedRow") + local sortCol = self.get("sortColumn") + local scrollOffset = self.get("scrollOffset") + local height = self.get("height") + + local currentX = 1 + for i, col in ipairs(columns) do + local text = col.name + if i == sortCol then + text = text .. (self.get("sortDirection") == "asc" and "\30" or "\31") + end + self:textFg(currentX, 1, text:sub(1, col.width), self.get("headerColor")) + currentX = currentX + col.width + end + + -- Angepasste Berechnung der sichtbaren Zeilen + local visibleRows = height - 2 -- Verfügbare Zeilen (minus Header) + for y = 2, height do + local rowIndex = y - 2 + scrollOffset + local rowData = data[rowIndex + 1] + + -- Zeile nur rendern wenn es auch Daten dafür gibt + if rowData and (rowIndex + 1) <= #data then -- Korrigierte Bedingung + currentX = 1 + local bg = (rowIndex + 1) == selected and self.get("selectedColor") or self.get("background") + + for i, col in ipairs(columns) do + local cellText = rowData[i] or "" + local paddedText = cellText .. string.rep(" ", col.width - #cellText) + self:blit(currentX, y, paddedText, + string.rep(tHex[self.get("foreground")], col.width), + string.rep(tHex[bg], col.width)) + currentX = currentX + col.width + end + else + -- Leere Zeile füllen + self:blit(1, y, string.rep(" ", self.get("width")), + string.rep(tHex[self.get("foreground")], self.get("width")), + string.rep(tHex[self.get("background")], self.get("width"))) + end + end + + -- Scrollbar Berechnung überarbeitet + if #data > height - 2 then + local scrollbarHeight = height - 2 + local thumbSize = math.max(1, math.floor(scrollbarHeight * (height - 2) / #data)) + + -- Thumb Position korrigiert + local maxScroll = #data - (height - 2) + 1 -- +1 für korrekte End-Position + local scrollPercent = scrollOffset / maxScroll + local thumbPos = 2 + math.floor(scrollPercent * (scrollbarHeight - thumbSize)) + + if scrollOffset >= maxScroll then + thumbPos = height - thumbSize -- Exakt am Ende + end + + -- Scrollbar Background + for y = 2, height do + self:blit(self.get("width"), y, "\127", tHex[colors.gray], tHex[colors.gray]) + end + + -- Thumb zeichnen + for y = thumbPos, math.min(height, thumbPos + thumbSize - 1) do + self:blit(self.get("width"), y, "\127", tHex[colors.white], tHex[colors.white]) + end + end +end + +return Table \ No newline at end of file diff --git a/src/elements/Tree.lua b/src/elements/Tree.lua new file mode 100644 index 0000000..e94db5f --- /dev/null +++ b/src/elements/Tree.lua @@ -0,0 +1,147 @@ +local VisualElement = require("elements/VisualElement") +local tHex = require("libraries/colorHex") + +---@class Tree : VisualElement +local Tree = setmetatable({}, VisualElement) +Tree.__index = Tree + +Tree.defineProperty(Tree, "nodes", {default = {}, type = "table", canTriggerRender = true}) +Tree.defineProperty(Tree, "selectedNode", {default = nil, type = "table", canTriggerRender = true}) +Tree.defineProperty(Tree, "expandedNodes", {default = {}, type = "table", canTriggerRender = true}) +Tree.defineProperty(Tree, "scrollOffset", {default = 0, type = "number", canTriggerRender = true}) +Tree.defineProperty(Tree, "nodeColor", {default = colors.white, type = "number"}) +Tree.defineProperty(Tree, "selectedColor", {default = colors.lightBlue, type = "number"}) + +Tree.listenTo(Tree, "mouse_click") +Tree.listenTo(Tree, "mouse_scroll") + +function Tree.new(props, basalt) + local self = setmetatable({}, Tree):__init() + self.set("width", 30) + self.set("height", 10) + self.set("z", 5) + self:init(props, basalt) + return self +end + +function Tree:init(props, basalt) + VisualElement.init(self, props, basalt) + self.set("type", "Tree") + return self +end + +function Tree:setNodes(nodes) + self.set("nodes", nodes) + if #nodes > 0 then + self.get("expandedNodes")[nodes[1]] = true + end + return self +end + +function Tree:expandNode(node) + self.get("expandedNodes")[node] = true + self:updateRender() + return self +end + +function Tree:collapseNode(node) + self.get("expandedNodes")[node] = nil + self:updateRender() + return self +end + +function Tree:toggleNode(node) + if self.get("expandedNodes")[node] then + self:collapseNode(node) + else + self:expandNode(node) + end + return self +end + +local function flattenTree(nodes, expandedNodes, level, result) + result = result or {} + level = level or 0 + + for _, node in ipairs(nodes) do + table.insert(result, {node = node, level = level}) + if expandedNodes[node] and node.children then + flattenTree(node.children, expandedNodes, level + 1, result) + end + end + return result +end + +function Tree:mouse_click(button, x, y) + if not VisualElement.mouse_click(self, button, x, y) then return false end + + local relX, relY = self:getRelativePosition(x, y) + local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes")) + local visibleIndex = relY + self.get("scrollOffset") + + if flatNodes[visibleIndex] then + local nodeInfo = flatNodes[visibleIndex] + local node = nodeInfo.node + + if relX <= nodeInfo.level * 2 + 2 then + self:toggleNode(node) + end + + self.set("selectedNode", node) + self:fireEvent("node_select", node) + end + return true +end + +function Tree:onSelect(callback) + self:registerCallback("node_select", callback) + return self +end + +function Tree:mouse_scroll(direction) + local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes")) + local maxScroll = math.max(0, #flatNodes - self.get("height")) + local newScroll = math.min(maxScroll, math.max(0, self.get("scrollOffset") + direction)) + + self.set("scrollOffset", newScroll) + return true +end + +function Tree:render() + VisualElement.render(self) + + local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes")) + local height = self.get("height") + local selectedNode = self.get("selectedNode") + local expandedNodes = self.get("expandedNodes") + local scrollOffset = self.get("scrollOffset") + + for y = 1, height do + local nodeInfo = flatNodes[y + scrollOffset] + if nodeInfo then + local node = nodeInfo.node + local level = nodeInfo.level + local indent = string.rep(" ", level) + + -- Expand/Collapse Symbol + local symbol = " " + if node.children and #node.children > 0 then + symbol = expandedNodes[node] and "\31" or "\16" + end + + local bg = node == selectedNode and self.get("selectedColor") or self.get("background") + local text = indent .. symbol .." " .. (node.text or "Node") + + self:blit(1, y, text .. string.rep(" ", self.get("width") - #text), + string.rep(tHex[self.get("nodeColor")], self.get("width")), + string.rep(tHex[bg], self.get("width"))) + else + -- Leere Zeile + self:blit(1, y, string.rep(" ", self.get("width")), + string.rep(tHex[self.get("foreground")], self.get("width")), + string.rep(tHex[self.get("background")], self.get("width"))) + end + end +end + +return Tree diff --git a/src/elements/VisualElement.lua b/src/elements/VisualElement.lua index b7b457c..108a18e 100644 --- a/src/elements/VisualElement.lua +++ b/src/elements/VisualElement.lua @@ -53,6 +53,18 @@ VisualElement.defineProperty(VisualElement, "focused", {default = false, type = return value end}) +VisualElement.defineProperty(VisualElement, "visible", {default = true, type = "boolean", canTriggerRender = true, setter=function(self, value) + if(self.parent~=nil)then + self.parent.set("childrenSorted", false) + self.parent.set("childrenEventsSorted", false) + end + return value +end}) + +VisualElement.combineProperties(VisualElement, "position", "x", "y") +VisualElement.combineProperties(VisualElement, "size", "width", "height") +VisualElement.combineProperties(VisualElement, "color", "foreground", "background") + VisualElement.listenTo(VisualElement, "focus") VisualElement.listenTo(VisualElement, "blur") @@ -66,7 +78,6 @@ local max, min = math.max, math.min function VisualElement.new(props, basalt) local self = setmetatable({}, VisualElement):__init() self:init(props, basalt) - self.set("type", "VisualElement") return self end @@ -139,8 +150,8 @@ function VisualElement:mouse_click(button, x, y) end function VisualElement:mouse_up(button, x, y) + self.set("clicked", false) if self:isInBounds(x, y) then - self.set("clicked", false) self:fireEvent("mouse_up", button, x, y) return true end diff --git a/src/main.lua b/src/main.lua index 70687a8..5c70267 100644 --- a/src/main.lua +++ b/src/main.lua @@ -1,5 +1,3 @@ -local benchmark = require("benchmark") -benchmark.start("Basalt Initialization") local elementManager = require("elementManager") local errorManager = require("errorManager") local propertySystem = require("propertySystem") @@ -178,7 +176,6 @@ 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() @@ -197,4 +194,8 @@ function basalt.run(isActive) end end +function basalt.getAPI(name) + return elementManager.getAPI(name) +end + return basalt \ No newline at end of file diff --git a/src/plugins/benchmark.lua b/src/plugins/benchmark.lua new file mode 100644 index 0000000..ea6b219 --- /dev/null +++ b/src/plugins/benchmark.lua @@ -0,0 +1,325 @@ +local log = require("log") + +local activeProfiles = setmetatable({}, {__mode = "k"}) + +local function createProfile() + return { + methods = {}, + } +end + +local function wrapMethod(element, methodName) + local originalMethod = element[methodName] + + if not activeProfiles[element] then + activeProfiles[element] = createProfile() + end + if not activeProfiles[element].methods[methodName] then + activeProfiles[element].methods[methodName] = { + calls = 0, + totalTime = 0, + minTime = math.huge, + maxTime = 0, + lastTime = 0, + startTime = 0, + path = {}, + methodName = methodName, + originalMethod = originalMethod + } + end + + element[methodName] = function(self, ...) + self:startProfile(methodName) + local result = originalMethod(self, ...) + self:endProfile(methodName) + return result + end +end + +local BaseElement = {} + +function BaseElement:startProfile(methodName) + local profile = activeProfiles[self] + if not profile then + profile = createProfile() + activeProfiles[self] = profile + end + + if not profile.methods[methodName] then + profile.methods[methodName] = { + calls = 0, + totalTime = 0, + minTime = math.huge, + maxTime = 0, + lastTime = 0, + startTime = 0, + path = {}, + methodName = methodName + } + end + + local methodProfile = profile.methods[methodName] + methodProfile.startTime = os.clock() * 1000 + methodProfile.path = {} + + local current = self + while current do + table.insert(methodProfile.path, 1, current.get("name") or current.get("id")) + current = current.parent + end + return self +end + +function BaseElement:endProfile(methodName) + local profile = activeProfiles[self] + if not profile or not profile.methods[methodName] then return self end + + local methodProfile = profile.methods[methodName] + local endTime = os.clock() * 1000 + local duration = endTime - methodProfile.startTime + + methodProfile.calls = methodProfile.calls + 1 + methodProfile.totalTime = methodProfile.totalTime + duration + methodProfile.minTime = math.min(methodProfile.minTime, duration) + methodProfile.maxTime = math.max(methodProfile.maxTime, duration) + methodProfile.lastTime = duration + + return self +end + +function BaseElement:benchmark(methodName) + if not self[methodName] then + log.error("Method " .. methodName .. " does not exist") + return self + end + + activeProfiles[self] = createProfile() + activeProfiles[self].methodName = methodName + activeProfiles[self].isRunning = true + + wrapMethod(self, methodName) + return self +end + +function BaseElement:logBenchmark(methodName) + local profile = activeProfiles[self] + if not profile or not profile.methods[methodName] then return self end + + local stats = profile.methods[methodName] + if stats then + local averageTime = stats.calls > 0 and (stats.totalTime / stats.calls) or 0 + log.info(string.format( + "Benchmark results for %s.%s: " .. + "Path: %s " .. + "Calls: %d " .. + "Average time: %.2fms " .. + "Min time: %.2fms " .. + "Max time: %.2fms " .. + "Last time: %.2fms " .. + "Total time: %.2fms", + table.concat(stats.path, "."), + stats.methodName, + table.concat(stats.path, "/"), + stats.calls, + averageTime, + stats.minTime ~= math.huge and stats.minTime or 0, + stats.maxTime, + stats.lastTime, + stats.totalTime + )) + end + return self +end + +function BaseElement:stopBenchmark(methodName) + local profile = activeProfiles[self] + if not profile or not profile.methods[methodName] then return self end + + local stats = profile.methods[methodName] + if stats and stats.originalMethod then + self[methodName] = stats.originalMethod + end + + profile.methods[methodName] = nil + if not next(profile.methods) then + activeProfiles[self] = nil + end + return self +end + +function BaseElement:getBenchmarkStats(methodName) + local profile = activeProfiles[self] + if not profile or not profile.methods[methodName] then return nil end + + local stats = profile.methods[methodName] + return { + averageTime = stats.totalTime / stats.calls, + totalTime = stats.totalTime, + calls = stats.calls, + minTime = stats.minTime, + maxTime = stats.maxTime, + lastTime = stats.lastTime + } +end + +local Container = {} + +function Container:benchmarkContainer(methodName) + self:benchmark(methodName) + + for _, child in pairs(self.get("children")) do + child:benchmark(methodName) + + if child:isType("Container") then + child:benchmarkContainer(methodName) + end + end + return self +end + +function Container:logContainerBenchmarks(methodName, depth) + depth = depth or 0 + local indent = string.rep(" ", depth) + local childrenTotalTime = 0 + local childrenStats = {} + + for _, child in pairs(self.get("children")) do + local profile = activeProfiles[child] + if profile and profile.methods[methodName] then + local stats = profile.methods[methodName] + childrenTotalTime = childrenTotalTime + stats.totalTime + table.insert(childrenStats, { + element = child, + type = child.get("type"), + calls = stats.calls, + totalTime = stats.totalTime, + avgTime = stats.totalTime / stats.calls + }) + end + end + + local profile = activeProfiles[self] + if profile and profile.methods[methodName] then + local stats = profile.methods[methodName] + local selfTime = stats.totalTime - childrenTotalTime + local avgSelfTime = selfTime / stats.calls + + log.info(string.format( + "%sBenchmark %s (%s): " .. + "%.2fms/call (Self: %.2fms/call) " .. + "[Total: %dms, Calls: %d]", + indent, + self.get("type"), + methodName, + stats.totalTime / stats.calls, + avgSelfTime, + stats.totalTime, + stats.calls + )) + + if #childrenStats > 0 then + for _, childStat in ipairs(childrenStats) do + if childStat.element:isType("Container") then + childStat.element:logContainerBenchmarks(methodName, depth + 1) + else + log.info(string.format("%s> %s: %.2fms/call [Total: %dms, Calls: %d]", + indent .. " ", + childStat.type, + childStat.avgTime, + childStat.totalTime, + childStat.calls + )) + end + end + end + end + + return self +end + +function Container:stopContainerBenchmark(methodName) + for _, child in pairs(self.get("children")) do + if child:isType("Container") then + child:stopContainerBenchmark(methodName) + else + child:stopBenchmark(methodName) + end + end + + self:stopBenchmark(methodName) + return self +end + +local API = { + start = function(name, options) + options = options or {} + local profile = createProfile() + profile.name = name + profile.startTime = os.clock() * 1000 + profile.custom = true + activeProfiles[name] = profile + end, + + stop = function(name) + local profile = activeProfiles[name] + if not profile or not profile.custom then return end + + local endTime = os.clock() * 1000 + local duration = endTime - profile.startTime + + profile.calls = profile.calls + 1 + profile.totalTime = profile.totalTime + duration + profile.minTime = math.min(profile.minTime, duration) + profile.maxTime = math.max(profile.maxTime, duration) + profile.lastTime = duration + + log.info(string.format( + "Custom Benchmark '%s': " .. + "Calls: %d " .. + "Average time: %.2fms " .. + "Min time: %.2fms " .. + "Max time: %.2fms " .. + "Last time: %.2fms " .. + "Total time: %.2fms", + name, + profile.calls, + profile.totalTime / profile.calls, + profile.minTime, + profile.maxTime, + profile.lastTime, + profile.totalTime + )) + end, + + getStats = function(name) + local profile = activeProfiles[name] + if not profile then return nil end + + return { + averageTime = profile.totalTime / profile.calls, + totalTime = profile.totalTime, + calls = profile.calls, + minTime = profile.minTime, + maxTime = profile.maxTime, + lastTime = profile.lastTime + } + end, + + clear = function(name) + activeProfiles[name] = nil + end, + + clearAll = function() + for k,v in pairs(activeProfiles) do + if v.custom then + activeProfiles[k] = nil + end + end + end +} + +return { + BaseElement = BaseElement, + Container = Container, + API = API +} \ No newline at end of file diff --git a/src/plugins/debug.lua b/src/plugins/debug.lua new file mode 100644 index 0000000..c29212b --- /dev/null +++ b/src/plugins/debug.lua @@ -0,0 +1,182 @@ +local log = require("log") +local tHex = require("libraries/colorHex") + +local maxLines = 10 +local isVisible = false + +local function createDebugger(element) + local elementInfo = { + renderCount = 0, + eventCount = {}, + lastRender = os.epoch("utc"), + properties = {}, + children = {} + } + + return { + trackProperty = function(name, value) + elementInfo.properties[name] = value + end, + + trackRender = function() + elementInfo.renderCount = elementInfo.renderCount + 1 + elementInfo.lastRender = os.epoch("utc") + end, + + trackEvent = function(event) + elementInfo.eventCount[event] = (elementInfo.eventCount[event] or 0) + 1 + end, + + dump = function() + return { + type = element.get("type"), + id = element.get("id"), + stats = elementInfo + } + end + } +end + +local BaseElement = { + debug = function(self, level) + self._debugger = createDebugger(self) + self._debugLevel = level or DEBUG_LEVELS.INFO + return self + end, + + dumpDebug = function(self) + if not self._debugger then return end + return self._debugger.dump() + end +} + +local BaseFrame = { + showDebugLog = function(self) + if not self._debugFrame then + local width = self.get("width") + local height = self.get("height") + self._debugFrame = self:addFrame() + :setWidth(width) + :setHeight(height) + :setBackground(colors.black) + :setZ(999) + :listenEvent("mouse_scroll", true) + + self._debugFrame:addButton() + :setWidth(9) + :setHeight(1) + :setX(width - 8) + :setY(height) + :setText("Close") + :setBackground(colors.red) + :onMouseClick(function() + self:hideDebugLog() + end) + + self._debugFrame._scrollOffset = 0 + self._debugFrame._processedLogs = {} + + local function wrapText(text, width) + local lines = {} + while #text > 0 do + local line = text:sub(1, width) + table.insert(lines, line) + text = text:sub(width + 1) + end + return lines + end + + local function processLogs() + local processed = {} + local width = self._debugFrame.get("width") + + for _, entry in ipairs(log._logs) do + local lines = wrapText(entry.message, width) + for _, line in ipairs(lines) do + table.insert(processed, { + text = line, + level = entry.level + }) + end + end + return processed + end + + local totalLines = #processLogs() - self.get("height") + self._scrollOffset = totalLines + + local originalRender = self._debugFrame.render + self._debugFrame.render = function(frame) + originalRender(frame) + frame._processedLogs = processLogs() + + local height = frame.get("height")-2 + local totalLines = #frame._processedLogs + local maxScroll = math.max(0, totalLines - height) + frame._scrollOffset = math.min(frame._scrollOffset, maxScroll) + + for i = 1, height-2 do + local logIndex = i + frame._scrollOffset + local entry = frame._processedLogs[logIndex] + + if entry then + local color = entry.level == log.LEVEL.ERROR and colors.red + or entry.level == log.LEVEL.WARN and colors.yellow + or entry.level == log.LEVEL.DEBUG and colors.lightGray + or colors.white + + frame:textFg(2, i, entry.text, color) + end + end + end + + local baseDispatchEvent = self._debugFrame.dispatchEvent + self._debugFrame.dispatchEvent = function(self, event, direction, ...) + if(event == "mouse_scroll") then + self._scrollOffset = math.max(0, self._scrollOffset + direction) + self:updateRender() + return true + else + baseDispatchEvent(self, event, direction, ...) + end + end + end + self._debugFrame.set("visible", true) + return self + end, + + hideDebugLog = function(self) + if self._debugFrame then + self._debugFrame.set("visible", false) + end + return self + end, + + toggleDebugLog = function(self) + if self._debugFrame and self._debugFrame:isVisible() then + self:hideDebugLog() + else + self:showDebugLog() + end + return self + end +} + + +local Container = { + debugChildren = function(self, level) + self:debug(level) + for _, child in pairs(self.get("children")) do + if child.debug then + child:debug(level) + end + end + return self + end +} + +return { + BaseElement = BaseElement, + Container = Container, + BaseFrame = BaseFrame, +} diff --git a/src/plugins/reactive.lua b/src/plugins/reactive.lua index f51dde5..c97f768 100644 --- a/src/plugins/reactive.lua +++ b/src/plugins/reactive.lua @@ -18,14 +18,18 @@ local mathEnv = { end } -local function parseExpression(expr, element) +local function parseExpression(expr, element, propName) expr = expr:gsub("^{(.+)}$", "%1") - for k,v in pairs(colors) do - if type(k) == "string" then - expr = expr:gsub("%f[%w]"..k.."%f[%W]", "colors."..k) + expr = expr:gsub("([%w_]+)%$([%w_]+)", function(obj, prop) + if obj == "self" then + return string.format('__getState("%s")', prop) + elseif obj == "parent" then + return string.format('__getParentState("%s")', prop) + else + return string.format('__getElementState("%s", "%s")', obj, prop) end - end + end) expr = expr:gsub("([%w_]+)%.([%w_]+)", function(obj, prop) if protectedNames[obj] then @@ -37,6 +41,23 @@ local function parseExpression(expr, element) local env = setmetatable({ colors = colors, math = math, + tostring = tostring, + tonumber = tonumber, + __getState = function(prop) + return element:getState(prop) + end, + __getParentState = function(prop) + return element.parent:getState(prop) + end, + __getElementState = function(objName, prop) + local target = element:getBaseFrame():getChild(objName) + if not target then + errorManager.header = "Reactive evaluation error" + errorManager.error("Could not find element: " .. objName) + return nil + end + return target:getState(prop).value + end, __getProperty = function(objName, propName) if objName == "self" then return element.get(propName) @@ -55,6 +76,12 @@ local function parseExpression(expr, element) end }, { __index = mathEnv }) + if(element._properties[propName].type == "string")then + expr = "tostring(" .. expr .. ")" + elseif(element._properties[propName].type == "number")then + expr = "tonumber(" .. expr .. ")" + end + local func, err = load("return "..expr, "reactive", "t", env) if not func then errorManager.header = "Reactive evaluation error" @@ -68,7 +95,8 @@ end local function validateReferences(expr, element) for ref in expr:gmatch("([%w_]+)%.") do if not protectedNames[ref] then - if ref == "parent" then + if ref == "self" then + elseif ref == "parent" then if not element.parent then errorManager.header = "Reactive evaluation error" errorManager.error("No parent element available") @@ -87,12 +115,19 @@ local function validateReferences(expr, element) return true end -local functionCache = {} -local observerCache = setmetatable({}, {__mode = "k"}) +local functionCache = setmetatable({}, {__mode = "k"}) -local function setupObservers(element, expr) - if observerCache[element] then - for _, observer in ipairs(observerCache[element]) do +local observerCache = setmetatable({}, { + __mode = "k", + __index = function(t, k) + t[k] = {} + return t[k] + end +}) + +local function setupObservers(element, expr, propertyName) + if observerCache[element][propertyName] then + for _, observer in ipairs(observerCache[element][propertyName]) do observer.target:removeObserver(observer.property, observer.callback) end end @@ -123,7 +158,7 @@ local function setupObservers(element, expr) end end - observerCache[element] = observers + observerCache[element][propertyName] = observers end PropertySystem.addSetterHook(function(element, propertyName, value, config) @@ -133,15 +168,18 @@ PropertySystem.addSetterHook(function(element, propertyName, value, config) return config.default end - setupObservers(element, expr) + setupObservers(element, expr, propertyName) - if not functionCache[value] then - local parsedFunc = parseExpression(value, element) - functionCache[value] = parsedFunc + if not functionCache[element] then + functionCache[element] = {} + end + if not functionCache[element][value] then + local parsedFunc = parseExpression(value, element, propertyName) + functionCache[element][value] = parsedFunc end return function(self) - local success, result = pcall(functionCache[value]) + local success, result = pcall(functionCache[element][value]) if not success then errorManager.header = "Reactive evaluation error" if type(result) == "string" then @@ -161,8 +199,10 @@ local BaseElement = {} BaseElement.hooks = { destroy = function(self) if observerCache[self] then - for _, observer in ipairs(observerCache[self]) do - observer.target:observe(observer.property, observer.callback) + for propName, observers in pairs(observerCache[self]) do + for _, observer in ipairs(observers) do + observer.target:removeObserver(observer.property, observer.callback) + end end observerCache[self] = nil end diff --git a/src/plugins/state.lua b/src/plugins/state.lua new file mode 100644 index 0000000..ee632d8 --- /dev/null +++ b/src/plugins/state.lua @@ -0,0 +1,135 @@ +local PropertySystem = require("propertySystem") +local errorManager = require("errorManager") +local BaseElement = {} + +function BaseElement.setup(element) + element.defineProperty(element, "states", {default = {}, type = "table"}) + element.defineProperty(element, "computedStates", {default = {}, type = "table"}) + element.defineProperty(element, "stateUpdate", { + default = {key = "", value = nil, oldValue = nil}, + type = "table" + }) +end + +function BaseElement:initializeState(name, default, canTriggerRender, persist, path) + local states = self.get("states") + + if states[name] then + errorManager.error("State '" .. name .. "' already exists") + return self + end + + if persist then + local file = path or ("states/" .. self.get("name") .. "_" .. name .. ".state") + + if fs.exists(file) then + local f = fs.open(file, "r") + states[name] = { + value = textutils.unserialize(f.readAll()), + persist = true, + file = file + } + f.close() + else + states[name] = { + value = default, + persist = true, + file = file, + canTriggerRender = canTriggerRender + } + end + else + states[name] = { + value = default, + canTriggerRender = canTriggerRender + } + end + return self +end + +function BaseElement:setState(name, value) + local states = self.get("states") + if not states[name] then + error("State '"..name.."' not initialized") + end + + local oldValue = states[name].value + states[name].value = value + + if states[name].persist then + local dir = fs.getDir(states[name].file) + if not fs.exists(dir) then + fs.makeDir(dir) + end + local f = fs.open(states[name].file, "w") + f.write(textutils.serialize(value)) + f.close() + end + + if states[name].canTriggerRender then + self:updateRender() + end + + self.set("stateUpdate", { + key = name, + value = value, + oldValue = oldValue + }) + return self +end + +function BaseElement:getState(name) + local states = self.get("states") + if not states[name] then + errorManager.error("State '"..name.."' not initialized") + end + return states[name].value +end + +function BaseElement:computed(key, computeFn) + local computed = self.get("computedStates") + computed[key] = setmetatable({}, { + __call = function() + return computeFn(self) + end + }) + return self +end + +function BaseElement:shareState(stateKey, ...) + local value = self:getState(stateKey) + + for _, element in ipairs({...}) do + if element.get("states")[stateKey] then + errorManager.error("Cannot share state '" .. stateKey .. "': Target element already has this state") + return self + end + + element:initializeState(stateKey, value) + + self:observe("stateUpdate", function(self, update) + if update.key == stateKey then + element:setState(stateKey, update.value) + end + end) + end + return self +end + +function BaseElement:onStateChange(stateName, callback) + if not self.get("states")[stateName] then + errorManager.error("Cannot observe state '" .. stateName .. "': State not initialized") + return self + end + + self:observe("stateUpdate", function(self, update) + if update.key == stateName then + callback(self, update.value, update.oldValue) + end + end) + return self +end + +return { + BaseElement = BaseElement +} diff --git a/src/plugins/theme.lua b/src/plugins/theme.lua index d52a759..572a5dc 100644 --- a/src/plugins/theme.lua +++ b/src/plugins/theme.lua @@ -1,33 +1,36 @@ --- Has to be reworked - -local Theme = {} - local defaultTheme = { - colors = { - primary = colors.blue, - secondary = colors.cyan, - background = colors.black, - text = colors.white, - borders = colors.gray, - error = colors.red, - success = colors.green, + default = { + background = colors.lightGray, + foreground = colors.black, }, - elementStyles = { - Button = { - background = "background", - foreground = "text", - activeBackground = "primary", - activeForeground = "text", - }, - Input = { - background = "background", - foreground = "text", - focusBackground = "primary", - focusForeground = "text", - }, + BaseFrame = { + background = colors.white, + foreground = colors.black, + Frame = { - background = "background", - foreground = "text" + background = colors.gray, + + Button = { + background = "{self.clicked and colors.black or colors.blue}", + foreground = "{self.clicked and colors.blue or colors.white}" + } + }, + + Button = { + background = "{self.clicked and colors.black or colors.cyan}", + foreground = "{self.clicked and colors.cyan or colors.black}" + }, + + Input = { + background = "{self.focused and colors.cyan or colors.lightGray}", + foreground = colors.black, + placeholderColor = colors.gray + }, + + List = { + background = colors.cyan, + foreground = colors.black, + selectedColor = colors.blue } } } @@ -38,10 +41,6 @@ local themes = { local currentTheme = "default" -function Theme.registerTheme(name, theme) - themes[name] = theme -end - local function resolveThemeValue(value, theme) if type(value) == "string" and theme.colors[value] then return theme.colors[value] @@ -49,10 +48,50 @@ local function resolveThemeValue(value, theme) return value end +local function getThemeForElement(element) + local path = {} + local current = element + + while current do + table.insert(path, 1, current.get("type")) + current = current.parent + end + + local result = {} + local current = defaultTheme + + for _, elementType in ipairs(path) do + if current[elementType] then + for k,v in pairs(current[elementType]) do + result[k] = v + end + current = current[elementType] + end + end + + return result +end + +local function applyTheme(element, props) + + local theme = getThemeForElement(element) + + if props then + for k,v in pairs(props) do + theme[k] = v + end + end + + for k,v in pairs(theme) do + if element:getPropertyConfig(k) then + element.set(k, v) + end + end +end + local BaseElement = { hooks = { init = function(self) - -- Theme Properties für das Element registrieren self.defineProperty(self, "theme", { default = currentTheme, type = "string", @@ -68,7 +107,7 @@ local BaseElement = { function BaseElement:applyTheme(themeName) local theme = themes[themeName] or themes.default local elementType = self.get("type") - + if theme.elementStyles[elementType] then local styles = theme.elementStyles[elementType] for prop, value in pairs(styles) do @@ -79,21 +118,67 @@ function BaseElement:applyTheme(themeName) end end +local BaseFrame = { + hooks = { + init = function(self) + applyTheme(self) + end + } +} + local Container = { - hooks = { - addChild = function(self, child) - if self.get("themeInherit") then - child.set("theme", self.get("theme")) + hooks = { + init = function(self) + for k, _ in pairs(self.basalt.getElementManager().getElementList()) do + local capitalizedName = k:sub(1,1):upper() .. k:sub(2) + if capitalizedName ~= "BaseFrame" then + local methodName = "add"..capitalizedName + local original = self[methodName] + if original then + self[methodName] = function(self, name, props) + if type(name) == "table" then + props = name + name = nil + end + local element = original(self, name) + applyTheme(element, props) + return element + end + end + end end end } } -function Container.setup(element) - element.defineProperty(element, "themeInherit", {default = true, type = "boolean"}) -end +local themeAPI = { + setTheme = function(newTheme) + defaultTheme = newTheme + end, -return { - BaseElement = BaseElement, - Container = Container, + getTheme = function() + return defaultTheme + end, + + loadTheme = function(path) + local file = fs.open(path, "r") + if file then + local content = file.readAll() + file.close() + defaultTheme = textutils.unserializeJSON(content) + end + end } + +local Theme = { + setup = function(basalt) + basalt.setTheme(defaultTheme) + end, + + BaseElement = BaseElement, + BaseFrame = BaseFrame, + Container = Container, + API = themeAPI +} + +return Theme diff --git a/src/plugins/xml.lua b/src/plugins/xml.lua index bd7eefa..a847432 100644 --- a/src/plugins/xml.lua +++ b/src/plugins/xml.lua @@ -35,7 +35,7 @@ local function parseXML(self, xmlString) tag.children = {} tag.content = "" table.insert(current.children, tag) - + if not line:match("/>$") then table.insert(stack, current) current = tag diff --git a/src/propertySystem.lua b/src/propertySystem.lua index c72c179..dd75244 100644 --- a/src/propertySystem.lua +++ b/src/propertySystem.lua @@ -16,6 +16,16 @@ function PropertySystem.addSetterHook(hook) table.insert(PropertySystem._setterHooks, hook) end +local function applyHooks(element, propertyName, value, config) + for _, hook in ipairs(PropertySystem._setterHooks) do + local newValue = hook(element, propertyName, value, config) + if newValue ~= nil then + value = newValue + end + end + return value +end + function PropertySystem.defineProperty(class, name, config) if not rawget(class, '_properties') then class._properties = {} @@ -42,16 +52,8 @@ function PropertySystem.defineProperty(class, name, config) class["set" .. capitalizedName] = function(self, value, ...) expect(1, self, "element") - - -- Setter Hooks ausführen - for _, hook in ipairs(PropertySystem._setterHooks) do - local newValue = hook(self, name, value, config) - if newValue ~= nil then - value = newValue - end - end + value = applyHooks(self, name, value, config) - -- Type checking: Entweder korrekter Typ ODER Function if type(value) ~= "function" then expect(2, value, config.type) end @@ -59,12 +61,38 @@ function PropertySystem.defineProperty(class, name, config) if config.setter then value = config.setter(self, value, ...) end - + self:_updateProperty(name, value) return self end end +function PropertySystem.combineProperties(class, name, ...) + local properties = {...} + for k,v in pairs(properties)do + if not class._properties[v] then errorManager.error("Property not found: "..v) end + end + local capitalizedName = name:sub(1,1):upper() .. name:sub(2) + + class["get" .. capitalizedName] = function(self, ...) + expect(1, self, "element") + local value = {} + for _,v in pairs(properties)do + value[v] = self.get(v) + end + return table.unpack(value) + end + + class["set" .. capitalizedName] = function(self, ...) + expect(1, self, "element") + local values = {...} + for i,v in pairs(properties)do + self.set(v, values[i]) + end + 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 @@ -195,7 +223,7 @@ function PropertySystem:__init() if config.canTriggerRender then self:updateRender() end - self._values[name] = value + self._values[name] = applyHooks(self, name, value, config) if oldValue ~= value and self._observers[name] then for _, callback in ipairs(self._observers[name]) do callback(self, value, oldValue) @@ -249,10 +277,12 @@ function PropertySystem:__init() end end, __newindex = function(t, k, v) - if self._properties[k] then - if self._properties[k].setter then - v = self._properties[k].setter(self, v) + local config = self._properties[k] + if config then + if config.setter then + v = config.setter(self, v) end + v = applyHooks(self, k, v, config) self:_updateProperty(k, v) else rawset(t, k, v) @@ -278,15 +308,13 @@ end function PropertySystem:_updateProperty(name, value) local oldValue = self._values[name] - -- Wenn der alte Wert eine Funktion ist, müssen wir den tatsächlichen Wert holen if type(oldValue) == "function" then oldValue = oldValue(self) end - + self._values[name] = value - -- Wenn der neue Wert eine Funktion ist, evaluieren für Observer local newValue = type(value) == "function" and value(self) or value - + if oldValue ~= newValue then if self._properties[name].canTriggerRender then self:updateRender() diff --git a/src/render.lua b/src/render.lua index cfa5fa0..8f1048f 100644 --- a/src/render.lua +++ b/src/render.lua @@ -51,7 +51,7 @@ function Render:multiBlit(x, y, width, height, text, fg, bg) if(#text ~= #fg or #text ~= #bg)then error("Text, fg, and bg must be the same length") end - + text = text:rep(width) fg = fg:rep(width) bg = bg:rep(width) @@ -144,8 +144,6 @@ function Render:clear(bg) end function Render:render() - local benchmark = require("benchmark") - benchmark.start("render") local mergedRects = {} for _, rect in ipairs(self.buffer.dirtyRects) do @@ -162,7 +160,6 @@ function Render:render() 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 @@ -176,7 +173,6 @@ function Render:render() end end - benchmark.update("render") self.buffer.dirtyRects = {} if self.blink then @@ -186,7 +182,6 @@ function Render:render() self.terminal.setCursorBlink(false) end - --benchmark.stop("render") return self end