From b7f22bf63f34c2240f9ee273cd1354bfe1828d75 Mon Sep 17 00:00:00 2001 From: Robert Jelic <36573031+NoryiE@users.noreply.github.com> Date: Fri, 14 Feb 2025 14:40:20 +0100 Subject: [PATCH] - added List - added checkbox - added program - added slider - added progressbar - added reactive (dynamicValues) smaller bug fixxes --- .gitignore | 3 +- src/elementManager.lua | 5 +- src/elements/BaseElement.lua | 11 ++ src/elements/BaseFrame.lua | 8 ++ src/elements/Checkbox.lua | 49 +++++++++ src/elements/Container.lua | 19 ++++ src/elements/Input.lua | 114 +++++++++++++------- src/elements/Label.lua | 2 +- src/elements/List.lua | 103 +++++++++++++++++++ src/elements/Program.lua | 179 ++++++++++++++++++++++++++++++++ src/elements/ProgressBar.lua | 42 ++++++++ src/elements/Slider.lua | 87 ++++++++++++++++ src/elements/VisualElement.lua | 17 ++- src/errorManager.lua | 4 + src/init.lua | 1 - src/main.lua | 9 +- src/plugins/animation.lua | 1 + src/plugins/reactive.lua | 183 +++++++++++++++++++++++++++++---- src/plugins/theme.lua | 99 ++++++++++++++++++ src/propertySystem.lua | 82 +++++++++++++-- src/render.lua | 30 +++++- test_weak.lua | 49 +++++++++ 22 files changed, 1021 insertions(+), 76 deletions(-) create mode 100644 src/elements/Checkbox.lua create mode 100644 src/elements/List.lua create mode 100644 src/elements/Program.lua create mode 100644 src/elements/ProgressBar.lua create mode 100644 src/elements/Slider.lua create mode 100644 src/plugins/theme.lua create mode 100644 test_weak.lua diff --git a/.gitignore b/.gitignore index ed2c9f0..2f91782 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ test.lua test2.lua lua-ls-cc-tweaked-main -test.xml \ No newline at end of file +test.xml +ascii.lua \ No newline at end of file diff --git a/src/elementManager.lua b/src/elementManager.lua index 6359cc1..915ae70 100644 --- a/src/elementManager.lua +++ b/src/elementManager.lua @@ -76,8 +76,9 @@ function ElementManager.loadElement(name) end if(type(hooks)=="function")then element[methodName] = function(self, ...) - original(self, ...) - return hooks(self, ...) + local result = original(self, ...) + local hookResult = hooks(self, ...) + return hookResult == nil and result or hookResult end elseif(type(hooks)=="table")then element[methodName] = function(self, ...) diff --git a/src/elements/BaseElement.lua b/src/elements/BaseElement.lua index 7ff450a..25054ce 100644 --- a/src/elements/BaseElement.lua +++ b/src/elements/BaseElement.lua @@ -167,6 +167,17 @@ function BaseElement:handleEvent(event, ...) return true end +function BaseElement:getBaseFrame() + if self.parent then + return self.parent:getBaseFrame() + end + return self +end + +function BaseElement:destroy() + +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 4b9c3fa..0c8935e 100644 --- a/src/elements/BaseFrame.lua +++ b/src/elements/BaseFrame.lua @@ -40,6 +40,14 @@ function BaseFrame:textFg(x, y, text, fg) self._render:textFg(x, y, text, fg) end +function BaseFrame:textBg(x, y, text, bg) + self._render:textBg(x, y, text, bg) +end + +function BaseFrame:blit(x, y, text, fg, bg) + self._render:blit(x, y, text, fg, bg) +end + function BaseFrame:setCursor(x, y, blink) local term = self.get("term") self._render:setCursor(x, y, blink) diff --git a/src/elements/Checkbox.lua b/src/elements/Checkbox.lua new file mode 100644 index 0000000..533b5c2 --- /dev/null +++ b/src/elements/Checkbox.lua @@ -0,0 +1,49 @@ +local VisualElement = require("elements/VisualElement") + +---@class Checkbox : VisualElement +local Checkbox = setmetatable({}, VisualElement) +Checkbox.__index = Checkbox + +---@property checked boolean Whether checkbox is checked +Checkbox.defineProperty(Checkbox, "checked", {default = false, type = "boolean", canTriggerRender = true}) +---@property text string Label text +Checkbox.defineProperty(Checkbox, "text", {default = "", type = "string", canTriggerRender = true}) +---@property symbol string Check symbol +Checkbox.defineProperty(Checkbox, "symbol", {default = "x", type = "string"}) + +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) + return self +end + +function Checkbox:init(props, basalt) + VisualElement.init(self, props, basalt) + self.set("type", "Checkbox") +end + +function Checkbox:mouse_click(button, x, y) + if VisualElement.mouse_click(self, button, x, y) then + self.set("checked", not self.get("checked")) + self:fireEvent("change", self.get("checked")) + return true + end +end + +function Checkbox:render() + VisualElement.render(self) + + local text = self.get("checked") and self.get("symbol") or " " + self:textFg(1, 1, "["..text.."]", self.get("foreground")) + + local label = self.get("text") + if #label > 0 then + self:textFg(4, 1, label, self.get("foreground")) + end +end + +return Checkbox \ No newline at end of file diff --git a/src/elements/Container.lua b/src/elements/Container.lua index a218d50..a356714 100644 --- a/src/elements/Container.lua +++ b/src/elements/Container.lua @@ -305,6 +305,25 @@ 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 +function Container:blit(x, y, text, fg, bg) + local w, h = self.get("width"), self.get("height") + + if y < 1 or y > h then return end + + local textStart = x < 1 and (2 - x) or 1 + local textLen = math.min(#text - textStart + 1, w - math.max(1, x) + 1) + local fgLen = math.min(#fg - textStart + 1, w - math.max(1, x) + 1) + local bgLen = math.min(#bg - textStart + 1, w - math.max(1, x) + 1) + + if textLen <= 0 then return end + + local finalText = text:sub(textStart, textStart + textLen - 1) + local finalFg = fg:sub(textStart, textStart + fgLen - 1) + local finalBg = bg:sub(textStart, textStart + bgLen - 1) + + VisualElement.blit(self, math.max(1, x), math.max(1, y), finalText, finalFg, finalBg) +end + function Container:render() VisualElement.render(self) if not self.get("childrenSorted")then diff --git a/src/elements/Input.lua b/src/elements/Input.lua index d7d2936..5bb1e80 100644 --- a/src/elements/Input.lua +++ b/src/elements/Input.lua @@ -1,4 +1,5 @@ local VisualElement = require("elements/VisualElement") +local tHex = require("libraries/colorHex") ---@class Input : VisualElement local Input = setmetatable({}, VisualElement) @@ -10,19 +11,26 @@ Input.defineProperty(Input, "text", {default = "", type = "string", canTriggerRe ---@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"}) +---@property viewOffset number Input - offset of view +Input.defineProperty(Input, "viewOffset", {default = 0, type = "number", canTriggerRender = true}) + +-- Neue Properties +Input.defineProperty(Input, "maxLength", {default = nil, type = "number"}) +Input.defineProperty(Input, "placeholder", {default = "asd", type = "string"}) +Input.defineProperty(Input, "placeholderColor", {default = colors.gray, type = "number"}) +Input.defineProperty(Input, "focusedColor", {default = colors.blue, type = "number"}) +Input.defineProperty(Input, "pattern", {default = nil, type = "string"}) 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 props table The properties to initialize the element with --- @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) +function Input.new(props, basalt) local self = setmetatable({}, Input):__init() self:init(id, basalt) self.set("width", 8) @@ -30,8 +38,8 @@ function Input.new(id, basalt) return self end -function Input:init(id, basalt) - VisualElement.init(self, id, basalt) +function Input:init(props, basalt) + VisualElement.init(self, props, basalt) self.set("type", "Input") end @@ -39,8 +47,15 @@ function Input:char(char) if not self.get("focused") then return end local text = self.get("text") local pos = self.get("cursorPos") + local maxLength = self.get("maxLength") + local pattern = self.get("pattern") + + if maxLength and #text >= maxLength then return end + if pattern and not char:match(pattern) then return end + self.set("text", text:sub(1, pos-1) .. char .. text:sub(pos)) self.set("cursorPos", pos + 1) + self:updateRender() self:updateViewport() end @@ -48,64 +63,91 @@ function Input:key(key) if not self.get("focused") then return end local pos = self.get("cursorPos") local text = self.get("text") + local viewOffset = self.get("viewOffset") + local width = self.get("width") - 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) + if key == keys.left then + if pos > 1 then + self.set("cursorPos", pos - 1) + if pos - 1 <= viewOffset then + self.set("viewOffset", math.max(0, pos - 2)) + end + end + elseif key == keys.right then + if pos <= #text then + self.set("cursorPos", pos + 1) + if pos - viewOffset >= width then + self.set("viewOffset", pos - width + 1) + end + end + elseif key == keys.backspace then + if pos > 1 then + self.set("text", text:sub(1, pos-2) .. text:sub(pos)) + self.set("cursorPos", pos - 1) + self:updateRender() + self:updateViewport() + end end - self:updateViewport() + + local relativePos = self.get("cursorPos") - self.get("viewOffset") + self:setCursor(relativePos, 1, true) end function Input:focus() VisualElement.focus(self) - self.set("background", colors.blue) - self:setCursor(1,1, true) + self:updateRender() end function Input:blur() VisualElement.blur(self) - self.set("background", colors.green) + self:updateRender() +end + +function Input:mouse_click(button, x, y) + if VisualElement.mouse_click(self, button, x, y) then + local relX, relY = self:getRelativePosition(x, y) + local text = self.get("text") + self:setCursor(math.min(relX, #text + 1), relY, true) + self:set("cursorPos", relX + self.get("viewOffset")) + return true + end 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 + local textLength = #self.get("text") + if cursorPos - viewOffset > width then self.set("viewOffset", cursorPos - width) + elseif cursorPos <= viewOffset then + + self.set("viewOffset", math.max(0, cursorPos - 1)) end - - -- Wenn Cursor außerhalb des sichtbaren Bereichs nach links - if cursorPos <= viewOffset then - self.set("viewOffset", cursorPos - 1) + + if viewOffset > textLength - width then + self.set("viewOffset", math.max(0, textLength - width)) 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 placeholder = self.get("placeholder") + local focusedColor = self.get("focusedColor") + local focused = self.get("focused") + local width, height = self.get("width"), self.get("height") + self:multiBlit(1, 1, width, height, " ", tHex[self.get("foreground")], tHex[focused and focusedColor or self.get("background")]) + + if #text == 0 and #placeholder ~= 0 and self.get("focused") == false then + self:textFg(1, 1, placeholder:sub(1, width), self.get("placeholderColor")) + return + end + 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 index 93cd6a6..b5c7f71 100644 --- a/src/elements/Label.lua +++ b/src/elements/Label.lua @@ -12,7 +12,7 @@ Label.defineProperty(Label, "text", {default = "Label", type = "string", setter end}) --- Creates a new Label instance ---- @param name table The properties to initialize the element with +--- @param props 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) diff --git a/src/elements/List.lua b/src/elements/List.lua new file mode 100644 index 0000000..b616598 --- /dev/null +++ b/src/elements/List.lua @@ -0,0 +1,103 @@ +local VisualElement = require("elements/VisualElement") + +---@class List : VisualElement +local List = setmetatable({}, VisualElement) +List.__index = List + +---@property items table List of items to display +List.defineProperty(List, "items", {default = {}, type = "table", canTriggerRender = true}) +---@property selectedIndex number Currently selected item index +List.defineProperty(List, "selectedIndex", {default = 0, type = "number", canTriggerRender = true}) +---@property selectable boolean Whether items can be selected +List.defineProperty(List, "selectable", {default = true, type = "boolean"}) +---@property offset number Scrolling offset +List.defineProperty(List, "offset", {default = 0, type = "number", canTriggerRender = true}) +---@property selectedColor color Color for selected item +List.defineProperty(List, "selectedColor", {default = colors.blue, type = "number"}) + +List.listenTo(List, "mouse_click") +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) + return self +end + +function List:init(props, basalt) + VisualElement.init(self, props, basalt) + self.set("type", "List") +end + +function List:addItem(text) + local items = self.get("items") + table.insert(items, text) + self:updateRender() + return self +end + +function List:removeItem(index) + local items = self.get("items") + table.remove(items, index) + self:updateRender() + return self +end + +function List:clear() + self.set("items", {}) + self.set("selectedIndex", 0) + self:updateRender() + return self +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") + + if index <= #self.get("items") then + self.set("selectedIndex", index) + self:fireEvent("select", index, self.get("items")[index]) + self:updateRender() + return true + end + end +end + +function List:mouse_scroll(direction, x, y) + if self:isInBounds(x, y) then + local offset = self.get("offset") + local maxOffset = math.max(0, #self.get("items") - self.get("height")) + + offset = math.min(maxOffset, math.max(0, offset + direction)) + self.set("offset", offset) + return true + end +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") + + 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) + else + self:textFg(1, i, item, self.get("foreground")) + end + end + end +end + +return List diff --git a/src/elements/Program.lua b/src/elements/Program.lua new file mode 100644 index 0000000..d426c4a --- /dev/null +++ b/src/elements/Program.lua @@ -0,0 +1,179 @@ +local elementManager = require("elementManager") +local VisualElement = elementManager.getElement("VisualElement") +local errorManager = require("errorManager") + +--TODO: +-- Rendering optimization (only render when screen changed) +-- Eventsystem improvement +-- Cursor is sometimes not visible on time + +---@class Program : VisualElement +local Program = setmetatable({}, VisualElement) +Program.__index = Program + +Program.defineProperty(Program, "program", {default = nil, type = "table"}) +Program.defineProperty(Program, "path", {default = "", type = "string"}) +Program.defineProperty(Program, "running", {default = false, type = "boolean"}) + +Program.listenTo(Program, "key") +Program.listenTo(Program, "char") +Program.listenTo(Program, "key_up") +Program.listenTo(Program, "paste") +Program.listenTo(Program, "mouse_click") +Program.listenTo(Program, "mouse_drag") +Program.listenTo(Program, "mouse_scroll") +Program.listenTo(Program, "mouse_up") + +local BasaltProgram = {} +BasaltProgram.__index = BasaltProgram +local newPackage = dofile("rom/modules/main/cc/require.lua").make + +function BasaltProgram.new() + local self = setmetatable({}, BasaltProgram) + self.env = {} + self.args = {} + return self +end + +function BasaltProgram:run(path, width, height) + self.window = window.create(term.current(), 1, 1, width, height, false) + local pPath = shell.resolveProgram(path) + if(pPath~=nil)then + if(fs.exists(pPath)) then + local file = fs.open(pPath, "r") + local content = file.readAll() + file.close() + local env = setmetatable(self.env, {__index=_ENV}) + env.shell = shell + env.term = self.window + env.require, env.package = newPackage(env, fs.getDir(pPath)) + env.term.current = term.current + env.term.redirect = term.redirect + env.term.native = term.native + + self.coroutine = coroutine.create(function() + local program = load(content, path, "bt", env) + if program then + local current = term.current() + term.redirect(self.window) + local result = program(path, table.unpack(self.args)) + term.redirect(current) + return result + end + end) + local current = term.current() + term.redirect(self.window) + local ok, result = coroutine.resume(self.coroutine) + term.redirect(current) + if not ok then + errorManager.header = "Basalt Program Error ".. path + errorManager.error(result) + end + else + errorManager.header = "Basalt Program Error ".. path + errorManager.error("File not found") + end + else + errorManager.header = "Basalt Program Error" + errorManager.error("Program "..path.." not found") + end +end + +function BasaltProgram:resize(width, height) + self.window.reposition(1, 1, width, height) +end + +function BasaltProgram:resume(event, ...) + if self.coroutine==nil or coroutine.status(self.coroutine)=="dead" then return end + if(self.filter~=nil)then + if(event~=self.filter)then return end + self.filter=nil + end + local current = term.current() + term.redirect(self.window) + local ok, result = coroutine.resume(self.coroutine, event, ...) + term.redirect(current) + + if ok then + self.filter = result + else + errorManager.header = "Basalt Program Error" + errorManager.error(result) + end + return ok, result +end + +function BasaltProgram:stop() + +end + +--- Creates a new Program instance +--- @param props table The properties to initialize the element with +--- @param basalt table The basalt instance +--- @return Program object The newly created Program instance +--- @usage local element = Program.new("myId", basalt) +function Program.new(props, basalt) + local self = setmetatable({}, Program):__init() + self.set("z", 5) + self.set("width", 30) + self.set("height", 12) + self:init(props, basalt) + return self +end + +function Program:init(props, basalt) + VisualElement.init(self, props, basalt) + self.set("type", "Program") +end + +function Program:execute(path) + self.set("path", path) + self.set("running", true) + local program = BasaltProgram.new() + self.set("program", program) + program:run(path, self.get("width"), self.get("height")) + self:updateRender() + return self +end + +function Program:dispatchEvent(event, ...) + local program = self.get("program") + local result = VisualElement.dispatchEvent(self, event, ...) + if program then + program:resume(event, ...) + if(self.get("focused"))then + local cursorBlink = program.window.getCursorBlink() + local cursorX, cursorY = program.window.getCursorPos() + self:setCursor(cursorX, cursorY, cursorBlink) + end + self:updateRender() + end + return result +end + +function Program:focus() + if(VisualElement.focus(self))then + local program = self.get("program") + if program then + local cursorBlink = program.window.getCursorBlink() + local cursorX, cursorY = program.window.getCursorPos() + self:setCursor(cursorX, cursorY, cursorBlink) + end + end +end + +function Program:render() + VisualElement.render(self) + local program = self.get("program") + if program then + local _, height = program.window.getSize() + for y = 1, height do + local text, fg, bg = program.window.getLine(y) + if text then + self:blit(1, y, text, fg, bg) + end + end + end +end + +return Program \ No newline at end of file diff --git a/src/elements/ProgressBar.lua b/src/elements/ProgressBar.lua new file mode 100644 index 0000000..5ed803d --- /dev/null +++ b/src/elements/ProgressBar.lua @@ -0,0 +1,42 @@ +local VisualElement = require("elements/VisualElement") + +---@class ProgressBar : VisualElement +local ProgressBar = setmetatable({}, VisualElement) +ProgressBar.__index = ProgressBar + +---@property progress number Current progress (0-100) +ProgressBar.defineProperty(ProgressBar, "progress", {default = 0, type = "number", canTriggerRender = true}) +---@property showPercentage boolean Show percentage text +ProgressBar.defineProperty(ProgressBar, "showPercentage", {default = false, type = "boolean"}) +---@property progressColor color Progress bar color +ProgressBar.defineProperty(ProgressBar, "progressColor", {default = colors.lime, type = "number"}) + +function ProgressBar.new(props, basalt) + local self = setmetatable({}, ProgressBar):__init() + self:init(props, basalt) + self.set("width", 10) + self.set("height", 1) + return self +end + +function ProgressBar:init(props, basalt) + VisualElement.init(self, props, basalt) + self.set("type", "ProgressBar") +end + +function ProgressBar:render() + VisualElement.render(self) + local width = self.get("width") + local progress = math.min(100, math.max(0, self.get("progress"))) + local fillWidth = math.floor((width * progress) / 100) + + self:textBg(1, 1, string.rep(" ", fillWidth), self.get("progressColor")) + + if self.get("showPercentage") then + local text = tostring(progress).."%" + local x = math.floor((width - #text) / 2) + 1 + self:textFg(x, 1, text, self.get("foreground")) + end +end + +return ProgressBar \ No newline at end of file diff --git a/src/elements/Slider.lua b/src/elements/Slider.lua new file mode 100644 index 0000000..a21ffa1 --- /dev/null +++ b/src/elements/Slider.lua @@ -0,0 +1,87 @@ +local VisualElement = require("elements/VisualElement") + +---@class Slider : VisualElement +local Slider = setmetatable({}, VisualElement) +Slider.__index = Slider + +---@property step number 1 Current step position (1 to width/height) +Slider.defineProperty(Slider, "step", {default = 1, type = "number", canTriggerRender = true}) +---@property max number 100 Maximum value for value conversion +Slider.defineProperty(Slider, "max", {default = 100, type = "number"}) +---@property horizontal boolean true Whether the slider is horizontal +Slider.defineProperty(Slider, "horizontal", {default = true, type = "boolean", canTriggerRender = true}) +---@property barColor color color Colors for the slider bar +Slider.defineProperty(Slider, "barColor", {default = colors.gray, type = "number", canTriggerRender = true}) +---@property sliderColor color The color of the slider handle +Slider.defineProperty(Slider, "sliderColor", {default = colors.blue, type = "number", canTriggerRender = true}) + +Slider.listenTo(Slider, "mouse_click") +Slider.listenTo(Slider, "mouse_drag") +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) + return self +end + +function Slider:init(props, basalt) + VisualElement.init(self, props, basalt) + self.set("type", "Slider") +end + +function Slider:getValue() + local step = self.get("step") + local max = self.get("max") + local maxSteps = self.get("horizontal") and self.get("width") or self.get("height") + return math.floor((step - 1) * (max / (maxSteps - 1))) +end + +function Slider:mouse_click(button, x, y) + if button == 1 and self:isInBounds(x, y) then + local relX, relY = self:getRelativePosition(x, y) + local pos = self.get("horizontal") and relX or relY + local maxSteps = self.get("horizontal") and self.get("width") or self.get("height") + + self.set("step", math.min(maxSteps, math.max(1, pos))) + self:updateRender() + return true + end +end +Slider.mouse_drag = Slider.mouse_click + +function Slider:mouse_scroll(direction, x, y) + if self:isInBounds(x, y) then + local step = self.get("step") + local maxSteps = self.get("horizontal") and self.get("width") or self.get("height") + self.set("step", math.min(maxSteps, math.max(1, step + direction))) + self:updateRender() + return true + end +end + +function Slider:render() + VisualElement.render(self) + local width = self.get("width") + local height = self.get("height") + local horizontal = self.get("horizontal") + local step = self.get("step") + + local barChar = horizontal and "\140" or "│" + local text = string.rep(barChar, horizontal and width or height) + + if horizontal then + self:textFg(1, 1, text, self.get("barColor")) + self:textBg(step, 1, " ", self.get("sliderColor")) + else + for y = 1, height do + self:textFg(1, y, barChar, self.get("barColor")) + end + self:textFg(1, step, "\140", self.get("sliderColor")) + end +end + +return Slider \ No newline at end of file diff --git a/src/elements/VisualElement.lua b/src/elements/VisualElement.lua index dd44b1d..b7b457c 100644 --- a/src/elements/VisualElement.lua +++ b/src/elements/VisualElement.lua @@ -1,12 +1,12 @@ local elementManager = require("elementManager") local BaseElement = elementManager.getElement("BaseElement") +local tHex = require("libraries/colorHex") ---@alias color number ---@class VisualElement : BaseElement local VisualElement = setmetatable({}, BaseElement) VisualElement.__index = VisualElement -local tHex = require("libraries/colorHex") ---@property x number 1 x position of the element VisualElement.defineProperty(VisualElement, "x", {default = 1, type = "number", canTriggerRender = true}) @@ -56,6 +56,8 @@ end}) VisualElement.listenTo(VisualElement, "focus") VisualElement.listenTo(VisualElement, "blur") +local max, min = math.max, math.min + --- Creates a new VisualElement instance --- @param props table The properties to initialize the element with --- @param basalt table The basalt instance @@ -98,6 +100,18 @@ function VisualElement:textFg(x, y, text, fg) self.parent:textFg(x, y, text, fg) end +function VisualElement:textBg(x, y, text, bg) + x = x + self.get("x") - 1 + y = y + self.get("y") - 1 + self.parent:textBg(x, y, text, bg) +end + +function VisualElement:blit(x, y, text, fg, bg) + x = x + self.get("x") - 1 + y = y + self.get("y") - 1 + self.parent:blit(x, y, text, fg, bg) +end + --- Checks if the specified coordinates are within the bounds of the element --- @param x number The x position to check --- @param y number The y position to check @@ -193,6 +207,7 @@ end function VisualElement:setCursor(x, y, blink) if self.parent then local absX, absY = self:getAbsolutePosition(x, y) + absX = max(self.get("x"), min(absX, self.get("width") + self.get("x") - 1)) return self.parent:setCursor(absX, absY, blink) end end diff --git a/src/errorManager.lua b/src/errorManager.lua index 6b50e32..8e5d1f3 100644 --- a/src/errorManager.lua +++ b/src/errorManager.lua @@ -12,6 +12,9 @@ local function coloredPrint(message, color) end function errorHandler.error(errMsg) + if errorHandler.errorHandled then + error() + end term.setBackgroundColor(colors.black) term.clear() @@ -98,6 +101,7 @@ function errorHandler.error(errMsg) term.setBackgroundColor(colors.black) LOGGER.error(errMsg) + errorHandler.errorHandled = true error() end diff --git a/src/init.lua b/src/init.lua index 1ff35cb..791dad7 100644 --- a/src/init.lua +++ b/src/init.lua @@ -13,7 +13,6 @@ local function errorHandler(err) errorManager.error(err) end --- Use xpcall with error handler local ok, result = pcall(require, "main") package.path = defaultPath diff --git a/src/main.lua b/src/main.lua index 39c9e92..70687a8 100644 --- a/src/main.lua +++ b/src/main.lua @@ -28,6 +28,7 @@ local lazyElementCount = 10 local lazyElementsTimer = 0 local isLazyElementsTimerActive = false + local function queueLazyElements() if(isLazyElementsTimerActive)then return end lazyElementsTimer = os.startTimer(0.2) @@ -134,9 +135,11 @@ local function updateEvent(event, ...) if(event=="terminate")then basalt.stop() end if lazyElementsEventHandler(event, ...) then return end - if(mainFrame:dispatchEvent(event, ...))then - return - end + if(mainFrame)then + if(mainFrame:dispatchEvent(event, ...))then + return + end + end if basalt._events[event] then for _, callback in ipairs(basalt._events[event]) do diff --git a/src/plugins/animation.lua b/src/plugins/animation.lua index 4c41fbd..0602eae 100644 --- a/src/plugins/animation.lua +++ b/src/plugins/animation.lua @@ -312,6 +312,7 @@ function VisualElement.hooks.dispatchEvent(self, event, ...) end function VisualElement.setup(element) + VisualElementBaseDispatchEvent = element.dispatchEvent element.defineProperty(element, "animation", {default = nil, type = "table"}) element.listenTo(element, "timer") end diff --git a/src/plugins/reactive.lua b/src/plugins/reactive.lua index ef3907e..f51dde5 100644 --- a/src/plugins/reactive.lua +++ b/src/plugins/reactive.lua @@ -1,32 +1,173 @@ -local function setupReactiveProperty(element, propertyName, expression) +local errorManager = require("errorManager") +local PropertySystem = require("propertySystem") +local log = require("log") -end +local protectedNames = { + colors = true, + math = true, + clamp = true, + round = true +} -local function createReactiveFunction(expression, scope) - local code = expression:gsub( - "(%w+)%s*%?%s*([^:]+)%s*:%s*([^}]+)", - "%1 and %2 or %3" - ) +local mathEnv = { + clamp = function(val, min, max) + return math.min(math.max(val, min), max) + end, + round = function(val) + return math.floor(val + 0.5) + end +} - return load(string.format([[ - return function(self) - return %s +local function parseExpression(expr, element) + 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) end - ]], code), "reactive", "t", scope)() + end + + expr = expr:gsub("([%w_]+)%.([%w_]+)", function(obj, prop) + if protectedNames[obj] then + return obj.."."..prop + end + return string.format('__getProperty("%s", "%s")', obj, prop) + end) + + local env = setmetatable({ + colors = colors, + math = math, + __getProperty = function(objName, propName) + if objName == "self" then + return element.get(propName) + elseif objName == "parent" then + return element.parent.get(propName) + else + 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.get(propName) + end + end + }, { __index = mathEnv }) + + local func, err = load("return "..expr, "reactive", "t", env) + if not func then + errorManager.header = "Reactive evaluation error" + errorManager.error("Invalid expression: " .. err) + return function() return nil end + end + + return func end +local function validateReferences(expr, element) + for ref in expr:gmatch("([%w_]+)%.") do + if not protectedNames[ref] then + if ref == "parent" then + if not element.parent then + errorManager.header = "Reactive evaluation error" + errorManager.error("No parent element available") + return false + end + else + local target = element:getBaseFrame():getChild(ref) + if not target then + errorManager.header = "Reactive evaluation error" + errorManager.error("Referenced element not found: " .. ref) + return false + end + end + end + end + return true +end + +local functionCache = {} +local observerCache = setmetatable({}, {__mode = "k"}) + +local function setupObservers(element, expr) + if observerCache[element] then + for _, observer in ipairs(observerCache[element]) do + observer.target:removeObserver(observer.property, observer.callback) + end + end + + local observers = {} + for ref, prop in expr:gmatch("([%w_]+)%.([%w_]+)") do + if not protectedNames[ref] then + local target + if ref == "self" then + target = element + elseif ref == "parent" then + target = element.parent + else + target = element:getBaseFrame():getChild(ref) + end + + if target then + local observer = { + target = target, + property = prop, + callback = function() + element:updateRender() + end + } + target:observe(prop, observer.callback) + table.insert(observers, observer) + end + end + end + + observerCache[element] = observers +end + +PropertySystem.addSetterHook(function(element, propertyName, value, config) + if type(value) == "string" and value:match("^{.+}$") then + local expr = value:gsub("^{(.+)}$", "%1") + if not validateReferences(expr, element) then + return config.default + end + + setupObservers(element, expr) + + if not functionCache[value] then + local parsedFunc = parseExpression(value, element) + functionCache[value] = parsedFunc + end + + return function(self) + local success, result = pcall(functionCache[value]) + if not success then + errorManager.header = "Reactive evaluation error" + if type(result) == "string" then + errorManager.error("Error evaluating expression: " .. result) + else + errorManager.error("Error evaluating expression") + end + return config.default + end + return result + end + end +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 +BaseElement.hooks = { + destroy = function(self) + if observerCache[self] then + for _, observer in ipairs(observerCache[self]) do + observer.target:observe(observer.property, observer.callback) + end + observerCache[self] = nil + end + end +} return { BaseElement = BaseElement diff --git a/src/plugins/theme.lua b/src/plugins/theme.lua new file mode 100644 index 0000000..d52a759 --- /dev/null +++ b/src/plugins/theme.lua @@ -0,0 +1,99 @@ +-- 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, + }, + elementStyles = { + Button = { + background = "background", + foreground = "text", + activeBackground = "primary", + activeForeground = "text", + }, + Input = { + background = "background", + foreground = "text", + focusBackground = "primary", + focusForeground = "text", + }, + Frame = { + background = "background", + foreground = "text" + } + } +} + +local themes = { + default = defaultTheme +} + +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] + end + return value +end + +local BaseElement = { + hooks = { + init = function(self) + -- Theme Properties für das Element registrieren + self.defineProperty(self, "theme", { + default = currentTheme, + type = "string", + setter = function(self, value) + self:applyTheme(value) + return value + end + }) + end + } +} + +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 + if self:getPropertyConfig(prop) then + self.set(prop, resolveThemeValue(value, theme)) + end + end + end +end + +local Container = { + hooks = { + addChild = function(self, child) + if self.get("themeInherit") then + child.set("theme", self.get("theme")) + end + end + } +} + +function Container.setup(element) + element.defineProperty(element, "themeInherit", {default = true, type = "boolean"}) +end + +return { + BaseElement = BaseElement, + Container = Container, +} diff --git a/src/propertySystem.lua b/src/propertySystem.lua index 6c39a78..c72c179 100644 --- a/src/propertySystem.lua +++ b/src/propertySystem.lua @@ -10,6 +10,12 @@ PropertySystem.__index = PropertySystem PropertySystem._properties = {} local blueprintTemplates = {} +PropertySystem._setterHooks = {} + +function PropertySystem.addSetterHook(hook) + table.insert(PropertySystem._setterHooks, hook) +end + function PropertySystem.defineProperty(class, name, config) if not rawget(class, '_properties') then class._properties = {} @@ -28,15 +34,32 @@ function PropertySystem.defineProperty(class, name, config) class["get" .. capitalizedName] = function(self, ...) expect(1, self, "element") local value = self._values[name] + if type(value) == "function" and config.type ~= "function" then + value = value(self) + end return config.getter and config.getter(self, value, ...) or value end class["set" .. capitalizedName] = function(self, value, ...) expect(1, self, "element") - expect(2, value, config.type) + + -- 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 + + -- Type checking: Entweder korrekter Typ ODER Function + if type(value) ~= "function" then + expect(2, value, config.type) + end + if config.setter then value = config.setter(self, value, ...) end + self:_updateProperty(name, value) return self end @@ -104,7 +127,12 @@ function PropertySystem.blueprint(elementClass, properties, basalt, parent) } blueprint.get = function(name) - return blueprint._values[name] + local value = blueprint._values[name] + local config = elementClass._properties[name] + if type(value) == "function" and config.type ~= "function" then + value = value(blueprint) + end + return value end blueprint.set = function(name, value) blueprint._values[name] = value @@ -180,6 +208,9 @@ function PropertySystem:__init() local value = self._values[name] local config = self._properties[name] if(config==nil)then errorManager.error("Property not found: "..name) return end + if type(value) == "function" and config.type ~= "function" then + value = value(self) + end return config.getter and config.getter(self, value, ...) or value end @@ -203,8 +234,13 @@ function PropertySystem:__init() local originalIndex = originalMT.__index setmetatable(self, { __index = function(t, k) - if self._properties[k] then - return self._values[k] + local config = self._properties[k] + if config then + local value = self._values[k] + if type(value) == "function" and config.type ~= "function" then + value = value(self) + end + return value end if type(originalIndex) == "function" then return originalIndex(t, k) @@ -242,14 +278,22 @@ end function PropertySystem:_updateProperty(name, value) local oldValue = self._values[name] - if oldValue ~= value then - self._values[name] = value + -- 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() end if self._observers[name] then for _, callback in ipairs(self._observers[name]) do - callback(self, value, oldValue) + callback(self, newValue, oldValue) end end end @@ -261,6 +305,30 @@ function PropertySystem:observe(name, callback) return self end +function PropertySystem:removeObserver(name, callback) + if self._observers[name] then + for i, cb in ipairs(self._observers[name]) do + if cb == callback then + table.remove(self._observers[name], i) + if #self._observers[name] == 0 then + self._observers[name] = nil + end + break + end + end + end + return self +end + +function PropertySystem:removeAllObservers(name) + if name then + self._observers[name] = nil + else + self._observers = {} + end + return self +end + function PropertySystem:instanceProperty(name, config) PropertySystem.defineProperty(self, name, config) self._values[name] = config.default diff --git a/src/render.lua b/src/render.lua index d1f2779..cfa5fa0 100644 --- a/src/render.lua +++ b/src/render.lua @@ -80,6 +80,17 @@ function Render:textFg(x, y, text, fg) return self end +function Render:textBg(x, y, text, bg) + if y < 1 or y > self.height then return self end + bg = colorChars[bg] or "f" + + self.buffer.text[y] = self.buffer.text[y]:sub(1,x-1) .. text .. self.buffer.text[y]:sub(x+#text) + self.buffer.bg[y] = self.buffer.bg[y]:sub(1,x-1) .. bg:rep(#text) .. self.buffer.bg[y]:sub(x+#text) + self:addDirtyRect(x, y, #text, 1) + + return self +end + function Render:text(x, y, text) if y < 1 or y > self.height then return self end @@ -107,6 +118,20 @@ function Render:bg(x, y, bg) return self 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 + error("Text, fg, and bg must be the same length") + end + + 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:addDirtyRect(x, y, #text, 1) + + return self +end + function Render:clear(bg) local bgChar = colorChars[bg] or "f" for y=1, self.height do @@ -153,7 +178,7 @@ function Render:render() benchmark.update("render") self.buffer.dirtyRects = {} - + if self.blink then self.terminal.setCursorPos(self.xCursor, self.yCursor) self.terminal.setCursorBlink(true) @@ -165,9 +190,8 @@ function Render:render() return self end --- Hilfsfunktionen für Rectangle-Management function Render:rectOverlaps(r1, r2) - return not (r1.x + r1.width <= r2.x or + 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) diff --git a/test_weak.lua b/test_weak.lua new file mode 100644 index 0000000..752b927 --- /dev/null +++ b/test_weak.lua @@ -0,0 +1,49 @@ +local cache = setmetatable({}, {__mode = "k"}) + +-- Funktion um den Cache-Status zu prüfen +local function printCache() + local count = 0 + for k,v in pairs(cache) do + count = count + 1 + end + print("Cache entries: " .. count) +end + +-- Test 1: Objekte im Cache speichern +local function test1() + print("Test 1: Adding objects") + local obj1 = {name = "obj1"} + local obj2 = {name = "obj2"} + + cache[obj1] = "value1" + cache[obj2] = "value2" + printCache() -- Sollte 2 ausgeben +end + +-- Test 2: Referenzen löschen und GC ausführen +local function test2() + print("\nTest 2: After garbage collection") + collectgarbage() -- Force GC + printCache() -- Sollte 0 ausgeben, da keine Referenzen mehr existieren +end + +-- Test 3: Objekt mit starker Referenz +local function test3() + print("\nTest 3: Strong reference") + local strongRef = {name = "strong"} + cache[strongRef] = "value3" + printCache() -- Sollte 1 ausgeben + + print("Keeping strong reference...") + collectgarbage() + printCache() -- Sollte immer noch 1 ausgeben + + print("Removing strong reference...") + strongRef = nil + collectgarbage() + printCache() -- Sollte 0 ausgeben +end + +test1() +test2() +test3()