diff --git a/FlexLove.lua b/FlexLove.lua index aedffd8..98c9deb 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -841,7 +841,6 @@ end ---@param dy number function flexlove.wheelmoved(dx, dy) local mx, my = love.mouse.getPosition() - local function findScrollableAtPosition(elements, x, y) for i = #elements, 1, -1 do local element = elements[i] @@ -871,7 +870,6 @@ function flexlove.wheelmoved(dx, dy) end if flexlove._immediateMode then - -- Find topmost scrollable element at mouse position using z-index ordering for i = #Context._zIndexOrderedElements, 1, -1 do local element = Context._zIndexOrderedElements[i] @@ -939,14 +937,14 @@ function flexlove.wheelmoved(dx, dy) if not isClipped then local overflowX = element.overflowX or element.overflow local overflowY = element.overflowY or element.overflow - if (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") and (element._overflowX or element._overflowY) then + + if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then element:_handleWheelScroll(dx, dy) - -- Save scroll position to StateManager immediately in immediate mode - if element._stateId then + if element._stateId and element._scrollManager then + local scrollManagerState = element._scrollManager:getState() StateManager.updateState(element._stateId, { - _scrollX = element._scrollX, - _scrollY = element._scrollY, + scrollManager = scrollManagerState, }) end return diff --git a/modules/Element.lua b/modules/Element.lua index 07b230e..348e37d 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -132,6 +132,7 @@ ---@field scrollbarRadius number? -- Scrollbar corner radius ---@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 _overflowX boolean? -- Internal: whether content overflows horizontally ---@field _overflowY boolean? -- Internal: whether content overflows vertically ---@field _contentWidth number? -- Internal: total content width @@ -1423,6 +1424,8 @@ function Element.new(props) scrollbarRadius = props.scrollbarRadius, scrollbarPadding = props.scrollbarPadding, scrollSpeed = props.scrollSpeed, + smoothScrollEnabled = props.smoothScrollEnabled, + scrollBarStyle = props.scrollBarStyle, hideScrollbars = props.hideScrollbars, _scrollX = props._scrollX, _scrollY = props._scrollY, @@ -1438,6 +1441,7 @@ function Element.new(props) self.scrollbarRadius = self._scrollManager.scrollbarRadius self.scrollbarPadding = self._scrollManager.scrollbarPadding self.scrollSpeed = self._scrollManager.scrollSpeed + self.scrollBarStyle = self._scrollManager.scrollBarStyle self.hideScrollbars = self._scrollManager.hideScrollbars -- Initialize state properties (will be synced from ScrollManager) @@ -2212,6 +2216,12 @@ function Element:update(dt) self._textEditor:update(self, dt) end + -- Update scroll manager for smooth scrolling and momentum + if self._scrollManager then + self._scrollManager:update(dt) + self:_syncScrollManagerState() + end + -- Update animation if exists if self.animation then -- Ensure animation has Color module reference for color interpolation diff --git a/modules/EventHandler.lua b/modules/EventHandler.lua index 2108099..f4260a8 100644 --- a/modules/EventHandler.lua +++ b/modules/EventHandler.lua @@ -266,6 +266,14 @@ function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering) local lastY = self._lastMouseY[button] or my if lastX ~= mx or lastY ~= my then + -- Handle scrollbar drag if scrollbar was pressed + if button == 1 and self._scrollbarPressHandled and element._handleScrollbarDrag then + element:_handleScrollbarDrag(mx, my) + self._lastMouseX[button] = mx + self._lastMouseY[button] = my + return -- Don't process other drag events while dragging scrollbar + end + -- Mouse has moved - fire drag event only if still hovering if isHovering then local modifiers = EventHandler._utils.getModifiers() @@ -304,6 +312,16 @@ function EventHandler:_handleMouseRelease(element, mx, my, button) local currentTime = love.timer.getTime() local modifiers = EventHandler._utils.getModifiers() + -- Handle scrollbar release if scrollbar was pressed + if button == 1 and self._scrollbarPressHandled and element._handleScrollbarRelease then + element:_handleScrollbarRelease(button) + self._scrollbarPressHandled = false -- Reset flag + self._pressed[button] = false + self._dragStartX[button] = nil + self._dragStartY[button] = nil + return -- Don't process click events for scrollbar release + end + -- Determine click count (double-click detection) local clickCount = 1 local doubleClickThreshold = 0.3 -- 300ms for double-click diff --git a/modules/Renderer.lua b/modules/Renderer.lua index 5b923f3..7f157c6 100644 --- a/modules/Renderer.lua +++ b/modules/Renderer.lua @@ -880,6 +880,12 @@ end ---@param h number Height ---@param dims table Scrollbar dimensions from _calculateScrollbarDimensions function Renderer:drawScrollbars(element, x, y, w, h, dims) + -- Try to get themed scrollbar component + local scrollbarComponent = nil + if element.scrollBarStyle or self._Theme.hasActive() then + scrollbarComponent = self._Theme.getScrollbar(element.scrollBarStyle) + end + -- Vertical scrollbar if dims.vertical.visible and not element.hideScrollbars.vertical then -- Position scrollbar within content area (x, y is border-box origin) @@ -888,25 +894,57 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims) local trackX = contentX + w - element.scrollbarWidth - element.scrollbarPadding local trackY = contentY + element.scrollbarPadding - -- Determine thumb color based on state (independent for vertical) - local thumbColor = element.scrollbarColor - if element._scrollbarDragging and element._hoveredScrollbar == "vertical" then - -- Active state: brighter - local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4) - thumbColor = self._Color.new(r, g, b, a) - elseif element._scrollbarHoveredVertical then - -- Hover state: slightly brighter - local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2) - thumbColor = self._Color.new(r, g, b, a) + -- Check if we should use themed rendering + if scrollbarComponent then + -- Themed scrollbar rendering using NinePatch + local frameComponent = scrollbarComponent.frame or scrollbarComponent + local barComponent = scrollbarComponent.bar or scrollbarComponent + + -- Draw track (frame) if component exists + if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then + self._NinePatch.draw( + frameComponent, + frameComponent._loadedAtlas, + trackX, + trackY, + element.scrollbarWidth, + dims.vertical.trackHeight + ) + end + + -- Draw thumb (bar) if component exists + if barComponent and barComponent._loadedAtlas and barComponent.regions then + self._NinePatch.draw( + barComponent, + barComponent._loadedAtlas, + trackX, + trackY + dims.vertical.thumbY, + element.scrollbarWidth, + dims.vertical.thumbHeight + ) + end + else + -- Fallback to color-based rendering + -- Determine thumb color based on state (independent for vertical) + local thumbColor = element.scrollbarColor + if element._scrollbarDragging and element._hoveredScrollbar == "vertical" then + -- Active state: brighter + local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4) + thumbColor = self._Color.new(r, g, b, a) + elseif element._scrollbarHoveredVertical then + -- Hover state: slightly brighter + local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2) + thumbColor = self._Color.new(r, g, b, a) + end + + -- Draw track + love.graphics.setColor(element.scrollbarTrackColor:toRGBA()) + love.graphics.rectangle("fill", trackX, trackY, element.scrollbarWidth, dims.vertical.trackHeight, element.scrollbarRadius) + + -- Draw thumb with state-based color + love.graphics.setColor(thumbColor:toRGBA()) + love.graphics.rectangle("fill", trackX, trackY + dims.vertical.thumbY, element.scrollbarWidth, dims.vertical.thumbHeight, element.scrollbarRadius) end - - -- Draw track - love.graphics.setColor(element.scrollbarTrackColor:toRGBA()) - love.graphics.rectangle("fill", trackX, trackY, element.scrollbarWidth, dims.vertical.trackHeight, element.scrollbarRadius) - - -- Draw thumb with state-based color - love.graphics.setColor(thumbColor:toRGBA()) - love.graphics.rectangle("fill", trackX, trackY + dims.vertical.thumbY, element.scrollbarWidth, dims.vertical.thumbHeight, element.scrollbarRadius) end -- Horizontal scrollbar @@ -917,25 +955,57 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims) local trackX = contentX + element.scrollbarPadding local trackY = contentY + h - element.scrollbarWidth - element.scrollbarPadding - -- Determine thumb color based on state (independent for horizontal) - local thumbColor = element.scrollbarColor - if element._scrollbarDragging and element._hoveredScrollbar == "horizontal" then - -- Active state: brighter - local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4) - thumbColor = self._Color.new(r, g, b, a) - elseif element._scrollbarHoveredHorizontal then - -- Hover state: slightly brighter - local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2) - thumbColor = self._Color.new(r, g, b, a) + -- Check if we should use themed rendering + if scrollbarComponent then + -- Themed scrollbar rendering using NinePatch + local frameComponent = scrollbarComponent.frame or scrollbarComponent + local barComponent = scrollbarComponent.bar or scrollbarComponent + + -- Draw track (frame) if component exists + if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then + self._NinePatch.draw( + frameComponent, + frameComponent._loadedAtlas, + trackX, + trackY, + dims.horizontal.trackWidth, + element.scrollbarWidth + ) + end + + -- Draw thumb (bar) if component exists + if barComponent and barComponent._loadedAtlas and barComponent.regions then + self._NinePatch.draw( + barComponent, + barComponent._loadedAtlas, + trackX + dims.horizontal.thumbX, + trackY, + dims.horizontal.thumbWidth, + element.scrollbarWidth + ) + end + else + -- Fallback to color-based rendering + -- Determine thumb color based on state (independent for horizontal) + local thumbColor = element.scrollbarColor + if element._scrollbarDragging and element._hoveredScrollbar == "horizontal" then + -- Active state: brighter + local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4) + thumbColor = self._Color.new(r, g, b, a) + elseif element._scrollbarHoveredHorizontal then + -- Hover state: slightly brighter + local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2) + thumbColor = self._Color.new(r, g, b, a) + end + + -- Draw track + love.graphics.setColor(element.scrollbarTrackColor:toRGBA()) + love.graphics.rectangle("fill", trackX, trackY, dims.horizontal.trackWidth, element.scrollbarWidth, element.scrollbarRadius) + + -- Draw thumb with state-based color + love.graphics.setColor(thumbColor:toRGBA()) + love.graphics.rectangle("fill", trackX + dims.horizontal.thumbX, trackY, dims.horizontal.thumbWidth, element.scrollbarWidth, element.scrollbarRadius) end - - -- Draw track - love.graphics.setColor(element.scrollbarTrackColor:toRGBA()) - love.graphics.rectangle("fill", trackX, trackY, dims.horizontal.trackWidth, element.scrollbarWidth, element.scrollbarRadius) - - -- Draw thumb with state-based color - love.graphics.setColor(thumbColor:toRGBA()) - love.graphics.rectangle("fill", trackX + dims.horizontal.thumbX, trackY, dims.horizontal.thumbWidth, element.scrollbarWidth, element.scrollbarRadius) end -- Reset color diff --git a/modules/ScrollManager.lua b/modules/ScrollManager.lua index 4b63d1b..eb03862 100644 --- a/modules/ScrollManager.lua +++ b/modules/ScrollManager.lua @@ -8,6 +8,7 @@ ---@field scrollbarRadius number -- Border radius for scrollbars ---@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 hideScrollbars table -- {vertical: boolean, horizontal: boolean} ---@field touchScrollEnabled boolean -- Enable touch scrolling ---@field momentumScrollEnabled boolean -- Enable momentum scrolling @@ -21,6 +22,9 @@ ---@field _contentHeight number -- Total content height (including overflow) ---@field _scrollX number -- Current horizontal scroll position ---@field _scrollY number -- Current vertical scroll position +---@field _targetScrollX number? -- Target scroll X for smooth scrolling +---@field _targetScrollY number? -- Target scroll Y for smooth scrolling +---@field _smoothScrollSpeed number -- Speed of smooth scroll interpolation (0-1, higher = faster) ---@field _maxScrollX number -- Maximum horizontal scroll (contentWidth - containerWidth) ---@field _maxScrollY number -- Maximum vertical scroll (contentHeight - containerHeight) ---@field _scrollbarHoveredVertical boolean -- True if mouse is over vertical scrollbar @@ -74,6 +78,7 @@ function ScrollManager.new(config, deps) self.scrollbarRadius = config.scrollbarRadius or 6 self.scrollbarPadding = config.scrollbarPadding or 2 self.scrollSpeed = config.scrollSpeed or 20 + self.scrollBarStyle = config.scrollBarStyle -- Theme scrollbar style name (nil = use default) -- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean} self.hideScrollbars = self._utils.normalizeBooleanTable(config.hideScrollbars, false) @@ -95,6 +100,10 @@ function ScrollManager.new(config, deps) -- Scroll state (can be restored from config in immediate mode) self._scrollX = config._scrollX or 0 self._scrollY = config._scrollY or 0 + self._targetScrollX = nil + self._targetScrollY = nil + self._smoothScrollSpeed = 0.25 -- Interpolation speed (0-1, higher = faster) + self.smoothScrollEnabled = config.smoothScrollEnabled or false -- Enable smooth wheel scrolling self._maxScrollX = 0 self._maxScrollY = 0 @@ -552,24 +561,38 @@ function ScrollManager:handleWheel(x, y) return false end - local hasVerticalOverflow = self._overflowY and self._maxScrollY > 0 - local hasHorizontalOverflow = self._overflowX and self._maxScrollX > 0 + -- In immediate mode, overflow might not be calculated yet, so allow scrolling based on maxScroll values + -- If _overflowY is nil/false but _maxScrollY > 0, we should still allow scrolling (from restored state) + local hasVerticalOverflow = (self._overflowY and self._maxScrollY > 0) or (self._maxScrollY and self._maxScrollY > 0) + local hasHorizontalOverflow = (self._overflowX and self._maxScrollX > 0) or (self._maxScrollX and self._maxScrollX > 0) local scrolled = false -- Vertical scrolling if y ~= 0 and hasVerticalOverflow then local delta = -y * self.scrollSpeed -- Negative because wheel up = scroll up - local newScrollY = self._scrollY + delta - self:setScroll(nil, newScrollY) + if self.smoothScrollEnabled then + -- Set target for smooth scrolling instead of instant jump + self._targetScrollY = self._utils.clamp((self._targetScrollY or self._scrollY) + delta, 0, self._maxScrollY) + else + -- Instant scrolling (default behavior) + local newScrollY = self._scrollY + delta + self:setScroll(nil, newScrollY) + end scrolled = true end -- Horizontal scrolling if x ~= 0 and hasHorizontalOverflow then local delta = -x * self.scrollSpeed - local newScrollX = self._scrollX + delta - self:setScroll(newScrollX, nil) + if self.smoothScrollEnabled then + -- Set target for smooth scrolling instead of instant jump + self._targetScrollX = self._utils.clamp((self._targetScrollX or self._scrollX) + delta, 0, self._maxScrollX) + else + -- Instant scrolling (default behavior) + local newScrollX = self._scrollX + delta + self:setScroll(newScrollX, nil) + end scrolled = true end @@ -619,11 +642,18 @@ function ScrollManager:getState() return { _scrollX = self._scrollX or 0, _scrollY = self._scrollY or 0, + _targetScrollX = self._targetScrollX, + _targetScrollY = self._targetScrollY, _scrollbarDragging = self._scrollbarDragging or false, _hoveredScrollbar = self._hoveredScrollbar, _scrollbarDragOffset = self._scrollbarDragOffset or 0, _scrollbarHoveredVertical = self._scrollbarHoveredVertical or false, _scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal or false, + scrollBarStyle = self.scrollBarStyle, + _overflowX = self._overflowX, + _overflowY = self._overflowY, + _contentWidth = self._contentWidth, + _contentHeight = self._contentHeight, } end @@ -672,6 +702,34 @@ function ScrollManager:setState(state) if state._scrollbarHoveredHorizontal ~= nil then self._scrollbarHoveredHorizontal = state._scrollbarHoveredHorizontal end + + if state.scrollBarStyle ~= nil then + self.scrollBarStyle = state.scrollBarStyle + end + + if state._overflowX ~= nil then + self._overflowX = state._overflowX + end + + if state._overflowY ~= nil then + self._overflowY = state._overflowY + end + + if state._contentWidth ~= nil then + self._contentWidth = state._contentWidth + end + + if state._contentHeight ~= nil then + self._contentHeight = state._contentHeight + end + + if state._targetScrollX ~= nil then + self._targetScrollX = state._targetScrollX + end + + if state._targetScrollY ~= nil then + self._targetScrollY = state._targetScrollY + end end --- Handle touch press for scrolling @@ -795,6 +853,29 @@ end --- Update momentum scrolling (call every frame with dt) ---@param dt number Delta time in seconds function ScrollManager:update(dt) + -- Smooth scroll interpolation + if self._targetScrollX or self._targetScrollY then + if self._targetScrollY then + local diff = self._targetScrollY - self._scrollY + if math.abs(diff) > 0.5 then + self._scrollY = self._scrollY + diff * self._smoothScrollSpeed + else + self._scrollY = self._targetScrollY + self._targetScrollY = nil + end + end + + if self._targetScrollX then + local diff = self._targetScrollX - self._scrollX + if math.abs(diff) > 0.5 then + self._scrollX = self._scrollX + diff * self._smoothScrollSpeed + else + self._scrollX = self._targetScrollX + self._targetScrollX = nil + end + end + end + if not self._momentumScrolling then -- Handle bounce back if overscrolled if self.bounceEnabled then diff --git a/modules/Theme.lua b/modules/Theme.lua index 0dd3020..3f95a83 100644 --- a/modules/Theme.lua +++ b/modules/Theme.lua @@ -64,6 +64,10 @@ local function validateThemeDefinition(definition) return false, "Theme 'fonts' must be a table" end + if definition.scrollbars and type(definition.scrollbars) ~= "table" then + return false, "Theme 'scrollbars' must be a table" + end + return true, nil end @@ -331,6 +335,7 @@ end ---@field name string ---@field atlas string|love.Image? -- Optional: global atlas (can be overridden per component) ---@field components table +---@field scrollbars table? -- Optional: scrollbar component definitions (uses ThemeComponent format) ---@field colors table? ---@field fonts table? -- Optional: font family definitions (name -> path) ---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: default multiplier for auto-sized content dimensions @@ -340,6 +345,7 @@ end ---@field atlas love.Image? -- Optional: global atlas ---@field atlasData love.ImageData? ---@field components table +---@field scrollbars table -- Scrollbar component definitions ---@field colors table ---@field fonts table -- Font family definitions ---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: default multiplier for auto-sized content dimensions @@ -409,6 +415,7 @@ function Theme.new(definition) end self.components = definition.components or {} + self.scrollbars = definition.scrollbars or {} self.colors = definition.colors or {} self.fonts = definition.fonts or {} self.contentAutoSizingMultiplier = definition.contentAutoSizingMultiplier or nil @@ -552,6 +559,84 @@ function Theme.new(definition) end end + -- Load scrollbar-specific atlases and process 9-patch definitions + -- Scrollbars can have 'bar' and 'frame' subcomponents + for scrollbarName, scrollbarDef in pairs(self.scrollbars) do + -- Handle scrollbar definitions with bar/frame subcomponents + if scrollbarDef.bar or scrollbarDef.frame then + -- Process 'bar' subcomponent + if scrollbarDef.bar then + if type(scrollbarDef.bar) == "string" then + -- Convert string path to ThemeComponent structure + local barComponent = { atlas = scrollbarDef.bar } + loadAtlasWithNinePatch(barComponent, scrollbarDef.bar, "for scrollbar '" .. scrollbarName .. ".bar'") + if barComponent.insets then + createRegionsFromInsets(barComponent, barComponent._loadedAtlas or self.atlas) + end + scrollbarDef.bar = barComponent + elseif type(scrollbarDef.bar) == "table" then + -- Already a ThemeComponent structure, process it + if scrollbarDef.bar.atlas and type(scrollbarDef.bar.atlas) == "string" then + loadAtlasWithNinePatch(scrollbarDef.bar, scrollbarDef.bar.atlas, "for scrollbar '" .. scrollbarName .. ".bar'") + end + if scrollbarDef.bar.insets then + createRegionsFromInsets(scrollbarDef.bar, scrollbarDef.bar._loadedAtlas or self.atlas) + end + end + end + + -- Process 'frame' subcomponent + if scrollbarDef.frame then + if type(scrollbarDef.frame) == "string" then + -- Convert string path to ThemeComponent structure + local frameComponent = { atlas = scrollbarDef.frame } + loadAtlasWithNinePatch(frameComponent, scrollbarDef.frame, "for scrollbar '" .. scrollbarName .. ".frame'") + if frameComponent.insets then + createRegionsFromInsets(frameComponent, frameComponent._loadedAtlas or self.atlas) + end + scrollbarDef.frame = frameComponent + elseif type(scrollbarDef.frame) == "table" then + -- Already a ThemeComponent structure, process it + if scrollbarDef.frame.atlas and type(scrollbarDef.frame.atlas) == "string" then + loadAtlasWithNinePatch(scrollbarDef.frame, scrollbarDef.frame.atlas, "for scrollbar '" .. scrollbarName .. ".frame'") + end + if scrollbarDef.frame.insets then + createRegionsFromInsets(scrollbarDef.frame, scrollbarDef.frame._loadedAtlas or self.atlas) + end + end + end + else + -- Treat as a single ThemeComponent (no bar/frame split) + if scrollbarDef.atlas then + if type(scrollbarDef.atlas) == "string" then + loadAtlasWithNinePatch(scrollbarDef, scrollbarDef.atlas, "for scrollbar '" .. scrollbarName .. "'") + else + scrollbarDef._loadedAtlas = scrollbarDef.atlas + end + end + + if scrollbarDef.insets then + createRegionsFromInsets(scrollbarDef, self.atlas) + end + + if scrollbarDef.states then + for stateName, stateComponent in pairs(scrollbarDef.states) do + if stateComponent.atlas then + if type(stateComponent.atlas) == "string" then + loadAtlasWithNinePatch(stateComponent, stateComponent.atlas, "for scrollbar '" .. scrollbarName .. "' state '" .. stateName .. "'") + else + stateComponent._loadedAtlas = stateComponent.atlas + end + end + + if stateComponent.insets then + createRegionsFromInsets(stateComponent, scrollbarDef._loadedAtlas or self.atlas) + end + end + end + end + end + return self end @@ -646,6 +731,50 @@ function Theme.getComponent(componentName, state) return component end +--- Get the first (default) scrollbar from the active theme +--- Returns the first scrollbar component in insertion order +---@return ThemeComponent? scrollbar Returns first scrollbar component or nil if no scrollbars defined +function Theme.getDefaultScrollbar() + if not activeTheme or not activeTheme.scrollbars then + return nil + end + + -- Return first scrollbar in insertion order (Lua 5.3+ preserves order) + for _, scrollbar in pairs(activeTheme.scrollbars) do + return scrollbar + end + + return nil +end + +--- Retrieve themed scrollbar components for consistent scrollbar styling +--- Use this to apply theme-based scrollbar appearance to scrollable elements +---@param scrollbarName string? Name of the scrollbar style (e.g., "v1", "v2"). If nil, returns default (first) scrollbar +---@param state string? Optional state name (e.g., "hover", "pressed") - currently unused for scrollbars +---@return ThemeComponent? scrollbar Returns scrollbar component or nil if not found +function Theme.getScrollbar(scrollbarName, state) + if not activeTheme or not activeTheme.scrollbars then + return nil + end + + -- If no scrollbarName specified, return default (first) scrollbar + if not scrollbarName then + return Theme.getDefaultScrollbar() + end + + local scrollbar = activeTheme.scrollbars[scrollbarName] + if not scrollbar then + return nil + end + + -- Check for state-specific override (if scrollbar supports states in the future) + if state and scrollbar.states and scrollbar.states[state] then + return scrollbar.states[state] + end + + return scrollbar +end + --- Access theme-defined fonts for consistent typography across your UI --- Use this to load fonts specified in your theme definition ---@param fontName string Name of the font family (e.g., "default", "heading") @@ -889,6 +1018,26 @@ function ThemeManager:getStateComponent() return component end +---Get a scrollbar component from the theme +---@param scrollbarName string? The scrollbar style name (e.g., "v1", "v2"). If nil, returns default (first) scrollbar +---@return ThemeComponent? scrollbar The scrollbar component, or nil if not found +function ThemeManager:getScrollbarComponent(scrollbarName) + local themeToUse = self:getTheme() + if not themeToUse or not themeToUse.scrollbars or type(themeToUse.scrollbars) ~= "table" then + return nil + end + + -- If no scrollbarName specified, return default (first) scrollbar + if not scrollbarName then + for _, scrollbar in pairs(themeToUse.scrollbars) do + return scrollbar + end + return nil + end + + return themeToUse.scrollbars[scrollbarName] +end + ---Get a style property from the current state component ---@param property string The property name ---@return any? value The property value, or nil if not found @@ -1219,6 +1368,69 @@ function Theme.validateTheme(theme, options) end end + -- Scrollbars validation (optional) + if theme.scrollbars ~= nil then + if type(theme.scrollbars) ~= "table" then + table.insert(errors, "Theme 'scrollbars' must be a table") + else + for scrollbarName, scrollbarDef in pairs(theme.scrollbars) do + if type(scrollbarDef) == "table" then + -- Check if it has bar/frame subcomponents + if scrollbarDef.bar or scrollbarDef.frame then + -- Validate bar subcomponent + if scrollbarDef.bar ~= nil then + if type(scrollbarDef.bar) ~= "string" and type(scrollbarDef.bar) ~= "table" then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' bar must be a string or table") + end + end + -- Validate frame subcomponent + if scrollbarDef.frame ~= nil then + if type(scrollbarDef.frame) ~= "string" and type(scrollbarDef.frame) ~= "table" then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' frame must be a string or table") + end + end + else + -- Validate as a single ThemeComponent + -- Validate atlas if present + if scrollbarDef.atlas ~= nil and type(scrollbarDef.atlas) ~= "string" then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' atlas must be a string") + end + + -- Validate insets if present + if scrollbarDef.insets ~= nil then + if type(scrollbarDef.insets) ~= "table" then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets must be a table") + else + for _, side in ipairs({ "left", "top", "right", "bottom" }) do + if scrollbarDef.insets[side] == nil then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets must have '" .. side .. "' field") + elseif type(scrollbarDef.insets[side]) ~= "number" then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets." .. side .. " must be a number") + elseif scrollbarDef.insets[side] < 0 then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets." .. side .. " must be non-negative") + end + end + end + end + + -- Validate states if present + if scrollbarDef.states ~= nil then + if type(scrollbarDef.states) ~= "table" then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' states must be a table") + else + for stateName, stateComponent in pairs(scrollbarDef.states) do + if type(stateComponent) ~= "table" then + table.insert(errors, "Scrollbar '" .. scrollbarName .. "' state '" .. stateName .. "' must be a table") + end + end + end + end + end + end + end + end + end + -- contentAutoSizingMultiplier validation (optional) if theme.contentAutoSizingMultiplier ~= nil then if type(theme.contentAutoSizingMultiplier) ~= "table" then @@ -1254,6 +1466,7 @@ function Theme.validateTheme(theme, options) name = true, atlas = true, components = true, + scrollbars = true, colors = true, fonts = true, contentAutoSizingMultiplier = true, @@ -1330,6 +1543,11 @@ function Theme.sanitizeTheme(theme) sanitized.components = theme.components end + -- Sanitize scrollbars (preserve as-is, they're complex like components) + if type(theme.scrollbars) == "table" then + sanitized.scrollbars = theme.scrollbars + end + -- Sanitize contentAutoSizingMultiplier if type(theme.contentAutoSizingMultiplier) == "table" then sanitized.contentAutoSizingMultiplier = {} diff --git a/modules/types.lua b/modules/types.lua index 900a725..66da540 100644 --- a/modules/types.lua +++ b/modules/types.lua @@ -121,6 +121,8 @@ local AnimationProps = {} ---@field scrollbarRadius number? -- Corner radius for scrollbar (default: 6) ---@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2) ---@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 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/scripts/create-profile-packages.sh b/scripts/create-profile-packages.sh index 4701d05..524f9bf 100755 --- a/scripts/create-profile-packages.sh +++ b/scripts/create-profile-packages.sh @@ -89,7 +89,7 @@ for profile in minimal slim default full; do # Create temp directory TEMP_DIR=$(mktemp -d) BUILD_DIR="${TEMP_DIR}/flexlove" - + echo " → Creating build directory: $BUILD_DIR" mkdir -p "$BUILD_DIR/modules" || { echo -e "${RED}Error: Failed to create build directory${NC}" @@ -174,12 +174,12 @@ EOF if [ "$profile" == "default" ] || [ "$profile" == "full" ]; then echo " → Copying themes/" mkdir -p "$BUILD_DIR/themes" - + # Copy README if [ -f "themes/README.md" ]; then cp "themes/README.md" "$BUILD_DIR/themes/" fi - + # Copy theme files as .example.lua if [ -f "themes/metal.lua" ]; then cp "themes/metal.lua" "$BUILD_DIR/themes/metal.example.lua" diff --git a/testing/__tests__/flexlove_test.lua b/testing/__tests__/flexlove_test.lua index 8d3e653..37cf4b6 100644 --- a/testing/__tests__/flexlove_test.lua +++ b/testing/__tests__/flexlove_test.lua @@ -1302,6 +1302,166 @@ function TestFlexLoveUnhappyPaths:testImmediateModeFrameEdgeCases() luaunit.assertTrue(true) end +-- Test: scrollSpeed prop is properly passed to ScrollManager in immediate mode +function TestFlexLove:testScrollSpeedInImmediateMode() + FlexLove.setMode("immediate") + + FlexLove.beginFrame() + local element = FlexLove.new({ + id = "scrollableElement", + width = 200, + height = 200, + overflow = "auto", + scrollSpeed = 75, -- Custom scroll speed + }) + + -- Add children to make it scrollable + for i = 1, 10 do + FlexLove.new({ + parent = element, + width = 180, + height = 50, + }) + end + FlexLove.endFrame() + + -- Verify scrollSpeed was set correctly + luaunit.assertEquals(element.scrollSpeed, 75) + luaunit.assertNotNil(element._scrollManager) + luaunit.assertEquals(element._scrollManager.scrollSpeed, 75) + + -- Test another frame to ensure scrollSpeed persists + FlexLove.beginFrame() + local element2 = FlexLove.new({ + id = "scrollableElement", + width = 200, + height = 200, + overflow = "auto", + scrollSpeed = 75, + }) + + for i = 1, 10 do + FlexLove.new({ + parent = element2, + width = 180, + height = 50, + }) + end + FlexLove.endFrame() + + -- Verify scrollSpeed is still correct after recreating element + luaunit.assertEquals(element2.scrollSpeed, 75) + luaunit.assertEquals(element2._scrollManager.scrollSpeed, 75) +end + +-- Test: smoothScrollEnabled prop is properly passed to ScrollManager +function TestFlexLove:testSmoothScrollEnabledProp() + FlexLove.setMode("immediate") + + FlexLove.beginFrame() + local element = FlexLove.new({ + id = "smoothScrollElement", + width = 200, + height = 200, + overflow = "auto", + smoothScrollEnabled = true, + }) + + for i = 1, 10 do + FlexLove.new({ + parent = element, + width = 180, + height = 50, + }) + end + FlexLove.endFrame() + + -- Verify smoothScrollEnabled was set correctly + luaunit.assertNotNil(element._scrollManager) + luaunit.assertTrue(element._scrollManager.smoothScrollEnabled) +end + +-- Test: scrollSpeed must be provided every frame in immediate mode +function TestFlexLove:testScrollSpeedMustBeProvidedEveryFrame() + FlexLove.setMode("immediate") + + -- Frame 1: Set custom scrollSpeed + FlexLove.beginFrame() + local element1 = FlexLove.new({ + id = "scrollSpeedTest", + width = 200, + height = 200, + overflow = "auto", + scrollSpeed = 50, + }) + for i = 1, 10 do + FlexLove.new({ parent = element1, width = 180, height = 50 }) + end + FlexLove.endFrame() + luaunit.assertEquals(element1._scrollManager.scrollSpeed, 50) + + -- Frame 2: Forget to provide scrollSpeed (should default to 20) + FlexLove.beginFrame() + local element2 = FlexLove.new({ + id = "scrollSpeedTest", + width = 200, + height = 200, + overflow = "auto", + -- scrollSpeed NOT provided - will default to 20 + }) + for i = 1, 10 do + FlexLove.new({ parent = element2, width = 180, height = 50 }) + end + FlexLove.endFrame() + + -- In immediate mode, props must be provided every frame + luaunit.assertEquals(element2._scrollManager.scrollSpeed, 20) +end + +-- Test: smooth scrolling actually interpolates scroll position +function TestFlexLove:testSmoothScrollingInterpolation() + FlexLove.setMode("retained") + + local element = FlexLove.new({ + width = 200, + height = 200, + overflow = "auto", + smoothScrollEnabled = true, + }) + + for i = 1, 20 do + FlexLove.new({ + parent = element, + width = 180, + height = 50, + }) + end + + -- Manually set overflow state (normally done by layout) + element._scrollManager._overflowY = true + element._scrollManager._maxScrollY = 800 -- 20 * 50 - 200 + + -- Trigger wheel scroll + element:_handleWheelScroll(0, -1) -- Scroll down + + -- Should set target, not immediate scroll + luaunit.assertNotNil(element._scrollManager._targetScrollY) + local initialScroll = element._scrollManager._scrollY + local targetScroll = element._scrollManager._targetScrollY + + -- Initial scroll should be 0, target should be scrollSpeed (default 20) + luaunit.assertEquals(initialScroll, 0) + luaunit.assertEquals(targetScroll, 20) + + -- Update should interpolate towards target + element:update(0.016) -- One frame at 60fps + local afterUpdate = element._scrollManager._scrollY + + -- Scroll position should have moved towards target + luaunit.assertTrue(afterUpdate > initialScroll) + luaunit.assertTrue(afterUpdate <= targetScroll) +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 85ab6c7..c165ce2 100644 --- a/themes/metal.lua +++ b/themes/metal.lua @@ -3,6 +3,16 @@ local Color = require("libs.FlexLove").Color return { name = "Metal Theme", contentAutoSizingMultiplier = { width = 1.05, height = 1.1 }, + scrollbars = { + v1 = { + bar = "themes/metal/Button/Button01a_1.9.png", + frame = "themes/metal/Frame/Frame01a.9.png", + }, + v2 = { + bar = "themes/metal/Button/Button01a_1.9.png", + frame = "themes/metal/Frame/Frame01a.9.png", + }, + }, components = { framev1 = { atlas = "themes/metal/Frame/Frame01a.9.png",