diff --git a/examples/TabControl.lua b/examples/TabControl.lua new file mode 100644 index 0000000..e1a7ab0 --- /dev/null +++ b/examples/TabControl.lua @@ -0,0 +1,35 @@ +local basalt = require("basalt") + +local frame = basalt.getMainFrame() + +local tabs = frame:addTabControl({x = 2, +y = 2, +width = 35, +height = 12, +headerBackground = colors.black, +foreground = colors.lightGray +}) + + +-- create three tabs; addTab now returns a proxy for adding elements into the new tab +local overview = tabs:newTab("Overview") +local editor = tabs:newTab("Editor") +local settings = tabs:newTab("Settings") + +-- Overview tab: add a label and a button +overview:addLabel({x = 2, y = 2, width = 46}):setText("Welcome to the demo") +overview:addButton({x = 2, y = 4, width = 12, height = 3}):setText("Click me") +:setBackground("{self.clicked and colors.green or colors.black}") +:setForeground("{self.clicked and colors.black or colors.lightGray}") + +-- Editor tab: textbox with some sample text +editor:addTextBox({x = 2, y = 2, width = 12, height = 8, background=colors.black, foreground=colors.white}):setText("Type here...\nLine 2\nLine 3") + +-- Settings tab: show some inputs +settings:addLabel({x = 2, y = 2, width = 20}):setText("Settings") +settings:addLabel({x = 2, y = 4, width = 20}):setText("Username:") +settings:addLabel({x = 2, y = 6, width = 20}):setText("Password:") +settings:addInput({x = 12, y = 4, width = 20, background=colors.black, foreground=colors.white}) +settings:addInput({x = 12, y = 6, width = 20, background=colors.black, foreground=colors.white}) + +basalt.run() \ No newline at end of file diff --git a/src/elements/ComboBox.lua b/src/elements/ComboBox.lua new file mode 100644 index 0000000..f1447e4 --- /dev/null +++ b/src/elements/ComboBox.lua @@ -0,0 +1,423 @@ +local VisualElement = require("elements/VisualElement") +local Dropdown = require("elements/Dropdown") +local tHex = require("libraries/colorHex") + +---@configDescription A ComboBox that combines dropdown selection with editable text input +---@configDefault false + +--- This is the ComboBox class. It extends the dropdown functionality with editable text input, +--- allowing users to either select from a list or type their own custom text. +--- @usage local ComboBox = main:addCombobox() +--- @usage ComboBox:setEditable(true) +--- @usage ComboBox:setItems({ +--- @usage {text = "Option 1"}, +--- @usage {text = "Option 2"}, +--- @usage {text = "Option 3"}, +--- @usage }) +--- @usage ComboBox:setText("Custom input...") +---@class ComboBox : Dropdown +local ComboBox = setmetatable({}, Dropdown) +ComboBox.__index = ComboBox + +---@property editable boolean true Whether the ComboBox allows text input +ComboBox.defineProperty(ComboBox, "editable", {default = true, type = "boolean", canTriggerRender = true}) +---@property text string "" The current text content of the ComboBox +ComboBox.defineProperty(ComboBox, "text", {default = "", type = "string", canTriggerRender = true}) +---@property cursorPos number 1 The current cursor position in the text +ComboBox.defineProperty(ComboBox, "cursorPos", {default = 1, type = "number"}) +---@property viewOffset number 0 The horizontal scroll offset for viewing long text +ComboBox.defineProperty(ComboBox, "viewOffset", {default = 0, type = "number", canTriggerRender = true}) +---@property placeholder string "..." Text to display when input is empty +ComboBox.defineProperty(ComboBox, "placeholder", {default = "...", type = "string"}) +---@property placeholderColor color gray Color of the placeholder text +ComboBox.defineProperty(ComboBox, "placeholderColor", {default = colors.gray, type = "color"}) +---@property focusedBackground color blue Background color when ComboBox is focused +ComboBox.defineProperty(ComboBox, "focusedBackground", {default = colors.blue, type = "color"}) +---@property focusedForeground color white Foreground color when ComboBox is focused +ComboBox.defineProperty(ComboBox, "focusedForeground", {default = colors.white, type = "color"}) +---@property autoComplete boolean false Whether to enable auto-complete filtering when typing +ComboBox.defineProperty(ComboBox, "autoComplete", {default = false, type = "boolean"}) +---@property manuallyOpened boolean false Whether the dropdown was manually opened (not by auto-complete) +ComboBox.defineProperty(ComboBox, "manuallyOpened", {default = false, type = "boolean"}) + +--- Creates a new ComboBox instance +--- @shortDescription Creates a new ComboBox instance +--- @return ComboBox self The newly created ComboBox instance +function ComboBox.new() + local self = setmetatable({}, ComboBox):__init() + self.class = ComboBox + self.set("width", 16) + self.set("height", 1) + self.set("z", 8) + return self +end + +--- @shortDescription Initializes the ComboBox instance +--- @param props table The properties to initialize the element with +--- @param basalt table The basalt instance +--- @return ComboBox self The initialized instance +--- @protected +function ComboBox:init(props, basalt) + Dropdown.init(self, props, basalt) + self.set("type", "ComboBox") + + self.set("cursorPos", 1) + self.set("viewOffset", 0) + + return self +end + +--- Sets the text content of the ComboBox +--- @shortDescription Sets the text content +--- @param text string The text to set +--- @return ComboBox self +function ComboBox:setText(text) + if text == nil then text = "" end + self.set("text", tostring(text)) + self.set("cursorPos", #self.get("text") + 1) + self:updateViewport() + return self +end + +--- Gets the current text content +--- @shortDescription Gets the text content +--- @return string text The current text +function ComboBox:getText() + return self.get("text") +end + +--- Sets whether the ComboBox is editable +--- @shortDescription Sets editable state +--- @param editable boolean Whether the ComboBox should be editable +--- @return ComboBox self +function ComboBox:setEditable(editable) + self.set("editable", editable) + return self +end + +--- Filters items based on current text for auto-complete +--- @shortDescription Filters items for auto-complete +--- @private +function ComboBox:getFilteredItems() + local allItems = self.get("items") or {} + local currentText = self.get("text"):lower() + + if not self.get("autoComplete") or #currentText == 0 then + return allItems + end + + local filteredItems = {} + for _, item in ipairs(allItems) do + local itemText = "" + if type(item) == "string" then + itemText = item:lower() + elseif type(item) == "table" and item.text then + itemText = item.text:lower() + end + + if itemText:find(currentText, 1, true) then + table.insert(filteredItems, item) + end + end + + return filteredItems +end + +--- Updates the dropdown with filtered items +--- @shortDescription Updates dropdown with filtered items +--- @private +function ComboBox:updateFilteredDropdown() + if not self.get("autoComplete") then return end + + local filteredItems = self:getFilteredItems() + local shouldOpen = #filteredItems > 0 and #self.get("text") > 0 + + if shouldOpen then + self.set("isOpen", true) + self.set("manuallyOpened", false) + local dropdownHeight = self.get("dropdownHeight") or 5 + local actualHeight = math.min(dropdownHeight, #filteredItems) + self.set("height", 1 + actualHeight) + else + self.set("isOpen", false) + self.set("manuallyOpened", false) + self.set("height", 1) + end + self:updateRender() +end +--- @shortDescription Updates the viewport +--- @private +function ComboBox:updateViewport() + local text = self.get("text") + local cursorPos = self.get("cursorPos") + local width = self.get("width") + local dropSymbol = self.get("dropSymbol") + + local textWidth = width - #dropSymbol + if textWidth < 1 then textWidth = 1 end + + local viewOffset = self.get("viewOffset") + + if cursorPos - viewOffset > textWidth then + viewOffset = cursorPos - textWidth + elseif cursorPos - 1 < viewOffset then + viewOffset = math.max(0, cursorPos - 1) + end + + self.set("viewOffset", viewOffset) +end + +--- Handles character input when editable +--- @shortDescription Handles character input +--- @param char string The character that was typed +function ComboBox:char(char) + if not self.get("editable") then return end + if not self.get("focused") then return end + + local text = self.get("text") + local cursorPos = self.get("cursorPos") + + local newText = text:sub(1, cursorPos - 1) .. char .. text:sub(cursorPos) + self.set("text", newText) + self.set("cursorPos", cursorPos + 1) + self:updateViewport() + + if self.get("autoComplete") then + self:updateFilteredDropdown() + else + self:updateRender() + end +end + +--- Handles key input when editable +--- @shortDescription Handles key input +--- @param key number The key code that was pressed +--- @param held boolean Whether the key is being held +function ComboBox:key(key, held) + if not self.get("editable") then return end + if not self.get("focused") then return end + + local text = self.get("text") + local cursorPos = self.get("cursorPos") + + if key == keys.left then + self.set("cursorPos", math.max(1, cursorPos - 1)) + self:updateViewport() + elseif key == keys.right then + self.set("cursorPos", math.min(#text + 1, cursorPos + 1)) + self:updateViewport() + elseif key == keys.backspace then + if cursorPos > 1 then + local newText = text:sub(1, cursorPos - 2) .. text:sub(cursorPos) + self.set("text", newText) + self.set("cursorPos", cursorPos - 1) + self:updateViewport() + + if self.get("autoComplete") then + self:updateFilteredDropdown() + else + self:updateRender() + end + end + elseif key == keys.delete then + if cursorPos <= #text then + local newText = text:sub(1, cursorPos - 1) .. text:sub(cursorPos + 1) + self.set("text", newText) + self:updateViewport() + + if self.get("autoComplete") then + self:updateFilteredDropdown() + else + self:updateRender() + end + end + elseif key == keys.home then + self.set("cursorPos", 1) + self:updateViewport() + elseif key == keys["end"] then + self.set("cursorPos", #text + 1) + self:updateViewport() + elseif key == keys.enter then + self.set("isOpen", not self.get("isOpen")) + self:updateRender() + end +end + +--- Handles mouse clicks +--- @shortDescription Handles mouse clicks +--- @param button number The mouse button (1 = left, 2 = right, 3 = middle) +--- @param x number The x coordinate of the click +--- @param y number The y coordinate of the click +--- @return boolean handled Whether the event was handled +--- @protected +function ComboBox: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 width = self.get("width") + local dropSymbol = self.get("dropSymbol") + + if relY == 1 then + if relX >= width - #dropSymbol + 1 and relX <= width then + + local isCurrentlyOpen = self.get("isOpen") + self.set("isOpen", not isCurrentlyOpen) + + if self.get("isOpen") then + local allItems = self.get("items") or {} + local dropdownHeight = self.get("dropdownHeight") or 5 + local actualHeight = math.min(dropdownHeight, #allItems) + self.set("height", 1 + actualHeight) + self.set("manuallyOpened", true) + else + self.set("height", 1) + self.set("manuallyOpened", false) + end + self:updateRender() + return true + end + + if relX <= width - #dropSymbol and self.get("editable") then + local text = self.get("text") + local viewOffset = self.get("viewOffset") + local maxPos = #text + 1 + local targetPos = math.min(maxPos, viewOffset + relX) + + self.set("cursorPos", targetPos) + self:updateRender() + return true + end + + return true + elseif self.get("isOpen") and relY > 1 and self.get("selectable") then + local itemIndex = (relY - 1) + self.get("offset") + local items = self.get("items") + + if itemIndex <= #items then + local item = items[itemIndex] + if type(item) == "string" then + item = {text = item} + items[itemIndex] = item + end + + if not self.get("multiSelection") then + for _, otherItem in ipairs(items) do + if type(otherItem) == "table" then + otherItem.selected = false + end + end + end + + item.selected = true + + if item.text then + self:setText(item.text) + end + self.set("isOpen", false) + self.set("height", 1) + self:updateRender() + + return true + end + end + + return false +end + +--- Renders the ComboBox +--- @shortDescription Renders the ComboBox +function ComboBox:render() + VisualElement.render(self) + + local text = self.get("text") + local width = self.get("width") + local dropSymbol = self.get("dropSymbol") + local isFocused = self.get("focused") + local isOpen = self.get("isOpen") + local viewOffset = self.get("viewOffset") + local placeholder = self.get("placeholder") + + local bg = isFocused and self.get("focusedBackground") or self.get("background") + local fg = isFocused and self.get("focusedForeground") or self.get("foreground") + + local displayText = text + local textWidth = width - #dropSymbol + + if #text == 0 and not isFocused and #placeholder > 0 then + displayText = placeholder + fg = self.get("placeholderColor") + end + + if #displayText > 0 then + displayText = displayText:sub(viewOffset + 1, viewOffset + textWidth) + end + + displayText = displayText .. string.rep(" ", textWidth - #displayText) + + local fullText = displayText .. (isOpen and "\31" or "\17") + + self:blit(1, 1, fullText, + string.rep(tHex[fg], width), + string.rep(tHex[bg], width)) + + if isFocused and self.get("editable") then + local cursorPos = self.get("cursorPos") + local cursorX = cursorPos - viewOffset + if cursorX >= 1 and cursorX <= textWidth then + self:setCursor(cursorX, 1, true, self.get("foreground")) + end + end + + if isOpen then + local items + if self.get("autoComplete") and not self.get("manuallyOpened") then + items = self:getFilteredItems() + else + items = self.get("items") + end + + local dropdownHeight = math.min(self.get("dropdownHeight"), #items) + if dropdownHeight > 0 then + local offset = self.get("offset") + + for i = 1, dropdownHeight do + local itemIndex = i + offset + if items[itemIndex] then + local item = items[itemIndex] + local itemText = item.text or "" + local isSelected = item.selected or false + + local itemBg = isSelected and self.get("selectedBackground") or self.get("background") + local itemFg = isSelected and self.get("selectedForeground") or self.get("foreground") + + if #itemText > width then + itemText = itemText:sub(1, width) + end + + itemText = itemText .. string.rep(" ", width - #itemText) + self:blit(1, i + 1, itemText, + string.rep(tHex[itemFg], width), + string.rep(tHex[itemBg], width)) + end + end + end + end +end + +--- Called when the ComboBox gains focus +--- @shortDescription Called when gaining focus +function ComboBox:focus() + Dropdown.focus(self) + -- Additional focus logic for input if needed +end + +--- Called when the ComboBox loses focus +--- @shortDescription Called when losing focus +function ComboBox:blur() + Dropdown.blur(self) + self.set("isOpen", false) + self.set("height", 1) + self:updateRender() +end + +return ComboBox diff --git a/src/elements/Switch.lua b/src/elements/Switch.lua index 5716cab..aa5e9e8 100644 --- a/src/elements/Switch.lua +++ b/src/elements/Switch.lua @@ -1,6 +1,7 @@ local elementManager = require("elementManager") local VisualElement = elementManager.getElement("VisualElement") ----@cofnigDescription The Switch is a standard Switch element with click handling and state management. +local tHex = require("libraries/colorHex") +---@configDescription The Switch is a standard Switch element with click handling and state management. --- The Switch is a standard Switch element with click handling and state management. ---@class Switch : VisualElement @@ -9,6 +10,14 @@ Switch.__index = Switch ---@property checked boolean Whether switch is checked Switch.defineProperty(Switch, "checked", {default = false, type = "boolean", canTriggerRender = true}) +---@property text string Text to display next to switch +Switch.defineProperty(Switch, "text", {default = "", type = "string", canTriggerRender = true}) +---@property autoSize boolean Whether to automatically size the element to fit switch and text +Switch.defineProperty(Switch, "autoSize", {default = false, type = "boolean"}) +---@property onBackground number Background color when ON +Switch.defineProperty(Switch, "onBackground", {default = colors.green, type = "number", canTriggerRender = true}) +---@property offBackground number Background color when OFF +Switch.defineProperty(Switch, "offBackground", {default = colors.red, type = "number", canTriggerRender = true}) Switch.defineEvent(Switch, "mouse_click") Switch.defineEvent(Switch, "mouse_up") @@ -22,6 +31,7 @@ function Switch.new() self.set("width", 2) self.set("height", 1) self.set("z", 5) + self.set("backgroundEnabled", true) return self end @@ -34,10 +44,38 @@ function Switch:init(props, basalt) self.set("type", "Switch") end +--- @shortDescription Handles mouse click events +--- @param button number The button that was clicked +--- @param x number The x position of the click +--- @param y number The y position of the click +--- @return boolean Whether the event was handled +--- @protected +function Switch:mouse_click(button, x, y) + if VisualElement.mouse_click(self, button, x, y) then + self.set("checked", not self.get("checked")) + return true + end + return false +end + --- @shortDescription Renders the Switch --- @protected function Switch:render() - VisualElement.render(self) + local checked = self.get("checked") + local text = self.get("text") + local switchWidth = self.get("width") + local switchHeight = self.get("height") + + local bgColor = checked and self.get("onBackground") or self.get("offBackground") + self:multiBlit(1, 1, switchWidth, switchHeight, " ", tHex[self.get("foreground")], tHex[bgColor]) + + local sliderSize = math.floor(switchWidth / 2) + local sliderStart = checked and (switchWidth - sliderSize + 1) or 1 + self:multiBlit(sliderStart, 1, sliderSize, switchHeight, " ", tHex[self.get("foreground")], tHex[self.get("background")]) + + if text ~= "" then + self:textFg(switchWidth + 2, 1, text, self.get("foreground")) + end end -return Switch \ No newline at end of file +return Switch diff --git a/src/elements/TabControl.lua b/src/elements/TabControl.lua new file mode 100644 index 0000000..5773cd0 --- /dev/null +++ b/src/elements/TabControl.lua @@ -0,0 +1,427 @@ +local elementManager = require("elementManager") +local VisualElement = require("elements/VisualElement") +local Container = elementManager.getElement("Container") +local tHex = require("libraries/colorHex") +local log = require("log") +---@configDescription A TabControl element that provides tabbed interface with multiple content areas. + +--- The TabControl is a container that provides tabbed interface functionality +---@class TabControl : Container +local TabControl = setmetatable({}, Container) +TabControl.__index = TabControl + +---@property activeTab number The currently active tab ID +TabControl.defineProperty(TabControl, "activeTab", {default = nil, type = "number", allowNil = true, canTriggerRender = true, setter = function(self, value) + return value +end}) +---@property tabHeight number Height of the tab header area +TabControl.defineProperty(TabControl, "tabHeight", {default = 1, type = "number", canTriggerRender = true}) +---@property tabs table List of tab definitions +TabControl.defineProperty(TabControl, "tabs", {default = {}, type = "table"}) + +---@property headerBackground color Background color for the tab header area +TabControl.defineProperty(TabControl, "headerBackground", {default = colors.gray, type = "color", canTriggerRender = true}) +---@property activeTabBackground color Background color for the active tab +TabControl.defineProperty(TabControl, "activeTabBackground", {default = colors.white, type = "color", canTriggerRender = true}) +---@property activeTabTextColor color Foreground color for the active tab text +TabControl.defineProperty(TabControl, "activeTabTextColor", {default = colors.black, type = "color", canTriggerRender = true}) + +TabControl.defineEvent(TabControl, "tabChanged") + +--- @shortDescription Creates a new TabControl instance +--- @return TabControl self The created instance +--- @private +function TabControl.new() + local self = setmetatable({}, TabControl):__init() + self.class = TabControl + self.set("width", 20) + self.set("height", 10) + self.set("z", 10) + return self +end + +--- @shortDescription Initializes the TabControl instance +--- @param props table The properties to initialize the element with +--- @param basalt table The basalt instance +--- @protected +function TabControl:init(props, basalt) + Container.init(self, props, basalt) + self.set("type", "TabControl") +end + +--- returns a proxy for adding elements to the tab +--- @shortDescription Creates a new tab handler proxy +--- @param title string The title of the tab +--- @return table tabHandler The tab handler proxy for adding elements to the new tab +function TabControl:newTab(title) + local tabs = self.get("tabs") or {} + local tabId = #tabs + 1 + + table.insert(tabs, { + id = tabId, + title = tostring(title or ("Tab " .. tabId)) + }) + + self.set("tabs", tabs) + + if not self.get("activeTab") then + self.set("activeTab", tabId) + end + self:updateTabVisibility() + + local tabControl = self + local proxy = {} + setmetatable(proxy, { + __index = function(_, key) + if type(key) == "string" and key:sub(1,3) == "add" and type(tabControl[key]) == "function" then + return function(_, ...) + local el = tabControl[key](tabControl, ...) + if el then + el._tabId = tabId + tabControl.set("childrenSorted", false) + tabControl.set("childrenEventsSorted", false) + tabControl:updateRender() + end + return el + end + end + local v = tabControl[key] + if type(v) == "function" then + return function(_, ...) + return v(tabControl, ...) + end + end + return v + end + }) + + return proxy +end + +--- @shortDescription Sets an element to belong to a specific tab +--- @param element table The element to assign to a tab +--- @param tabId number The ID of the tab to assign the element to +--- @return TabControl self For method chaining +function TabControl:setTab(element, tabId) + element._tabId = tabId + self:updateTabVisibility() + return self +end + +--- @shortDescription Adds an element to the TabControl and assigns it to the active tab +--- @param elementType string The type of element to add +--- @param tabId number Optional tab ID, defaults to active tab +--- @return table element The created element +function TabControl:addElement(elementType, tabId) + local element = Container.addElement(self, elementType) + local targetTab = tabId or self.get("activeTab") + if targetTab then + element._tabId = targetTab + self:updateTabVisibility() + end + return element +end + +--- @shortDescription Overrides Container's addChild to assign new elements to tab 1 by default +--- @param child table The child element to add +--- @return Container self For method chaining +--- @protected +function TabControl:addChild(child) + Container.addChild(self, child) + if not child._tabId then + local tabs = self.get("tabs") or {} + if #tabs > 0 then + child._tabId = 1 + self:updateTabVisibility() + end + end + return self +end + +--- @shortDescription Updates visibility of tab containers +--- @private +function TabControl:updateTabVisibility() + self.set("childrenSorted", false) + self.set("childrenEventsSorted", false) +end + +--- @shortDescription Sets the active tab +--- @param tabId number The ID of the tab to activate +function TabControl:setActiveTab(tabId) + local oldTab = self.get("activeTab") + if oldTab == tabId then return self end + self.set("activeTab", tabId) + self:updateTabVisibility() + self:dispatchEvent("tabChanged", tabId, oldTab) + return self +end + +--- @shortDescription Checks if a child should be visible (overrides Container) +--- @param child table The child element to check +--- @return boolean Whether the child should be visible +--- @protected +function TabControl:isChildVisible(child) + if not Container.isChildVisible(self, child) then + return false + end + if child._tabId then + return child._tabId == self.get("activeTab") + end + return true +end + +--- @shortDescription Gets the content area Y offset (below tab headers) +--- @return number yOffset The Y offset for content +--- @protected +function TabControl:getContentYOffset() + local metrics = self:_getHeaderMetrics() + return metrics.headerHeight +end + +function TabControl:_getHeaderMetrics() + local tabs = self.get("tabs") or {} + local width = self.get("width") or 1 + local minTabH = self.get("tabHeight") or 1 + + local positions = {} + local line = 1 + local cursorX = 1 + for i, tab in ipairs(tabs) do + local tabWidth = #tab.title + 2 + if tabWidth > width then + tabWidth = width + end + if cursorX + tabWidth - 1 > width then + line = line + 1 + cursorX = 1 + end + table.insert(positions, {id = tab.id, title = tab.title, line = line, x1 = cursorX, x2 = cursorX + tabWidth - 1, width = tabWidth}) + cursorX = cursorX + tabWidth + end + + local computedLines = line + local headerHeight = math.max(minTabH, computedLines) + return {headerHeight = headerHeight, lines = computedLines, positions = positions} +end + +--- @shortDescription Handles mouse click events for tab switching +--- @param button number The button that was clicked +--- @param x number The x position of the click (global) +--- @param y number The y position of the click (global) +--- @return boolean Whether the event was handled +--- @protected +function TabControl:mouse_click(button, x, y) + if not VisualElement.mouse_click(self, button, x, y) then + return false + end + + local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) + local metrics = self:_getHeaderMetrics() + if baseRelY <= metrics.headerHeight then + if #metrics.positions == 0 then return true end + for _, pos in ipairs(metrics.positions) do + if pos.line == baseRelY and baseRelX >= pos.x1 and baseRelX <= pos.x2 then + self:setActiveTab(pos.id) + self.set("focusedChild", nil) + return true + end + end + return true + end + return Container.mouse_click(self, button, x, y) +end + +function TabControl:getRelativePosition(x, y) + local headerH = self:_getHeaderMetrics().headerHeight + if x == nil or y == nil then + return VisualElement.getRelativePosition(self) + else + local rx, ry = VisualElement.getRelativePosition(self, x, y) + return rx, ry - headerH + end +end + +function TabControl:multiBlit(x, y, width, height, text, fg, bg) + local headerH = self:_getHeaderMetrics().headerHeight + return Container.multiBlit(self, x, (y or 1) + headerH, width, height, text, fg, bg) +end + +function TabControl:textFg(x, y, text, fg) + local headerH = self:_getHeaderMetrics().headerHeight + return Container.textFg(self, x, (y or 1) + headerH, text, fg) +end + +function TabControl:textBg(x, y, text, bg) + local headerH = self:_getHeaderMetrics().headerHeight + return Container.textBg(self, x, (y or 1) + headerH, text, bg) +end + +function TabControl:drawText(x, y, text) + local headerH = self:_getHeaderMetrics().headerHeight + return Container.drawText(self, x, (y or 1) + headerH, text) +end + +function TabControl:drawFg(x, y, fg) + local headerH = self:_getHeaderMetrics().headerHeight + return Container.drawFg(self, x, (y or 1) + headerH, fg) +end + +function TabControl:drawBg(x, y, bg) + local headerH = self:_getHeaderMetrics().headerHeight + return Container.drawBg(self, x, (y or 1) + headerH, bg) +end + +function TabControl:blit(x, y, text, fg, bg) + local headerH = self:_getHeaderMetrics().headerHeight + return Container.blit(self, x, (y or 1) + headerH, text, fg, bg) +end + +function TabControl:mouse_up(button, x, y) + if not VisualElement.mouse_up(self, button, x, y) then + return false + end + local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) + local headerH = self:_getHeaderMetrics().headerHeight + if baseRelY <= headerH then + return true + end + return Container.mouse_up(self, button, x, y) +end + +function TabControl:mouse_release(button, x, y) + VisualElement.mouse_release(self, button, x, y) + local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) + local headerH = self:_getHeaderMetrics().headerHeight + if baseRelY <= headerH then + return + end + return Container.mouse_release(self, button, x, y) +end + +function TabControl:mouse_move(_, x, y) + if VisualElement.mouse_move(self, _, x, y) then + local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) + local headerH = self:_getHeaderMetrics().headerHeight + if baseRelY <= headerH then + return true + end + local args = {self:getRelativePosition(x, y)} + local success, child = self:callChildrenEvent(true, "mouse_move", table.unpack(args)) + if success then + return true + end + end + return false +end + +function TabControl:mouse_drag(button, x, y) + if VisualElement.mouse_drag(self, button, x, y) then + local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) + local headerH = self:_getHeaderMetrics().headerHeight + if baseRelY <= headerH then + return true + end + return Container.mouse_drag(self, button, x, y) + end + return false +end + +function TabControl:mouse_scroll(direction, x, y) + if VisualElement.mouse_scroll(self, direction, x, y) then + local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) + local headerH = self:_getHeaderMetrics().headerHeight + if baseRelY <= headerH then + return true + end + return Container.mouse_scroll(self, direction, x, y) + end + return false +end + +--- @shortDescription Sets the cursor position; accounts for tab header offset when delegating to parent +function TabControl:setCursor(x, y, blink, color) + local tabH = self:_getHeaderMetrics().headerHeight + if self.parent then + local xPos, yPos = self:calculatePosition() + local targetX = x + xPos - 1 + local targetY = y + yPos - 1 + tabH + + if(targetX < 1) or (targetX > self.parent.get("width")) or + (targetY < 1) or (targetY > self.parent.get("height")) then + return self.parent:setCursor(targetX, targetY, false) + end + return self.parent:setCursor(targetX, targetY, blink, color) + end + return self +end + +--- @shortDescription Renders the TabControl (header + children) +--- @protected +function TabControl:render() + VisualElement.render(self) + + local width = self.get("width") + + local metrics = self:_getHeaderMetrics() + local headerH = metrics.headerHeight or 1 + VisualElement.multiBlit(self, 1, 1, width, headerH, " ", tHex[self.get("foreground")], tHex[self.get("headerBackground")]) + + local activeTab = self.get("activeTab") + for _, pos in ipairs(metrics.positions) do + local bgColor = (pos.id == activeTab) and self.get("activeTabBackground") or self.get("headerBackground") + local fgColor = (pos.id == activeTab) and self.get("activeTabTextColor") or self.get("foreground") + VisualElement.multiBlit(self, pos.x1, pos.line, pos.width, 1, " ", tHex[self.get("foreground")], tHex[bgColor]) + VisualElement.textFg(self, pos.x1 + 1, pos.line, pos.title, fgColor) + end + + if not self.get("childrenSorted") then + self:sortChildren() + end + if not self.get("childrenEventsSorted") then + for eventName in pairs(self._values.childrenEvents or {}) do + self:sortChildrenEvents(eventName) + end + end + + for _, child in ipairs(self.get("visibleChildren") or {}) do + if child == self then error("CIRCULAR REFERENCE DETECTED!") return end + child:render() + child:postRender() + end +end + +--- @protected +function TabControl:sortChildrenEvents(eventName) + local childrenEvents = self._values.childrenEvents and self._values.childrenEvents[eventName] + if childrenEvents then + local visibleChildrenEvents = {} + for _, child in ipairs(childrenEvents) do + if self:isChildVisible(child) then + table.insert(visibleChildrenEvents, child) + end + end + + for i = 2, #visibleChildrenEvents do + local current = visibleChildrenEvents[i] + local currentZ = current.get("z") + local j = i - 1 + while j > 0 do + local compare = visibleChildrenEvents[j].get("z") + if compare > currentZ then + visibleChildrenEvents[j + 1] = visibleChildrenEvents[j] + j = j - 1 + else + break + end + end + visibleChildrenEvents[j + 1] = current + end + + self._values.visibleChildrenEvents = self._values.visibleChildrenEvents or {} + self._values.visibleChildrenEvents[eventName] = visibleChildrenEvents + end + self.set("childrenEventsSorted", true) + return self +end + +return TabControl diff --git a/src/elements/TextBox.lua b/src/elements/TextBox.lua index 01b5e87..e2e4f21 100644 --- a/src/elements/TextBox.lua +++ b/src/elements/TextBox.lua @@ -57,13 +57,35 @@ end --- Adds a new syntax highlighting pattern --- @shortDescription Adds a new syntax highlighting pattern --- @param pattern string The regex pattern to match ---- @param color colors The color to apply +--- @param color number The color to apply --- @return TextBox self The TextBox instance function TextBox:addSyntaxPattern(pattern, color) table.insert(self.get("syntaxPatterns"), {pattern = pattern, color = color}) return self end +--- Removes a syntax pattern by index (1-based) +--- @param index number The index of the pattern to remove +--- @return TextBox self +function TextBox:removeSyntaxPattern(index) + local patterns = self.get("syntaxPatterns") or {} + if type(index) ~= "number" then return self end + if index >= 1 and index <= #patterns then + table.remove(patterns, index) + self.set("syntaxPatterns", patterns) + self:updateRender() + end + return self +end + +--- Clears all syntax highlighting patterns +--- @return TextBox self +function TextBox:clearSyntaxPatterns() + self.set("syntaxPatterns", {}) + self:updateRender() + return self +end + local function insertChar(self, char) local lines = self.get("lines") local cursorX = self.get("cursorX") @@ -223,12 +245,15 @@ function TextBox:mouse_click(button, x, y) local scrollX = self.get("scrollX") local scrollY = self.get("scrollY") - local targetY = relY + scrollY - local lines = self.get("lines") + local targetY = (relY or 0) + (scrollY or 0) + local lines = self.get("lines") or {} - if targetY <= #lines then + -- clamp and validate before indexing to avoid nil errors + if targetY < 1 then targetY = 1 end + if targetY <= #lines and lines[targetY] ~= nil then self.set("cursorY", targetY) - self.set("cursorX", math.min(relX + scrollX, #lines[targetY] + 1)) + local lineLen = #tostring(lines[targetY]) + self.set("cursorX", math.min((relX or 1) + (scrollX or 0), lineLen + 1)) end self:updateRender() return true @@ -286,8 +311,15 @@ local function applySyntaxHighlighting(self, line) while true do local s, e = text:find(syntax.pattern, start) if not s then break end - colors = colors:sub(1, s-1) .. string.rep(tHex[syntax.color], e-s+1) .. colors:sub(e+1) - start = e + 1 + local matchLen = e - s + 1 + if matchLen <= 0 then + -- avoid infinite loops for zero-length matches: color one char and advance + colors = colors:sub(1, s-1) .. string.rep(tHex[syntax.color], 1) .. colors:sub(s+1) + start = s + 1 + else + colors = colors:sub(1, s-1) .. string.rep(tHex[syntax.color], matchLen) .. colors:sub(e+1) + start = e + 1 + end end end @@ -310,13 +342,18 @@ function TextBox:render() for y = 1, height do local lineNum = y + scrollY local line = lines[lineNum] or "" - local visibleText = line:sub(scrollX + 1, scrollX + width) - if #visibleText < width then - visibleText = visibleText .. string.rep(" ", width - #visibleText) + + local fullText, fullColors = applySyntaxHighlighting(self, line) + local text = fullText:sub(scrollX + 1, scrollX + width) + local colors = fullColors:sub(scrollX + 1, scrollX + width) + + local padLen = width - #text + if padLen > 0 then + text = text .. string.rep(" ", padLen) + colors = colors .. string.rep(tHex[self.get("foreground")], padLen) end - local text, colors = applySyntaxHighlighting(self, visibleText) - self:blit(1, y, text, colors, string.rep(bg, #visibleText)) + self:blit(1, y, text, colors, string.rep(bg, #text)) end if self.get("focused") then