From dcbc5e965fd9dddd8ddb3197806068e1199384e2 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 2 Nov 2025 13:24:55 -0500 Subject: [PATCH] scrollbars fixed --- .gitignore | 2 + modules/Element.lua | 115 +++- .../__tests__/30_scrollbar_features_tests.lua | 552 ++++++++++++++++++ 3 files changed, 643 insertions(+), 26 deletions(-) create mode 100644 testing/__tests__/30_scrollbar_features_tests.lua diff --git a/.gitignore b/.gitignore index 2ad7cef..bae1536 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ Cartographer.lua OverlayStats.lua +lume.lua +lurker.lua themes/metal/ themes/space/ .DS_STORE diff --git a/modules/Element.lua b/modules/Element.lua index ccf38a6..dda320b 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -171,6 +171,7 @@ Public API methods to access internal state: ---@field objectPosition string? -- Image position like "center center", "top left", "50% 50%" (default: "center center") ---@field imageOpacity number? -- Image opacity 0-1 (default: 1, combines with element opacity) ---@field _loadedImage love.Image? -- Internal: cached loaded image +---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control) local Element = {} Element.__index = Element @@ -1126,6 +1127,22 @@ function Element.new(props) self.scrollbarRadius = props.scrollbarRadius or 6 self.scrollbarPadding = props.scrollbarPadding or 2 self.scrollSpeed = props.scrollSpeed or 20 + + -- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean} + if props.hideScrollbars ~= nil then + if type(props.hideScrollbars) == "boolean" then + self.hideScrollbars = { vertical = props.hideScrollbars, horizontal = props.hideScrollbars } + elseif type(props.hideScrollbars) == "table" then + self.hideScrollbars = { + vertical = props.hideScrollbars.vertical ~= nil and props.hideScrollbars.vertical or false, + horizontal = props.hideScrollbars.horizontal ~= nil and props.hideScrollbars.horizontal or false, + } + else + self.hideScrollbars = { vertical = false, horizontal = false } + end + else + self.hideScrollbars = { vertical = false, horizontal = false } + end -- Internal overflow state self._overflowX = false @@ -1140,10 +1157,12 @@ function Element.new(props) self._maxScrollY = 0 -- Scrollbar interaction state - self._scrollbarHovered = false + self._scrollbarHoveredVertical = false + self._scrollbarHoveredHorizontal = false self._scrollbarDragging = false self._hoveredScrollbar = nil -- "vertical" or "horizontal" self._scrollbarDragOffset = 0 -- Offset from thumb top when drag started + self._scrollbarPressHandled = false -- Track if scrollbar press was handled this frame return self end @@ -1312,21 +1331,21 @@ function Element:_drawScrollbars(dims) local x, y = self.x, self.y local w, h = self.width, self.height - -- Determine thumb color based on state - local thumbColor = self.scrollbarColor - if self._scrollbarDragging then - -- Active state: brighter - thumbColor = Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a) - elseif self._scrollbarHovered then - -- Hover state: slightly brighter - thumbColor = Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a) - end - -- Vertical scrollbar - if dims.vertical.visible then + if dims.vertical.visible and not self.hideScrollbars.vertical then local trackX = x + w - self.scrollbarWidth - self.scrollbarPadding + self.padding.left local trackY = y + self.scrollbarPadding + self.padding.top + -- Determine thumb color based on state (independent for vertical) + local thumbColor = self.scrollbarColor + if self._scrollbarDragging and self._hoveredScrollbar == "vertical" then + -- Active state: brighter + thumbColor = Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a) + elseif self._scrollbarHoveredVertical then + -- Hover state: slightly brighter + thumbColor = Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a) + end + -- Draw track love.graphics.setColor(self.scrollbarTrackColor:toRGBA()) love.graphics.rectangle("fill", trackX, trackY, self.scrollbarWidth, dims.vertical.trackHeight, self.scrollbarRadius) @@ -1337,10 +1356,20 @@ function Element:_drawScrollbars(dims) end -- Horizontal scrollbar - if dims.horizontal.visible then + if dims.horizontal.visible and not self.hideScrollbars.horizontal then local trackX = x + self.scrollbarPadding + self.padding.left local trackY = y + h - self.scrollbarWidth - self.scrollbarPadding + self.padding.top + -- Determine thumb color based on state (independent for horizontal) + local thumbColor = self.scrollbarColor + if self._scrollbarDragging and self._hoveredScrollbar == "horizontal" then + -- Active state: brighter + thumbColor = Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a) + elseif self._scrollbarHoveredHorizontal then + -- Hover state: slightly brighter + thumbColor = Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a) + end + -- Draw track love.graphics.setColor(self.scrollbarTrackColor:toRGBA()) love.graphics.rectangle("fill", trackX, trackY, dims.horizontal.trackWidth, self.scrollbarWidth, self.scrollbarRadius) @@ -1370,8 +1399,8 @@ function Element:_getScrollbarAtPosition(mouseX, mouseY) local x, y = self.x, self.y local w, h = self.width, self.height - -- Check vertical scrollbar - if dims.vertical.visible then + -- Check vertical scrollbar (only if not hidden) + if dims.vertical.visible and not self.hideScrollbars.vertical then local trackX = x + w - self.scrollbarWidth - self.scrollbarPadding + self.padding.left local trackY = y + self.scrollbarPadding + self.padding.top local trackW = self.scrollbarWidth @@ -1389,8 +1418,8 @@ function Element:_getScrollbarAtPosition(mouseX, mouseY) end end - -- Check horizontal scrollbar - if dims.horizontal.visible then + -- Check horizontal scrollbar (only if not hidden) + if dims.horizontal.visible and not self.hideScrollbars.horizontal then local trackX = x + self.scrollbarPadding + self.padding.left local trackY = y + h - self.scrollbarWidth - self.scrollbarPadding + self.padding.top local trackW = dims.horizontal.trackWidth @@ -2699,16 +2728,30 @@ function Element:update(dt) -- Handle scrollbar hover detection local mx, my = love.mouse.getPosition() local scrollbar = self:_getScrollbarAtPosition(mx, my) - local wasHovered = self._scrollbarHovered - if scrollbar then - self._scrollbarHovered = true - self._hoveredScrollbar = scrollbar.component + + -- Update independent hover states for vertical and horizontal scrollbars + if scrollbar and scrollbar.component == "vertical" then + self._scrollbarHoveredVertical = true + self._hoveredScrollbar = "vertical" else - if not self._scrollbarDragging then - self._scrollbarHovered = false - self._hoveredScrollbar = nil + if not (self._scrollbarDragging and self._hoveredScrollbar == "vertical") then + self._scrollbarHoveredVertical = false end end + + if scrollbar and scrollbar.component == "horizontal" then + self._scrollbarHoveredHorizontal = true + self._hoveredScrollbar = "horizontal" + else + if not (self._scrollbarDragging and self._hoveredScrollbar == "horizontal") then + self._scrollbarHoveredHorizontal = false + end + end + + -- Clear hoveredScrollbar if neither is hovered + if not scrollbar and not self._scrollbarDragging then + self._hoveredScrollbar = nil + end -- Handle scrollbar dragging if self._scrollbarDragging and love.mouse.isDown(1) then @@ -2718,6 +2761,25 @@ function Element:update(dt) self._scrollbarDragging = false end + -- Handle scrollbar click/press (independent of callback) + -- Check if we should handle scrollbar press for elements with overflow + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + local hasScrollableOverflow = (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") + + if hasScrollableOverflow and not self._scrollbarDragging then + -- Check for scrollbar press on left mouse button + if love.mouse.isDown(1) and not self._scrollbarPressHandled then + local scrollbarPressed = self:_handleScrollbarPress(mx, my, 1) + if scrollbarPressed then + self._scrollbarPressHandled = true + end + elseif not love.mouse.isDown(1) then + -- Reset press handled flag when button is released + self._scrollbarPressHandled = false + end + end + -- Handle click detection for element with enhanced event system if self.callback or self.themeComponent then -- Clickable area is the border box (x, y already includes padding) @@ -2768,10 +2830,11 @@ function Element:update(dt) if love.mouse.isDown(button) then -- Button is pressed down if not self._pressed[button] then - -- Check if press is on scrollbar first - if button == 1 and self:_handleScrollbarPress(mx, my, button) then + -- Check if press is on scrollbar first (skip if already handled) + if button == 1 and not self._scrollbarPressHandled and self:_handleScrollbarPress(mx, my, button) then -- Scrollbar consumed the event, mark as pressed to prevent callback self._pressed[button] = true + self._scrollbarPressHandled = true else -- Just pressed - fire press event and record drag start position local modifiers = getModifiers() diff --git a/testing/__tests__/30_scrollbar_features_tests.lua b/testing/__tests__/30_scrollbar_features_tests.lua new file mode 100644 index 0000000..17c5ce3 --- /dev/null +++ b/testing/__tests__/30_scrollbar_features_tests.lua @@ -0,0 +1,552 @@ +package.path = package.path .. ";./?.lua;./game/?.lua;./game/utils/?.lua;./game/components/?.lua;./game/systems/?.lua" + +local luaunit = require("testing.luaunit") +require("testing.loveStub") -- Required to mock LOVE functions +local FlexLove = require("FlexLove") +local Gui, enums, Color = FlexLove.GUI, FlexLove.enums, FlexLove.Color + +local Positioning = enums.Positioning + +-- Create test cases for scrollbar features +TestScrollbarFeatures = {} + +function TestScrollbarFeatures:setUp() + -- Clean up before each test + Gui.destroy() +end + +function TestScrollbarFeatures:tearDown() + -- Clean up after each test + Gui.destroy() +end + +-- ======================================== +-- Test 1: hideScrollbars with boolean value +-- ======================================== +function TestScrollbarFeatures:testHideScrollbarsBooleanTrue() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + hideScrollbars = true, + }) + + -- Verify hideScrollbars is properly initialized + luaunit.assertNotNil(container.hideScrollbars) + luaunit.assertEquals(type(container.hideScrollbars), "table") + luaunit.assertEquals(container.hideScrollbars.vertical, true) + luaunit.assertEquals(container.hideScrollbars.horizontal, true) +end + +function TestScrollbarFeatures:testHideScrollbarsBooleanFalse() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + hideScrollbars = false, + }) + + -- Verify hideScrollbars defaults to showing scrollbars + luaunit.assertNotNil(container.hideScrollbars) + luaunit.assertEquals(container.hideScrollbars.vertical, false) + luaunit.assertEquals(container.hideScrollbars.horizontal, false) +end + +-- ======================================== +-- Test 2: hideScrollbars with table configuration +-- ======================================== +function TestScrollbarFeatures:testHideScrollbarsTableVerticalOnly() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + hideScrollbars = { vertical = true, horizontal = false }, + }) + + -- Verify only vertical scrollbar is hidden + luaunit.assertEquals(container.hideScrollbars.vertical, true) + luaunit.assertEquals(container.hideScrollbars.horizontal, false) +end + +function TestScrollbarFeatures:testHideScrollbarsTableHorizontalOnly() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + hideScrollbars = { vertical = false, horizontal = true }, + }) + + -- Verify only horizontal scrollbar is hidden + luaunit.assertEquals(container.hideScrollbars.vertical, false) + luaunit.assertEquals(container.hideScrollbars.horizontal, true) +end + +function TestScrollbarFeatures:testHideScrollbarsTableBothHidden() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + hideScrollbars = { vertical = false, horizontal = false }, + }) + + -- Verify both scrollbars are shown + luaunit.assertEquals(container.hideScrollbars.vertical, false) + luaunit.assertEquals(container.hideScrollbars.horizontal, false) +end + +-- ======================================== +-- Test 3: Default hideScrollbars behavior +-- ======================================== +function TestScrollbarFeatures:testHideScrollbarsDefault() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + }) + + -- Verify default is to show scrollbars (backward compatibility) + luaunit.assertNotNil(container.hideScrollbars) + luaunit.assertEquals(container.hideScrollbars.vertical, false) + luaunit.assertEquals(container.hideScrollbars.horizontal, false) +end + +-- ======================================== +-- Test 4: Independent hover states initialization +-- ======================================== +function TestScrollbarFeatures:testIndependentHoverStatesInitialization() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + }) + + -- Verify independent hover states are initialized + luaunit.assertNotNil(container._scrollbarHoveredVertical) + luaunit.assertNotNil(container._scrollbarHoveredHorizontal) + luaunit.assertEquals(container._scrollbarHoveredVertical, false) + luaunit.assertEquals(container._scrollbarHoveredHorizontal, false) +end + +-- ======================================== +-- Test 5: Scrollbar dimensions calculation +-- ======================================== +function TestScrollbarFeatures:testScrollbarDimensionsCalculation() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + positioning = Positioning.FLEX, + }) + + -- Add child that overflows + local child = Gui.new({ + parent = container, + width = 300, + height = 300, + }) + + -- Detect overflow + container:_detectOverflow() + + -- Calculate scrollbar dimensions + local dims = container:_calculateScrollbarDimensions() + + -- Verify dimensions structure + luaunit.assertNotNil(dims.vertical) + luaunit.assertNotNil(dims.horizontal) + luaunit.assertNotNil(dims.vertical.visible) + luaunit.assertNotNil(dims.horizontal.visible) +end + +-- ======================================== +-- Test 6: Scroll position management +-- ======================================== +function TestScrollbarFeatures:testScrollPositionSetAndGet() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + positioning = Positioning.FLEX, + }) + + -- Add child that overflows + local child = Gui.new({ + parent = container, + width = 300, + height = 300, + }) + + -- Detect overflow to set max scroll + container:_detectOverflow() + + -- Set scroll position + container:setScrollPosition(50, 100) + + -- Get scroll position + local scrollX, scrollY = container:getScrollPosition() + luaunit.assertEquals(scrollX, 50) + luaunit.assertEquals(scrollY, 100) +end + +function TestScrollbarFeatures:testScrollPositionClamping() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + positioning = Positioning.FLEX, + }) + + -- Add child that overflows + local child = Gui.new({ + parent = container, + width = 300, + height = 300, + }) + + -- Detect overflow to set max scroll + container:_detectOverflow() + + -- Try to set scroll position beyond max + container:setScrollPosition(1000, 1000) + + -- Get scroll position - should be clamped to max + local scrollX, scrollY = container:getScrollPosition() + local maxScrollX, maxScrollY = container:getMaxScroll() + luaunit.assertEquals(scrollX, maxScrollX) + luaunit.assertEquals(scrollY, maxScrollY) +end + +-- ======================================== +-- Test 7: Scroll by delta +-- ======================================== +function TestScrollbarFeatures:testScrollByDelta() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + positioning = Positioning.FLEX, + }) + + -- Add child that overflows + local child = Gui.new({ + parent = container, + width = 300, + height = 300, + }) + + -- Detect overflow + container:_detectOverflow() + + -- Initial scroll position + container:setScrollPosition(50, 50) + + -- Scroll by delta + container:scrollBy(10, 20) + + -- Verify new position + local scrollX, scrollY = container:getScrollPosition() + luaunit.assertEquals(scrollX, 60) + luaunit.assertEquals(scrollY, 70) +end + +-- ======================================== +-- Test 8: Scroll to top/bottom/left/right +-- ======================================== +function TestScrollbarFeatures:testScrollToTop() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + positioning = Positioning.FLEX, + }) + + -- Add child that overflows + local child = Gui.new({ + parent = container, + width = 300, + height = 300, + }) + + -- Detect overflow + container:_detectOverflow() + + -- Set initial scroll position + container:setScrollPosition(50, 50) + + -- Scroll to top + container:scrollToTop() + + -- Verify position + local scrollX, scrollY = container:getScrollPosition() + luaunit.assertEquals(scrollY, 0) +end + +function TestScrollbarFeatures:testScrollToBottom() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + positioning = Positioning.FLEX, + }) + + -- Add child that overflows + local child = Gui.new({ + parent = container, + width = 300, + height = 300, + }) + + -- Detect overflow + container:_detectOverflow() + + -- Scroll to bottom + container:scrollToBottom() + + -- Verify position + local scrollX, scrollY = container:getScrollPosition() + local maxScrollX, maxScrollY = container:getMaxScroll() + luaunit.assertEquals(scrollY, maxScrollY) +end + +function TestScrollbarFeatures:testScrollToLeft() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + positioning = Positioning.FLEX, + }) + + -- Add child that overflows + local child = Gui.new({ + parent = container, + width = 300, + height = 300, + }) + + -- Detect overflow + container:_detectOverflow() + + -- Set initial scroll position + container:setScrollPosition(50, 50) + + -- Scroll to left + container:scrollToLeft() + + -- Verify position + local scrollX, scrollY = container:getScrollPosition() + luaunit.assertEquals(scrollX, 0) +end + +function TestScrollbarFeatures:testScrollToRight() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + positioning = Positioning.FLEX, + }) + + -- Add child that overflows + local child = Gui.new({ + parent = container, + width = 300, + height = 300, + }) + + -- Detect overflow + container:_detectOverflow() + + -- Scroll to right + container:scrollToRight() + + -- Verify position + local scrollX, scrollY = container:getScrollPosition() + local maxScrollX, maxScrollY = container:getMaxScroll() + luaunit.assertEquals(scrollX, maxScrollX) +end + +-- ======================================== +-- Test 9: Get scroll percentage +-- ======================================== +function TestScrollbarFeatures:testGetScrollPercentage() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + positioning = Positioning.FLEX, + }) + + -- Add child that overflows + local child = Gui.new({ + parent = container, + width = 300, + height = 300, + }) + + -- Detect overflow + container:_detectOverflow() + + -- Scroll to middle + local maxScrollX, maxScrollY = container:getMaxScroll() + container:setScrollPosition(maxScrollX / 2, maxScrollY / 2) + + -- Get scroll percentage + local percentX, percentY = container:getScrollPercentage() + luaunit.assertAlmostEquals(percentX, 0.5, 0.01) + luaunit.assertAlmostEquals(percentY, 0.5, 0.01) +end + +-- ======================================== +-- Test 10: Has overflow detection +-- ======================================== +function TestScrollbarFeatures:testHasOverflow() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + positioning = Positioning.FLEX, + }) + + -- Add child that overflows vertically + local child = Gui.new({ + parent = container, + width = 150, + height = 300, + }) + + -- Detect overflow + container:_detectOverflow() + + -- Check overflow + local hasOverflowX, hasOverflowY = container:hasOverflow() + luaunit.assertEquals(hasOverflowX, false) + luaunit.assertEquals(hasOverflowY, true) +end + +-- ======================================== +-- Test 11: Get content size +-- ======================================== +function TestScrollbarFeatures:testGetContentSize() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + positioning = Positioning.FLEX, + }) + + -- Add child with specific size + local child = Gui.new({ + parent = container, + width = 300, + height = 400, + }) + + -- Detect overflow + container:_detectOverflow() + + -- Get content size + local contentWidth, contentHeight = container:getContentSize() + luaunit.assertEquals(contentWidth, 300) + luaunit.assertEquals(contentHeight, 400) +end + +-- ======================================== +-- Test 12: Scrollbar configuration options +-- ======================================== +function TestScrollbarFeatures:testScrollbarConfigurationOptions() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + scrollbarWidth = 20, + scrollbarRadius = 10, + scrollbarPadding = 5, + scrollSpeed = 30, + scrollbarColor = Color.new(1, 0, 0, 1), + scrollbarTrackColor = Color.new(0, 1, 0, 1), + }) + + -- Verify custom configuration + luaunit.assertEquals(container.scrollbarWidth, 20) + luaunit.assertEquals(container.scrollbarRadius, 10) + luaunit.assertEquals(container.scrollbarPadding, 5) + luaunit.assertEquals(container.scrollSpeed, 30) + luaunit.assertEquals(container.scrollbarColor.r, 1) + luaunit.assertEquals(container.scrollbarTrackColor.g, 1) +end + +-- ======================================== +-- Test 13: Wheel scroll handling +-- ======================================== +function TestScrollbarFeatures:testWheelScrollHandling() + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 200, + overflow = "scroll", + positioning = Positioning.FLEX, + }) + + -- Add child that overflows + local child = Gui.new({ + parent = container, + width = 300, + height = 300, + }) + + -- Detect overflow + container:_detectOverflow() + + -- Set initial position away from top so we can scroll up + container:setScrollPosition(nil, 50) + local initialScrollX, initialScrollY = container:getScrollPosition() + + -- Handle wheel scroll (vertical) - positive y means scroll up + local handled = container:_handleWheelScroll(0, 1) + + -- Verify scroll was handled and position changed (scrolled up means lower scroll value) + luaunit.assertEquals(handled, true) + local scrollX, scrollY = container:getScrollPosition() + luaunit.assertTrue(scrollY < initialScrollY, "Expected scroll position to decrease when scrolling up") +end + +-- Run the tests +os.exit(luaunit.LuaUnit.run())