From 986887c2ccd27fc516a766f6679e8d348718144d Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 6 Dec 2025 11:11:15 -0500 Subject: [PATCH] better controls over themed scrollbars --- modules/Element.lua | 3 ++ modules/Renderer.lua | 36 +++++++++++++++-- modules/ScrollManager.lua | 9 +++++ modules/Theme.lua | 9 +++++ modules/types.lua | 1 + modules/utils.lua | 31 +++++++++++++++ testing/__tests__/scroll_manager_test.lua | 48 +++++++++++++++++++++++ themes/metal.lua | 2 + 8 files changed, 135 insertions(+), 4 deletions(-) diff --git a/modules/Element.lua b/modules/Element.lua index 3f0a8e9..9b0501b 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -133,6 +133,7 @@ ---@field scrollbarPadding number? -- Scrollbar padding from edges ---@field scrollSpeed number? -- Scroll speed multiplier ---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars) +---@field scrollbarKnobOffset number|table? -- Scrollbar knob/handle offset (number or {x, y} or {horizontal, vertical}) ---@field _overflowX boolean? -- Internal: whether content overflows horizontally ---@field _overflowY boolean? -- Internal: whether content overflows vertically ---@field _contentWidth number? -- Internal: total content width @@ -1444,6 +1445,7 @@ function Element.new(props) scrollSpeed = props.scrollSpeed, smoothScrollEnabled = props.smoothScrollEnabled, scrollBarStyle = props.scrollBarStyle, + scrollbarKnobOffset = props.scrollbarKnobOffset, hideScrollbars = props.hideScrollbars, _scrollX = props._scrollX, _scrollY = props._scrollY, @@ -1460,6 +1462,7 @@ function Element.new(props) self.scrollbarPadding = self._scrollManager.scrollbarPadding self.scrollSpeed = self._scrollManager.scrollSpeed self.scrollBarStyle = self._scrollManager.scrollBarStyle + self.scrollbarKnobOffset = self._scrollManager.scrollbarKnobOffset self.hideScrollbars = self._scrollManager.hideScrollbars -- Initialize state properties (will be synced from ScrollManager) diff --git a/modules/Renderer.lua b/modules/Renderer.lua index 7f157c6..406352c 100644 --- a/modules/Renderer.lua +++ b/modules/Renderer.lua @@ -900,6 +900,20 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims) local frameComponent = scrollbarComponent.frame or scrollbarComponent local barComponent = scrollbarComponent.bar or scrollbarComponent + -- Calculate knob offset (element overrides theme) + local knobOffsetX = 0 + local knobOffsetY = 0 + + -- Use element offset if provided, otherwise use theme offset + if element.scrollbarKnobOffset then + knobOffsetX = element.scrollbarKnobOffset.x or 0 + knobOffsetY = element.scrollbarKnobOffset.vertical or 0 + elseif barComponent and barComponent.knobOffset then + local themeOffset = self._utils.normalizeOffsetTable(barComponent.knobOffset, 0) + knobOffsetX = themeOffset.x + knobOffsetY = themeOffset.vertical + end + -- Draw track (frame) if component exists if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then self._NinePatch.draw( @@ -917,8 +931,8 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims) self._NinePatch.draw( barComponent, barComponent._loadedAtlas, - trackX, - trackY + dims.vertical.thumbY, + trackX + knobOffsetX, + trackY + dims.vertical.thumbY + knobOffsetY, element.scrollbarWidth, dims.vertical.thumbHeight ) @@ -961,6 +975,20 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims) local frameComponent = scrollbarComponent.frame or scrollbarComponent local barComponent = scrollbarComponent.bar or scrollbarComponent + -- Calculate knob offset (element overrides theme) + local knobOffsetX = 0 + local knobOffsetY = 0 + + -- Use element offset if provided, otherwise use theme offset + if element.scrollbarKnobOffset then + knobOffsetX = element.scrollbarKnobOffset.horizontal or 0 + knobOffsetY = element.scrollbarKnobOffset.y or 0 + elseif barComponent and barComponent.knobOffset then + local themeOffset = self._utils.normalizeOffsetTable(barComponent.knobOffset, 0) + knobOffsetX = themeOffset.horizontal + knobOffsetY = themeOffset.y + end + -- Draw track (frame) if component exists if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then self._NinePatch.draw( @@ -978,8 +1006,8 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims) self._NinePatch.draw( barComponent, barComponent._loadedAtlas, - trackX + dims.horizontal.thumbX, - trackY, + trackX + dims.horizontal.thumbX + knobOffsetX, + trackY + knobOffsetY, dims.horizontal.thumbWidth, element.scrollbarWidth ) diff --git a/modules/ScrollManager.lua b/modules/ScrollManager.lua index 7a0f9ad..646abde 100644 --- a/modules/ScrollManager.lua +++ b/modules/ScrollManager.lua @@ -9,6 +9,7 @@ ---@field scrollbarPadding number -- Padding around scrollbar ---@field scrollSpeed number -- Scroll speed for wheel events (pixels per wheel unit) ---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars) +---@field scrollbarKnobOffset table -- {x: number, y: number, horizontal: number, vertical: number} -- Offset for scrollbar knob/handle position ---@field hideScrollbars table -- {vertical: boolean, horizontal: boolean} ---@field touchScrollEnabled boolean -- Enable touch scrolling ---@field momentumScrollEnabled boolean -- Enable momentum scrolling @@ -84,6 +85,9 @@ function ScrollManager.new(config, deps) self.scrollSpeed = config.scrollSpeed or 20 self.scrollBarStyle = config.scrollBarStyle -- Theme scrollbar style name (nil = use default) + -- scrollbarKnobOffset can be number or table {x, y} or {horizontal, vertical} + self.scrollbarKnobOffset = self._utils.normalizeOffsetTable(config.scrollbarKnobOffset, 0) + -- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean} self.hideScrollbars = self._utils.normalizeBooleanTable(config.hideScrollbars, false) @@ -656,6 +660,7 @@ function ScrollManager:getState() _scrollbarHoveredVertical = self._scrollbarHoveredVertical or false, _scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal or false, scrollBarStyle = self.scrollBarStyle, + scrollbarKnobOffset = self.scrollbarKnobOffset, _overflowX = self._overflowX, _overflowY = self._overflowY, _contentWidth = self._contentWidth, @@ -730,6 +735,10 @@ function ScrollManager:setState(state) self.scrollBarStyle = state.scrollBarStyle end + if state.scrollbarKnobOffset ~= nil then + self.scrollbarKnobOffset = self._utils.normalizeOffsetTable(state.scrollbarKnobOffset, 0) + end + if state._overflowX ~= nil then self._overflowX = state._overflowX end diff --git a/modules/Theme.lua b/modules/Theme.lua index 3f95a83..692becd 100644 --- a/modules/Theme.lua +++ b/modules/Theme.lua @@ -322,6 +322,7 @@ end ---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: multiplier for auto-sized content dimensions ---@field scaleCorners number? -- Optional: scale multiplier for non-stretched regions (corners/edges). E.g., 2 = 2x size. Default: nil (no scaling) ---@field scalingAlgorithm "nearest"|"bilinear"? -- Optional: scaling algorithm for non-stretched regions. Default: "bilinear" +---@field knobOffset number|table? -- Optional: offset for scrollbar knob/handle (number or {x, y} or {horizontal, vertical}) ---@field _loadedAtlas string|love.Image? -- Internal: cached loaded atlas image ---@field _loadedAtlasData love.ImageData? -- Internal: cached loaded atlas ImageData for pixel access ---@field _ninePatchData {insets:table, contentPadding:table, stretchX:table, stretchY:table}? -- Internal: parsed 9-patch data with stretch regions and content padding @@ -569,6 +570,10 @@ function Theme.new(definition) if type(scrollbarDef.bar) == "string" then -- Convert string path to ThemeComponent structure local barComponent = { atlas = scrollbarDef.bar } + -- Copy knobOffset from parent scrollbarDef if it exists + if scrollbarDef.knobOffset then + barComponent.knobOffset = scrollbarDef.knobOffset + end loadAtlasWithNinePatch(barComponent, scrollbarDef.bar, "for scrollbar '" .. scrollbarName .. ".bar'") if barComponent.insets then createRegionsFromInsets(barComponent, barComponent._loadedAtlas or self.atlas) @@ -576,6 +581,10 @@ function Theme.new(definition) scrollbarDef.bar = barComponent elseif type(scrollbarDef.bar) == "table" then -- Already a ThemeComponent structure, process it + -- Copy knobOffset from parent if bar component doesn't have one + if scrollbarDef.knobOffset and not scrollbarDef.bar.knobOffset then + scrollbarDef.bar.knobOffset = scrollbarDef.knobOffset + end if scrollbarDef.bar.atlas and type(scrollbarDef.bar.atlas) == "string" then loadAtlasWithNinePatch(scrollbarDef.bar, scrollbarDef.bar.atlas, "for scrollbar '" .. scrollbarName .. ".bar'") end diff --git a/modules/types.lua b/modules/types.lua index 66da540..5f788dd 100644 --- a/modules/types.lua +++ b/modules/types.lua @@ -123,6 +123,7 @@ local AnimationProps = {} ---@field scrollSpeed number? -- Pixels per wheel notch (default: 20) ---@field smoothScrollEnabled boolean? -- Enable smooth scrolling animation for wheel events (default: false) ---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars, default: uses first scrollbar or fallback rendering) +---@field scrollbarKnobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}? -- Offset for scrollbar knob/handle position in pixels (number for both axes, or table for per-axis control, default: 0, adds to theme offset) ---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control, default: false) ---@field imagePath string? -- Path to image file (auto-loads via ImageCache) ---@field image love.Image? -- Image object to display diff --git a/modules/utils.lua b/modules/utils.lua index 17ec0aa..563f9cc 100644 --- a/modules/utils.lua +++ b/modules/utils.lua @@ -546,6 +546,36 @@ local function normalizeBooleanTable(value, defaultValue) return { vertical = defaultValue, horizontal = defaultValue } end +--- Normalize an offset value to {x, y} or {horizontal, vertical} format +---@param value number|table|nil Input value (number applies to both, table for individual control) +---@param defaultValue number Default value if nil (default: 0) +---@return table Normalized table with x/y or horizontal/vertical fields +local function normalizeOffsetTable(value, defaultValue) + defaultValue = defaultValue or 0 + + if value == nil then + return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue } + end + + if type(value) == "number" then + return { x = value, y = value, horizontal = value, vertical = value } + end + + if type(value) == "table" then + -- Support both {x, y} and {horizontal, vertical} formats + local x = value.x or value.horizontal or defaultValue + local y = value.y or value.vertical or defaultValue + return { + x = x, + y = y, + horizontal = x, + vertical = y, + } + end + + return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue } +end + -- Text sanitization utilities --- Sanitize text to prevent security vulnerabilities @@ -1187,6 +1217,7 @@ return { brightenColor = brightenColor, resolveImagePath = resolveImagePath, normalizeBooleanTable = normalizeBooleanTable, + normalizeOffsetTable = normalizeOffsetTable, resolveFontPath = resolveFontPath, getFont = getFont, applyContentMultiplier = applyContentMultiplier, diff --git a/testing/__tests__/scroll_manager_test.lua b/testing/__tests__/scroll_manager_test.lua index 8de719b..ef61136 100644 --- a/testing/__tests__/scroll_manager_test.lua +++ b/testing/__tests__/scroll_manager_test.lua @@ -1027,6 +1027,54 @@ function TestScrollManagerEdgeCases:testIsMomentumScrolling() luaunit.assertTrue(sm:isMomentumScrolling()) end +-- Test scrollbarKnobOffset configuration +function TestScrollManagerEdgeCases:testScrollbarKnobOffsetNumber() + local sm = createScrollManager({ scrollbarKnobOffset = 5 }) + luaunit.assertNotNil(sm.scrollbarKnobOffset) + luaunit.assertEquals(sm.scrollbarKnobOffset.x, 5) + luaunit.assertEquals(sm.scrollbarKnobOffset.y, 5) + luaunit.assertEquals(sm.scrollbarKnobOffset.horizontal, 5) + luaunit.assertEquals(sm.scrollbarKnobOffset.vertical, 5) +end + +function TestScrollManagerEdgeCases:testScrollbarKnobOffsetTableXY() + local sm = createScrollManager({ scrollbarKnobOffset = { x = 10, y = 20 } }) + luaunit.assertNotNil(sm.scrollbarKnobOffset) + luaunit.assertEquals(sm.scrollbarKnobOffset.x, 10) + luaunit.assertEquals(sm.scrollbarKnobOffset.y, 20) + luaunit.assertEquals(sm.scrollbarKnobOffset.horizontal, 10) + luaunit.assertEquals(sm.scrollbarKnobOffset.vertical, 20) +end + +function TestScrollManagerEdgeCases:testScrollbarKnobOffsetTableHorizontalVertical() + local sm = createScrollManager({ scrollbarKnobOffset = { horizontal = 15, vertical = 25 } }) + luaunit.assertNotNil(sm.scrollbarKnobOffset) + luaunit.assertEquals(sm.scrollbarKnobOffset.x, 15) + luaunit.assertEquals(sm.scrollbarKnobOffset.y, 25) + luaunit.assertEquals(sm.scrollbarKnobOffset.horizontal, 15) + luaunit.assertEquals(sm.scrollbarKnobOffset.vertical, 25) +end + +function TestScrollManagerEdgeCases:testScrollbarKnobOffsetDefault() + local sm = createScrollManager({}) + luaunit.assertNotNil(sm.scrollbarKnobOffset) + luaunit.assertEquals(sm.scrollbarKnobOffset.x, 0) + luaunit.assertEquals(sm.scrollbarKnobOffset.y, 0) + luaunit.assertEquals(sm.scrollbarKnobOffset.horizontal, 0) + luaunit.assertEquals(sm.scrollbarKnobOffset.vertical, 0) +end + +function TestScrollManagerEdgeCases:testScrollbarKnobOffsetStatePersistence() + local sm = createScrollManager({ scrollbarKnobOffset = { x = 5, y = 10 } }) + local state = sm:getState() + luaunit.assertNotNil(state.scrollbarKnobOffset) + + local sm2 = createScrollManager({}) + sm2:setState(state) + luaunit.assertEquals(sm2.scrollbarKnobOffset.x, 5) + luaunit.assertEquals(sm2.scrollbarKnobOffset.y, 10) +end + if not _G.RUNNING_ALL_TESTS then os.exit(luaunit.LuaUnit.run()) end diff --git a/themes/metal.lua b/themes/metal.lua index c165ce2..c2531e3 100644 --- a/themes/metal.lua +++ b/themes/metal.lua @@ -7,10 +7,12 @@ return { v1 = { bar = "themes/metal/Button/Button01a_1.9.png", frame = "themes/metal/Frame/Frame01a.9.png", + knobOffset = { x = 4, y = 4 }, -- 0, 0 is default }, v2 = { bar = "themes/metal/Button/Button01a_1.9.png", frame = "themes/metal/Frame/Frame01a.9.png", + knobOffset = { x = 4, y = 4 }, -- 0, 0 is default }, }, components = {