From 52e52c4c76336b241b58fdfd2d49d4a2faa30881 Mon Sep 17 00:00:00 2001 From: Robert Jelic <36573031+NoryiE@users.noreply.github.com> Date: Tue, 7 Oct 2025 00:15:44 +0200 Subject: [PATCH] TabControl scroll feature --- src/elements/TabControl.lua | 161 +++++++++++++++++++++++++++++------- 1 file changed, 133 insertions(+), 28 deletions(-) diff --git a/src/elements/TabControl.lua b/src/elements/TabControl.lua index 7d91808..92486ec 100644 --- a/src/elements/TabControl.lua +++ b/src/elements/TabControl.lua @@ -25,9 +25,14 @@ TabControl.defineProperty(TabControl, "headerBackground", {default = colors.gray 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}) +---@property scrollableTab boolean Enables scroll mode for tabs if they exceed width +TabControl.defineProperty(TabControl, "scrollableTab", {default = false, type = "boolean", canTriggerRender = true}) +---@property tabScrollOffset number Current scroll offset for tabs in scrollable mode +TabControl.defineProperty(TabControl, "tabScrollOffset", {default = 0, type = "number", canTriggerRender = true}) TabControl.defineEvent(TabControl, "mouse_click") TabControl.defineEvent(TabControl, "mouse_up") +TabControl.defineEvent(TabControl, "mouse_scroll") --- @shortDescription Creates a new TabControl instance --- @return TabControl self The created instance @@ -183,28 +188,95 @@ function TabControl:_getHeaderMetrics() local tabs = self.get("tabs") or {} local width = self.get("width") or 1 local minTabH = self.get("tabHeight") or 1 + local scrollable = self.get("scrollableTab") 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} + if scrollable then + local scrollOffset = self.get("tabScrollOffset") or 0 + local actualX = 1 + local totalWidth = 0 + + for i, tab in ipairs(tabs) do + local tabWidth = #tab.title + 2 + if tabWidth > width then + tabWidth = width + end + + local visualX = actualX - scrollOffset + local startClip = 0 + local endClip = 0 + + if visualX < 1 then + startClip = 1 - visualX + end + + if visualX + tabWidth - 1 > width then + endClip = (visualX + tabWidth - 1) - width + end + + if visualX + tabWidth > 1 and visualX <= width then + local displayX = math.max(1, visualX) + local displayWidth = tabWidth - startClip - endClip + + table.insert(positions, { + id = tab.id, + title = tab.title, + line = 1, + x1 = displayX, + x2 = displayX + displayWidth - 1, + width = tabWidth, + displayWidth = displayWidth, + actualX = actualX, + startClip = startClip, + endClip = endClip + }) + end + + actualX = actualX + tabWidth + end + + totalWidth = actualX - 1 + + return { + headerHeight = 1, + lines = 1, + positions = positions, + totalWidth = totalWidth, + scrollOffset = scrollOffset, + maxScroll = math.max(0, totalWidth - width) + } + else + 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 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) @@ -327,18 +399,39 @@ function TabControl:mouse_drag(button, x, y) return false end +---Scrolls the tab header left or right if scrollableTab is enabled +--- @shortDescription Scrolls the tab header left or right if scrollableTab is enabled +--- @param direction number -1 to scroll left, 1 to scroll right +--- @return TabControl self For method chaining +function TabControl:scrollTabs(direction) + if not self.get("scrollableTab") then return self end + + local metrics = self:_getHeaderMetrics() + local currentOffset = self.get("tabScrollOffset") or 0 + local maxScroll = metrics.maxScroll or 0 + + local newOffset = currentOffset + (direction * 5) + newOffset = math.max(0, math.min(maxScroll, newOffset)) + + self.set("tabScrollOffset", newOffset) + return self +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 + local headerH = self:_getHeaderMetrics().headerHeight + + if self.get("scrollableTab") and y == self.get("y") then + self:scrollTabs(direction) 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 @@ -360,19 +453,31 @@ end --- @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")]) + 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) + 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.displayWidth or (pos.x2 - pos.x1 + 1), 1, " ", tHex[self.get("foreground")], tHex[bgColor]) + + local displayTitle = pos.title + local textStartInTitle = 1 + (pos.startClip or 0) + local textLength = #pos.title - (pos.startClip or 0) - (pos.endClip or 0) + + if textLength > 0 then + displayTitle = pos.title:sub(textStartInTitle, textStartInTitle + textLength - 1) + local textX = pos.x1 + if (pos.startClip or 0) == 0 then + textX = textX + 1 + end + VisualElement.textFg(self, textX, pos.line, displayTitle, fgColor) + end end if not self.get("childrenSorted") then @@ -425,4 +530,4 @@ function TabControl:sortChildrenEvents(eventName) return self end -return TabControl +return TabControl \ No newline at end of file