diff --git a/src/elements/SideNav.lua b/src/elements/SideNav.lua new file mode 100644 index 0000000..b748cda --- /dev/null +++ b/src/elements/SideNav.lua @@ -0,0 +1,592 @@ +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 SideNav element that provides sidebar navigation with multiple content areas. + +--- The SideNav is a container that provides sidebar navigation functionality +---@class SideNav : Container +local SideNav = setmetatable({}, Container) +SideNav.__index = SideNav + +---@property activeTab number nil The currently active navigation item ID +SideNav.defineProperty(SideNav, "activeTab", {default = nil, type = "number", allowNil = true, canTriggerRender = true, setter = function(self, value) + return value +end}) +---@property sidebarWidth number 12 Width of the sidebar navigation area +SideNav.defineProperty(SideNav, "sidebarWidth", {default = 12, type = "number", canTriggerRender = true}) +---@property tabs table {} List of navigation item definitions +SideNav.defineProperty(SideNav, "tabs", {default = {}, type = "table"}) + +---@property sidebarBackground color gray Background color for the sidebar area +SideNav.defineProperty(SideNav, "sidebarBackground", {default = colors.gray, type = "color", canTriggerRender = true}) +---@property activeTabBackground color white Background color for the active navigation item +SideNav.defineProperty(SideNav, "activeTabBackground", {default = colors.white, type = "color", canTriggerRender = true}) +---@property activeTabTextColor color black Foreground color for the active navigation item text +SideNav.defineProperty(SideNav, "activeTabTextColor", {default = colors.black, type = "color", canTriggerRender = true}) +---@property sidebarScrollOffset number 0 Current scroll offset for navigation items in scrollable mode +SideNav.defineProperty(SideNav, "sidebarScrollOffset", {default = 0, type = "number", canTriggerRender = true}) +---@property sidebarPosition string "left" Position of the sidebar ("left" or "right") +SideNav.defineProperty(SideNav, "sidebarPosition", {default = "left", type = "string", canTriggerRender = true}) + +SideNav.defineEvent(SideNav, "mouse_click") +SideNav.defineEvent(SideNav, "mouse_up") +SideNav.defineEvent(SideNav, "mouse_scroll") + +--- @shortDescription Creates a new SideNav instance +--- @return SideNav self The created instance +--- @private +function SideNav.new() + local self = setmetatable({}, SideNav):__init() + self.class = SideNav + self.set("width", 30) + self.set("height", 15) + self.set("z", 10) + return self +end + +--- @shortDescription Initializes the SideNav instance +--- @param props table The properties to initialize the element with +--- @param basalt table The basalt instance +--- @protected +function SideNav:init(props, basalt) + Container.init(self, props, basalt) + self.set("type", "SideNav") +end + +--- returns a proxy for adding elements to the navigation item +--- @shortDescription Creates a new navigation item handler proxy +--- @param title string The title of the navigation item +--- @return table tabHandler The navigation item handler proxy for adding elements +function SideNav:newTab(title) + local tabs = self.get("tabs") or {} + local tabId = #tabs + 1 + + table.insert(tabs, { + id = tabId, + title = tostring(title or ("Item " .. tabId)) + }) + + self.set("tabs", tabs) + + if not self.get("activeTab") then + self.set("activeTab", tabId) + end + self:updateTabVisibility() + + local sideNav = self + local proxy = {} + setmetatable(proxy, { + __index = function(_, key) + if type(key) == "string" and key:sub(1,3) == "add" and type(sideNav[key]) == "function" then + return function(_, ...) + local el = sideNav[key](sideNav, ...) + if el then + el._tabId = tabId + sideNav.set("childrenSorted", false) + sideNav.set("childrenEventsSorted", false) + sideNav:updateRender() + end + return el + end + end + local v = sideNav[key] + if type(v) == "function" then + return function(_, ...) + return v(sideNav, ...) + end + end + return v + end + }) + + return proxy +end +SideNav.addTab = SideNav.newTab + +--- @shortDescription Sets an element to belong to a specific navigation item +--- @param element table The element to assign to a navigation item +--- @param tabId number The ID of the navigation item to assign the element to +--- @return SideNav self For method chaining +function SideNav:setTab(element, tabId) + element._tabId = tabId + self:updateTabVisibility() + return self +end + +--- @shortDescription Adds an element to the SideNav and assigns it to the active navigation item +--- @param elementType string The type of element to add +--- @param tabId number Optional navigation item ID, defaults to active item +--- @return table element The created element +function SideNav: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 item 1 by default +--- @param child table The child element to add +--- @return Container self For method chaining +--- @protected +function SideNav: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 navigation item containers +--- @private +function SideNav:updateTabVisibility() + self.set("childrenSorted", false) + self.set("childrenEventsSorted", false) +end + +--- @shortDescription Sets the active navigation item +--- @param tabId number The ID of the navigation item to activate +function SideNav: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 SideNav: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 X offset (right of sidebar) +--- @return number xOffset The X offset for content +--- @protected +function SideNav:getContentXOffset() + local metrics = self:_getSidebarMetrics() + return metrics.sidebarWidth +end + +function SideNav:_getSidebarMetrics() + local tabs = self.get("tabs") or {} + local height = self.get("height") or 1 + local sidebarWidth = self.get("sidebarWidth") or 12 + local scrollOffset = self.get("sidebarScrollOffset") or 0 + local sidebarPos = self.get("sidebarPosition") or "left" + + local positions = {} + local actualY = 1 + local totalHeight = #tabs + + for i, tab in ipairs(tabs) do + local itemHeight = 1 + + local visualY = actualY - scrollOffset + local startClip = 0 + local endClip = 0 + + if visualY < 1 then + startClip = 1 - visualY + end + + if visualY + itemHeight - 1 > height then + endClip = (visualY + itemHeight - 1) - height + end + + if visualY + itemHeight > 1 and visualY <= height then + local displayY = math.max(1, visualY) + local displayHeight = itemHeight - startClip - endClip + + table.insert(positions, { + id = tab.id, + title = tab.title, + y1 = displayY, + y2 = displayY + displayHeight - 1, + height = itemHeight, + displayHeight = displayHeight, + actualY = actualY, + startClip = startClip, + endClip = endClip + }) + end + + actualY = actualY + itemHeight + end + + return { + sidebarWidth = sidebarWidth, + sidebarPosition = sidebarPos, + positions = positions, + totalHeight = totalHeight, + scrollOffset = scrollOffset, + maxScroll = math.max(0, totalHeight - height) + } +end + +--- @shortDescription Handles mouse click events for navigation item 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 SideNav: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:_getSidebarMetrics() + local width = self.get("width") or 1 + + local inSidebar = false + if metrics.sidebarPosition == "right" then + inSidebar = baseRelX > (width - metrics.sidebarWidth) + else + inSidebar = baseRelX <= metrics.sidebarWidth + end + + if inSidebar then + if #metrics.positions == 0 then return true end + for _, pos in ipairs(metrics.positions) do + if baseRelY >= pos.y1 and baseRelY <= pos.y2 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 SideNav:getRelativePosition(x, y) + local metrics = self:_getSidebarMetrics() + local width = self.get("width") or 1 + + if x == nil or y == nil then + return VisualElement.getRelativePosition(self) + else + local rx, ry = VisualElement.getRelativePosition(self, x, y) + if metrics.sidebarPosition == "right" then + return rx, ry + else + return rx - metrics.sidebarWidth, ry + end + end +end + +function SideNav:multiBlit(x, y, width, height, text, fg, bg) + local metrics = self:_getSidebarMetrics() + if metrics.sidebarPosition == "right" then + return Container.multiBlit(self, x, y, width, height, text, fg, bg) + else + return Container.multiBlit(self, (x or 1) + metrics.sidebarWidth, y, width, height, text, fg, bg) + end +end + +function SideNav:textFg(x, y, text, fg) + local metrics = self:_getSidebarMetrics() + if metrics.sidebarPosition == "right" then + return Container.textFg(self, x, y, text, fg) + else + return Container.textFg(self, (x or 1) + metrics.sidebarWidth, y, text, fg) + end +end + +function SideNav:textBg(x, y, text, bg) + local metrics = self:_getSidebarMetrics() + if metrics.sidebarPosition == "right" then + return Container.textBg(self, x, y, text, bg) + else + return Container.textBg(self, (x or 1) + metrics.sidebarWidth, y, text, bg) + end +end + +function SideNav:drawText(x, y, text) + local metrics = self:_getSidebarMetrics() + if metrics.sidebarPosition == "right" then + return Container.drawText(self, x, y, text) + else + return Container.drawText(self, (x or 1) + metrics.sidebarWidth, y, text) + end +end + +function SideNav:drawFg(x, y, fg) + local metrics = self:_getSidebarMetrics() + if metrics.sidebarPosition == "right" then + return Container.drawFg(self, x, y, fg) + else + return Container.drawFg(self, (x or 1) + metrics.sidebarWidth, y, fg) + end +end + +function SideNav:drawBg(x, y, bg) + local metrics = self:_getSidebarMetrics() + if metrics.sidebarPosition == "right" then + return Container.drawBg(self, x, y, bg) + else + return Container.drawBg(self, (x or 1) + metrics.sidebarWidth, y, bg) + end +end + +function SideNav:blit(x, y, text, fg, bg) + local metrics = self:_getSidebarMetrics() + if metrics.sidebarPosition == "right" then + return Container.blit(self, x, y, text, fg, bg) + else + return Container.blit(self, (x or 1) + metrics.sidebarWidth, y, text, fg, bg) + end +end + +function SideNav: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 metrics = self:_getSidebarMetrics() + local width = self.get("width") or 1 + + local inSidebar = false + if metrics.sidebarPosition == "right" then + inSidebar = baseRelX > (width - metrics.sidebarWidth) + else + inSidebar = baseRelX <= metrics.sidebarWidth + end + + if inSidebar then + return true + end + return Container.mouse_up(self, button, x, y) +end + +function SideNav:mouse_release(button, x, y) + VisualElement.mouse_release(self, button, x, y) + local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) + local metrics = self:_getSidebarMetrics() + local width = self.get("width") or 1 + + local inSidebar = false + if metrics.sidebarPosition == "right" then + inSidebar = baseRelX > (width - metrics.sidebarWidth) + else + inSidebar = baseRelX <= metrics.sidebarWidth + end + + if inSidebar then + return + end + return Container.mouse_release(self, button, x, y) +end + +function SideNav:mouse_move(_, x, y) + if VisualElement.mouse_move(self, _, x, y) then + local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) + local metrics = self:_getSidebarMetrics() + local width = self.get("width") or 1 + + local inSidebar = false + if metrics.sidebarPosition == "right" then + inSidebar = baseRelX > (width - metrics.sidebarWidth) + else + inSidebar = baseRelX <= metrics.sidebarWidth + end + + if inSidebar 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 SideNav:mouse_drag(button, x, y) + if VisualElement.mouse_drag(self, button, x, y) then + local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) + local metrics = self:_getSidebarMetrics() + local width = self.get("width") or 1 + + local inSidebar = false + if metrics.sidebarPosition == "right" then + inSidebar = baseRelX > (width - metrics.sidebarWidth) + else + inSidebar = baseRelX <= metrics.sidebarWidth + end + + if inSidebar then + return true + end + return Container.mouse_drag(self, button, x, y) + end + return false +end + +---Scrolls the sidebar up or down +--- @shortDescription Scrolls the sidebar up or down +--- @param direction number -1 to scroll up, 1 to scroll down +--- @return SideNav self For method chaining +function SideNav:scrollSidebar(direction) + local metrics = self:_getSidebarMetrics() + local currentOffset = self.get("sidebarScrollOffset") or 0 + local maxScroll = metrics.maxScroll or 0 + + local newOffset = currentOffset + (direction * 2) + newOffset = math.max(0, math.min(maxScroll, newOffset)) + + self.set("sidebarScrollOffset", newOffset) + return self +end + +function SideNav:mouse_scroll(direction, x, y) + if VisualElement.mouse_scroll(self, direction, x, y) then + local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) + local metrics = self:_getSidebarMetrics() + local width = self.get("width") or 1 + + local inSidebar = false + if metrics.sidebarPosition == "right" then + inSidebar = baseRelX > (width - metrics.sidebarWidth) + else + inSidebar = baseRelX <= metrics.sidebarWidth + end + + if inSidebar then + self:scrollSidebar(direction) + return true + end + + return Container.mouse_scroll(self, direction, x, y) + end + return false +end + +--- @shortDescription Sets the cursor position; accounts for sidebar offset when delegating to parent +function SideNav:setCursor(x, y, blink, color) + local metrics = self:_getSidebarMetrics() + if self.parent then + local xPos, yPos = self:calculatePosition() + local targetX, targetY + + if metrics.sidebarPosition == "right" then + targetX = x + xPos - 1 + targetY = y + yPos - 1 + else + targetX = x + xPos - 1 + metrics.sidebarWidth + targetY = y + yPos - 1 + end + + 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 SideNav (sidebar + children) +--- @protected +function SideNav:render() + VisualElement.render(self) + local height = self.get("height") + local metrics = self:_getSidebarMetrics() + local sidebarW = metrics.sidebarWidth or 12 + + -- Render sidebar background + for y = 1, height do + VisualElement.multiBlit(self, 1, y, sidebarW, 1, " ", tHex[self.get("foreground")], tHex[self.get("sidebarBackground")]) + end + + local activeTab = self.get("activeTab") + + -- Render navigation items + for _, pos in ipairs(metrics.positions) do + local bgColor = (pos.id == activeTab) and self.get("activeTabBackground") or self.get("sidebarBackground") + local fgColor = (pos.id == activeTab) and self.get("activeTabTextColor") or self.get("foreground") + + local itemHeight = pos.displayHeight or (pos.y2 - pos.y1 + 1) + for dy = 0, itemHeight - 1 do + VisualElement.multiBlit(self, 1, pos.y1 + dy, sidebarW, 1, " ", tHex[self.get("foreground")], tHex[bgColor]) + end + + -- Render title text (truncate if necessary) + local displayTitle = pos.title + if #displayTitle > sidebarW - 2 then + displayTitle = displayTitle:sub(1, sidebarW - 2) + end + + VisualElement.textFg(self, 2, pos.y1, displayTitle, 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 SideNav: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 SideNav \ No newline at end of file diff --git a/src/elements/TabControl.lua b/src/elements/TabControl.lua index 1c95a2c..9bfb4ef 100644 --- a/src/elements/TabControl.lua +++ b/src/elements/TabControl.lua @@ -103,6 +103,7 @@ function TabControl:newTab(title) return proxy end +TabControl.addTab = TabControl.newTab --- @shortDescription Sets an element to belong to a specific tab --- @param element table The element to assign to a tab