---@class ScrollManager ---@field overflow string -- "visible"|"hidden"|"auto"|"scroll" ---@field overflowX string? -- X-axis specific overflow (overrides overflow) ---@field overflowY string? -- Y-axis specific overflow (overrides overflow) ---@field scrollbarWidth number -- Width/height of scrollbar track ---@field scrollbarColor Color -- Scrollbar thumb color ---@field scrollbarTrackColor Color -- Scrollbar track background color ---@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 invertScroll boolean -- Invert mouse wheel scroll direction (default: false) ---@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 scrollbarPlacement string -- "reserve-space"|"overlay" -- Whether scrollbar reserves space or overlays content (default: "reserve-space") ---@field touchScrollEnabled boolean -- Enable touch scrolling ---@field momentumScrollEnabled boolean -- Enable momentum scrolling ---@field bounceEnabled boolean -- Enable bounce effects at boundaries ---@field scrollFriction number -- Friction coefficient for momentum (0.95-0.98) ---@field bounceStiffness number -- Bounce spring constant (0.1-0.3) ---@field maxOverscroll number -- Maximum overscroll distance (pixels) ---@field _overflowX boolean -- True if content overflows horizontally ---@field _overflowY boolean -- True if content overflows vertically ---@field _contentWidth number -- Total content width (including overflow) ---@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 ---@field _scrollbarHoveredHorizontal boolean -- True if mouse is over horizontal scrollbar ---@field _scrollbarDragging boolean -- True if currently dragging a scrollbar ---@field _hoveredScrollbar string? -- "vertical" or "horizontal" when dragging ---@field _scrollbarDragOffset number -- DEPRECATED: Offset from thumb top when drag started (kept for compatibility) ---@field _dragStartMouseX number -- Mouse X position when drag started ---@field _dragStartMouseY number -- Mouse Y position when drag started ---@field _dragStartScrollX number -- Scroll X position when drag started ---@field _dragStartScrollY number -- Scroll Y position when drag started ---@field _scrollbarPressHandled boolean -- Track if scrollbar press was handled this frame ---@field _touchScrolling boolean -- True if currently touch scrolling ---@field _scrollVelocityX number -- Current horizontal scroll velocity (px/s) ---@field _scrollVelocityY number -- Current vertical scroll velocity (px/s) ---@field _momentumScrolling boolean -- True if momentum scrolling is active ---@field _lastTouchTime number -- Timestamp of last touch move ---@field _lastTouchX number -- Last touch X position ---@field _lastTouchY number -- Last touch Y position ---@field _Color table ---@field _utils table ---@field _ErrorHandler table? ErrorHandler module dependency local ScrollManager = {} ScrollManager.__index = ScrollManager --- Initialize module with shared dependencies ---@param deps table Dependencies {ErrorHandler} function ScrollManager.init(deps) if type(deps) == "table" then ScrollManager._ErrorHandler = deps.ErrorHandler end end --- Create a new ScrollManager instance ---@param config table Configuration options ---@param deps table Dependencies {Color: Color module, utils: utils module} ---@return ScrollManager function ScrollManager.new(config, deps) local Color = deps.Color local self = setmetatable({}, ScrollManager) -- Store dependencies for instance methods self._Color = Color self._utils = deps.utils -- Configuration self.overflow = config.overflow or "hidden" self.overflowX = config.overflowX self.overflowY = config.overflowY -- Scrollbar appearance self.scrollbarWidth = config.scrollbarWidth or 12 self.scrollbarColor = config.scrollbarColor or Color.new(0.5, 0.5, 0.5, 0.8) self.scrollbarTrackColor = config.scrollbarTrackColor or Color.new(0.2, 0.2, 0.2, 0.5) self.scrollbarRadius = config.scrollbarRadius or 6 self.scrollbarPadding = config.scrollbarPadding or 2 self.scrollSpeed = config.scrollSpeed or 20 self.invertScroll = config.invertScroll or false self.scrollBarStyle = config.scrollBarStyle -- Theme scrollbar style name (nil = use default) -- scrollbarKnobOffset can be number or table {x, y} or {horizontal, vertical} -- Only normalize if actually provided (nil means use theme default) if config.scrollbarKnobOffset ~= nil then self.scrollbarKnobOffset = self._utils.normalizeOffsetTable(config.scrollbarKnobOffset, 0) else self.scrollbarKnobOffset = nil end -- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean} self.hideScrollbars = self._utils.normalizeBooleanTable(config.hideScrollbars, false) -- Scrollbar placement: "reserve-space" (default) or "overlay" self.scrollbarPlacement = config.scrollbarPlacement or "reserve-space" -- Touch scrolling configuration self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true self.momentumScrollEnabled = config.momentumScrollEnabled ~= false -- Default true self.bounceEnabled = config.bounceEnabled ~= false -- Default true self.scrollFriction = config.scrollFriction or 0.95 -- Exponential decay per frame self.bounceStiffness = config.bounceStiffness or 0.2 -- Spring constant self.maxOverscroll = config.maxOverscroll or 100 -- pixels -- Internal overflow state self._overflowX = false self._overflowY = false self._contentWidth = 0 self._contentHeight = 0 -- 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 -- Scrollbar interaction state self._scrollbarHoveredVertical = false self._scrollbarHoveredHorizontal = false self._scrollbarDragging = false self._hoveredScrollbar = nil -- "vertical" or "horizontal" self._scrollbarDragOffset = 0 -- DEPRECATED: kept for backward compatibility self._dragStartMouseX = 0 -- Mouse X position when drag started self._dragStartMouseY = 0 -- Mouse Y position when drag started self._dragStartScrollX = 0 -- Scroll X position when drag started self._dragStartScrollY = 0 -- Scroll Y position when drag started self._scrollbarPressHandled = false -- Touch scrolling state self._touchScrolling = false self._scrollVelocityX = 0 self._scrollVelocityY = 0 self._momentumScrolling = false self._lastTouchTime = 0 self._lastTouchX = 0 self._lastTouchY = 0 return self end --- Get the space reserved for scrollbars (width and height reduction) --- This is called BEFORE layout to reduce available space for children ---@param element Element The parent Element instance ---@return number reservedWidth, number reservedHeight function ScrollManager:getReservedSpace(element) if self.scrollbarPlacement ~= "reserve-space" then return 0, 0 end local overflowX = self.overflowX or self.overflow local overflowY = self.overflowY or self.overflow local reservedWidth = 0 local reservedHeight = 0 -- Reserve space for vertical scrollbar if overflow mode requires it if (overflowY == "scroll" or overflowY == "auto") and not self.hideScrollbars.vertical then reservedWidth = self.scrollbarWidth + (self.scrollbarPadding * 2) end -- Reserve space for horizontal scrollbar if overflow mode requires it if (overflowX == "scroll" or overflowX == "auto") and not self.hideScrollbars.horizontal then reservedHeight = self.scrollbarWidth + (self.scrollbarPadding * 2) end return reservedWidth, reservedHeight end --- Detect if content overflows container bounds ---@param element Element The parent Element instance function ScrollManager:detectOverflow(element) -- Reset overflow state self._overflowX = false self._overflowY = false self._contentWidth = element.width self._contentHeight = element.height -- Skip detection if overflow is visible (no clipping needed) local overflowX = self.overflowX or self.overflow local overflowY = self.overflowY or self.overflow if overflowX == "visible" and overflowY == "visible" then return end -- Calculate content bounds based on children if #element.children == 0 then return -- No children, no overflow end local minX, minY = 0, 0 local maxX, maxY = 0, 0 -- Content area starts after padding local contentX = element.x + element.padding.left local contentY = element.y + element.padding.top for _, child in ipairs(element.children) do -- Skip absolutely positioned children (they don't contribute to overflow) if not child._explicitlyAbsolute then -- Calculate child's margin box bounds relative to content area -- child.x/y is the border-box position, margins extend outside this local childMarginLeft = child.x - contentX - child.margin.left local childMarginTop = child.y - contentY - child.margin.top local childMarginRight = child.x - contentX + child:getBorderBoxWidth() + child.margin.right local childMarginBottom = child.y - contentY + child:getBorderBoxHeight() + child.margin.bottom -- Track the maximum extents (we ignore negative space from margins) maxX = math.max(maxX, childMarginRight) maxY = math.max(maxY, childMarginBottom) end end -- Calculate content dimensions self._contentWidth = maxX self._contentHeight = maxY -- Detect overflow (compare against content area, not total element size) -- The content area excludes padding local containerWidth = element.width - element.padding.left - element.padding.right local containerHeight = element.height - element.padding.top - element.padding.bottom -- If scrollbarPlacement is "reserve-space", we need to subtract the reserved space -- because the layout already accounted for it, but element.width/height are still full size if self.scrollbarPlacement == "reserve-space" then local reservedWidth, reservedHeight = self:getReservedSpace() containerWidth = containerWidth - reservedWidth containerHeight = containerHeight - reservedHeight end self._overflowX = self._contentWidth > containerWidth self._overflowY = self._contentHeight > containerHeight -- Calculate maximum scroll bounds self._maxScrollX = math.max(0, self._contentWidth - containerWidth) self._maxScrollY = math.max(0, self._contentHeight - containerHeight) -- Clamp current scroll position to new bounds self._scrollX = self._utils.clamp(self._scrollX, 0, self._maxScrollX) self._scrollY = self._utils.clamp(self._scrollY, 0, self._maxScrollY) end --- Set scroll position with bounds clamping ---@param x number? -- X scroll position (nil to keep current) ---@param y number? -- Y scroll position (nil to keep current) function ScrollManager:setScroll(x, y) if x ~= nil then self._scrollX = self._utils.clamp(x, 0, self._maxScrollX) end if y ~= nil then self._scrollY = self._utils.clamp(y, 0, self._maxScrollY) end end --- Get current scroll position ---@return number scrollX, number scrollY function ScrollManager:getScroll() return self._scrollX, self._scrollY end --- Scroll by delta amount ---@param dx number? -- X delta (nil for no change) ---@param dy number? -- Y delta (nil for no change) function ScrollManager:scrollBy(dx, dy) if dx then self._scrollX = self._utils.clamp(self._scrollX + dx, 0, self._maxScrollX) end if dy then self._scrollY = self._utils.clamp(self._scrollY + dy, 0, self._maxScrollY) end end --- Get maximum scroll bounds ---@return number maxScrollX, number maxScrollY function ScrollManager:getMaxScroll() return self._maxScrollX, self._maxScrollY end --- Get scroll percentage (0-1) ---@return number percentX, number percentY function ScrollManager:getScrollPercentage() local percentX = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 local percentY = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 return percentX, percentY end --- Check if element has overflow ---@return boolean hasOverflowX, boolean hasOverflowY function ScrollManager:hasOverflow() return self._overflowX, self._overflowY end --- Get content dimensions (including overflow) ---@return number contentWidth, number contentHeight function ScrollManager:getContentSize() return self._contentWidth, self._contentHeight end --- Calculate scrollbar dimensions and positions ---@param element Element The parent Element instance ---@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}} function ScrollManager:calculateScrollbarDimensions(element) local result = { vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 }, horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 }, } local overflowX = self.overflowX or self.overflow local overflowY = self.overflowY or self.overflow -- Vertical scrollbar -- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows if overflowY == "scroll" then -- Always show scrollbar for "scroll" mode result.vertical.visible = true result.vertical.trackHeight = element.height - (self.scrollbarPadding * 2) if self._overflowY then -- Content overflows, calculate proper thumb size local contentRatio = element.height / math.max(self._contentHeight, element.height) result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) -- Calculate thumb position based on scroll ratio local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight result.vertical.thumbY = maxThumbY * scrollRatio else -- No overflow, thumb fills entire track result.vertical.thumbHeight = result.vertical.trackHeight result.vertical.thumbY = 0 end elseif self._overflowY and overflowY == "auto" then -- Only show scrollbar when content actually overflows result.vertical.visible = true result.vertical.trackHeight = element.height - (self.scrollbarPadding * 2) -- Calculate thumb height based on content ratio local contentRatio = element.height / math.max(self._contentHeight, element.height) result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) -- Calculate thumb position based on scroll ratio local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight result.vertical.thumbY = maxThumbY * scrollRatio end -- Horizontal scrollbar -- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows if overflowX == "scroll" then -- Always show scrollbar for "scroll" mode result.horizontal.visible = true result.horizontal.trackWidth = element.width - (self.scrollbarPadding * 2) if self._overflowX then -- Content overflows, calculate proper thumb size local contentRatio = element.width / math.max(self._contentWidth, element.width) result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) -- Calculate thumb position based on scroll ratio local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth result.horizontal.thumbX = maxThumbX * scrollRatio else -- No overflow, thumb fills entire track result.horizontal.thumbWidth = result.horizontal.trackWidth result.horizontal.thumbX = 0 end elseif self._overflowX and overflowX == "auto" then -- Only show scrollbar when content actually overflows result.horizontal.visible = true result.horizontal.trackWidth = element.width - (self.scrollbarPadding * 2) -- Calculate thumb width based on content ratio local contentRatio = element.width / math.max(self._contentWidth, element.width) result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) -- Calculate thumb position based on scroll ratio local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth result.horizontal.thumbX = maxThumbX * scrollRatio end return result end --- Get scrollbar at mouse position ---@param element Element The parent Element instance ---@param mouseX number ---@param mouseY number ---@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"} function ScrollManager:getScrollbarAtPosition(element, mouseX, mouseY) local overflowX = self.overflowX or self.overflow local overflowY = self.overflowY or self.overflow if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then return nil end local dims = self:calculateScrollbarDimensions(element) local x, y = element.x, element.y local w, h = element.width, element.height -- Check vertical scrollbar (only if not hidden) if dims.vertical.visible and not self.hideScrollbars.vertical then -- Position scrollbar within content area (x, y is border-box origin) local contentX = x + element.padding.left local contentY = y + element.padding.top local trackX = contentX + w - self.scrollbarWidth - self.scrollbarPadding local trackY = contentY + self.scrollbarPadding local trackW = self.scrollbarWidth local trackH = dims.vertical.trackHeight if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then -- Check if over thumb local thumbY = trackY + dims.vertical.thumbY local thumbH = dims.vertical.thumbHeight if mouseY >= thumbY and mouseY <= thumbY + thumbH then return { component = "vertical", region = "thumb" } else return { component = "vertical", region = "track" } end end end -- Check horizontal scrollbar (only if not hidden) if dims.horizontal.visible and not self.hideScrollbars.horizontal then -- Position scrollbar within content area (x, y is border-box origin) local contentX = x + element.padding.left local contentY = y + element.padding.top local trackX = contentX + self.scrollbarPadding local trackY = contentY + h - self.scrollbarWidth - self.scrollbarPadding local trackW = dims.horizontal.trackWidth local trackH = self.scrollbarWidth if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then -- Check if over thumb local thumbX = trackX + dims.horizontal.thumbX local thumbW = dims.horizontal.thumbWidth if mouseX >= thumbX and mouseX <= thumbX + thumbW then return { component = "horizontal", region = "thumb" } else return { component = "horizontal", region = "track" } end end end return nil end --- Handle scrollbar mouse press ---@param element Element The parent Element instance ---@param mouseX number ---@param mouseY number ---@param button number ---@return boolean -- True if event was consumed function ScrollManager:handleMousePress(element, mouseX, mouseY, button) if button ~= 1 then return false end -- Only left click local scrollbar = self:getScrollbarAtPosition(element, mouseX, mouseY) if not scrollbar then return false end if scrollbar.region == "thumb" then -- Start dragging thumb - store start positions for relative movement tracking self._scrollbarDragging = true self._hoveredScrollbar = scrollbar.component -- Store drag start positions for relative movement calculation self._dragStartMouseX = mouseX self._dragStartMouseY = mouseY self._dragStartScrollX = self._scrollX self._dragStartScrollY = self._scrollY return true -- Event consumed elseif scrollbar.region == "track" then self:_scrollToTrackPosition(element, mouseX, mouseY, scrollbar.component) return true end return false end --- Handle scrollbar drag ---@param element Element The parent Element instance ---@param mouseX number ---@param mouseY number ---@return boolean -- True if event was consumed function ScrollManager:handleMouseMove(element, mouseX, mouseY) if not self._scrollbarDragging then return false end local dims = self:calculateScrollbarDimensions(element) if self._hoveredScrollbar == "vertical" then local trackH = dims.vertical.trackHeight local thumbH = dims.vertical.thumbHeight -- Calculate relative mouse movement from drag start local mouseDeltaY = mouseY - self._dragStartMouseY -- Convert mouse delta to scroll delta -- scrollDelta / maxScroll = thumbDelta / (trackHeight - thumbHeight) local scrollableTrackHeight = trackH - thumbH local scrollDelta = scrollableTrackHeight > 0 and (mouseDeltaY / scrollableTrackHeight) * self._maxScrollY or 0 local newScrollY = self._dragStartScrollY + scrollDelta newScrollY = self._utils.clamp(newScrollY, 0, self._maxScrollY) self:setScroll(nil, newScrollY) return true elseif self._hoveredScrollbar == "horizontal" then local trackW = dims.horizontal.trackWidth local thumbW = dims.horizontal.thumbWidth -- Calculate relative mouse movement from drag start local mouseDeltaX = mouseX - self._dragStartMouseX -- Convert mouse delta to scroll delta local scrollableTrackWidth = trackW - thumbW local scrollDelta = scrollableTrackWidth > 0 and (mouseDeltaX / scrollableTrackWidth) * self._maxScrollX or 0 -- Apply delta to starting scroll position local newScrollX = self._dragStartScrollX + scrollDelta newScrollX = self._utils.clamp(newScrollX, 0, self._maxScrollX) self:setScroll(newScrollX, nil) return true end return false end --- Handle scrollbar release ---@param button number ---@return boolean -- True if event was consumed function ScrollManager:handleMouseRelease(button) if button ~= 1 then return false end if self._scrollbarDragging then self._scrollbarDragging = false return true end return false end --- Scroll to track click position (internal helper) ---@param element Element The parent Element instance ---@param mouseX number ---@param mouseY number ---@param component string -- "vertical" or "horizontal" function ScrollManager:_scrollToTrackPosition(element, mouseX, mouseY, component) local dims = self:calculateScrollbarDimensions(element) if component == "vertical" then local contentY = element.y + element.padding.top local trackY = contentY + self.scrollbarPadding local trackH = dims.vertical.trackHeight local thumbH = dims.vertical.thumbHeight -- Calculate target thumb position (centered on click) local targetThumbY = mouseY - trackY - (thumbH / 2) targetThumbY = self._utils.clamp(targetThumbY, 0, trackH - thumbH) -- Convert to scroll position local scrollRatio = (trackH - thumbH) > 0 and (targetThumbY / (trackH - thumbH)) or 0 local newScrollY = scrollRatio * self._maxScrollY self:setScroll(nil, newScrollY) elseif component == "horizontal" then local contentX = element.x + element.padding.left local trackX = contentX + self.scrollbarPadding local trackW = dims.horizontal.trackWidth local thumbW = dims.horizontal.thumbWidth -- Calculate target thumb position (centered on click) local targetThumbX = mouseX - trackX - (thumbW / 2) targetThumbX = self._utils.clamp(targetThumbX, 0, trackW - thumbW) -- Convert to scroll position local scrollRatio = (trackW - thumbW) > 0 and (targetThumbX / (trackW - thumbW)) or 0 local newScrollX = scrollRatio * self._maxScrollX self:setScroll(newScrollX, nil) end end --- Handle mouse wheel scrolling ---@param x number -- Horizontal scroll amount ---@param y number -- Vertical scroll amount ---@return boolean -- True if scroll was handled function ScrollManager:handleWheel(x, y) local overflowX = self.overflowX or self.overflow local overflowY = self.overflowY or self.overflow if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then return false end -- 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 if self.invertScroll then delta = -delta -- Invert scroll direction if enabled end 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 if self.invertScroll then delta = -delta -- Invert scroll direction if enabled end 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 return scrolled end --- Update scrollbar hover state based on mouse position ---@param element Element The parent Element instance ---@param mouseX number ---@param mouseY number function ScrollManager:updateHoverState(element, mouseX, mouseY) local scrollbar = self:getScrollbarAtPosition(element, mouseX, mouseY) if scrollbar then if scrollbar.component == "vertical" then self._scrollbarHoveredVertical = true self._scrollbarHoveredHorizontal = false elseif scrollbar.component == "horizontal" then self._scrollbarHoveredVertical = false self._scrollbarHoveredHorizontal = true end else self._scrollbarHoveredVertical = false self._scrollbarHoveredHorizontal = false end end --- Reset scrollbar press handled flag (call at start of frame) function ScrollManager:resetScrollbarPressFlag() self._scrollbarPressHandled = false end --- Check if scrollbar press was handled this frame ---@return boolean function ScrollManager:wasScrollbarPressHandled() return self._scrollbarPressHandled end --- Set scrollbar press handled flag function ScrollManager:setScrollbarPressHandled() self._scrollbarPressHandled = true end --- Get state for immediate mode persistence ---@return table State data 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, -- Deprecated but kept for compatibility _dragStartMouseX = self._dragStartMouseX or 0, _dragStartMouseY = self._dragStartMouseY or 0, _dragStartScrollX = self._dragStartScrollX or 0, _dragStartScrollY = self._dragStartScrollY or 0, _scrollbarHoveredVertical = self._scrollbarHoveredVertical or false, _scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal or false, scrollBarStyle = self.scrollBarStyle, scrollbarKnobOffset = self.scrollbarKnobOffset, scrollbarPlacement = self.scrollbarPlacement, _overflowX = self._overflowX, _overflowY = self._overflowY, _contentWidth = self._contentWidth, _contentHeight = self._contentHeight, } end --- Set state from immediate mode persistence ---@param state table State data function ScrollManager:setState(state) if not state then return end -- Support both old (scrollX) and new (_scrollX) field names for backward compatibility if state._scrollX ~= nil then self._scrollX = state._scrollX elseif state.scrollX ~= nil then self._scrollX = state.scrollX end if state._scrollY ~= nil then self._scrollY = state._scrollY elseif state.scrollY ~= nil then self._scrollY = state.scrollY end if state._scrollbarDragging ~= nil then self._scrollbarDragging = state._scrollbarDragging elseif state.scrollbarDragging ~= nil then self._scrollbarDragging = state.scrollbarDragging end if state._hoveredScrollbar ~= nil then self._hoveredScrollbar = state._hoveredScrollbar elseif state.hoveredScrollbar ~= nil then self._hoveredScrollbar = state.hoveredScrollbar end if state._scrollbarDragOffset ~= nil then self._scrollbarDragOffset = state._scrollbarDragOffset elseif state.scrollbarDragOffset ~= nil then self._scrollbarDragOffset = state.scrollbarDragOffset end -- Restore drag start positions for relative movement tracking if state._dragStartMouseX ~= nil then self._dragStartMouseX = state._dragStartMouseX end if state._dragStartMouseY ~= nil then self._dragStartMouseY = state._dragStartMouseY end if state._dragStartScrollX ~= nil then self._dragStartScrollX = state._dragStartScrollX end if state._dragStartScrollY ~= nil then self._dragStartScrollY = state._dragStartScrollY end if state._scrollbarHoveredVertical ~= nil then self._scrollbarHoveredVertical = state._scrollbarHoveredVertical end if state._scrollbarHoveredHorizontal ~= nil then self._scrollbarHoveredHorizontal = state._scrollbarHoveredHorizontal end if state.scrollBarStyle ~= nil then self.scrollBarStyle = state.scrollBarStyle end if state.scrollbarKnobOffset ~= nil then self.scrollbarKnobOffset = self._utils.normalizeOffsetTable(state.scrollbarKnobOffset, 0) end if state.scrollbarPlacement ~= nil then self.scrollbarPlacement = state.scrollbarPlacement 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 ---@param touchX number ---@param touchY number ---@return boolean -- True if touch scroll started function ScrollManager:handleTouchPress(touchX, touchY) if not self.touchScrollEnabled then return false end local overflowX = self.overflowX or self.overflow local overflowY = self.overflowY or self.overflow if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then return false end -- Stop momentum scrolling if active if self._momentumScrolling then self._momentumScrolling = false self._scrollVelocityX = 0 self._scrollVelocityY = 0 end -- Start touch scrolling self._touchScrolling = true self._lastTouchX = touchX self._lastTouchY = touchY self._lastTouchTime = love.timer.getTime() return true end --- Handle touch move for scrolling ---@param touchX number ---@param touchY number ---@return boolean -- True if touch scroll was handled function ScrollManager:handleTouchMove(touchX, touchY) if not self._touchScrolling then return false end local currentTime = love.timer.getTime() local dt = currentTime - self._lastTouchTime if dt <= 0 then return false end -- Calculate delta and velocity local dx = touchX - self._lastTouchX local dy = touchY - self._lastTouchY -- Invert deltas (touch moves opposite to scroll) dx = -dx dy = -dy -- Calculate velocity (pixels per second) self._scrollVelocityX = dx / dt self._scrollVelocityY = dy / dt -- Apply scroll with bounce if enabled if self.bounceEnabled then -- Allow overscroll local newScrollX = self._scrollX + dx local newScrollY = self._scrollY + dy -- Clamp to max overscroll limits local minScrollX = -self.maxOverscroll local maxScrollX = self._maxScrollX + self.maxOverscroll local minScrollY = -self.maxOverscroll local maxScrollY = self._maxScrollY + self.maxOverscroll newScrollX = self._utils.clamp(newScrollX, minScrollX, maxScrollX) newScrollY = self._utils.clamp(newScrollY, minScrollY, maxScrollY) self._scrollX = newScrollX self._scrollY = newScrollY else -- Normal clamped scrolling self:scrollBy(dx, dy) end -- Update last touch state self._lastTouchX = touchX self._lastTouchY = touchY self._lastTouchTime = currentTime return true end --- Handle touch release for scrolling ---@return boolean -- True if touch scroll was active function ScrollManager:handleTouchRelease() if not self._touchScrolling then return false end self._touchScrolling = false -- Start momentum scrolling if enabled and velocity is significant if self.momentumScrollEnabled then local velocityThreshold = 50 -- pixels per second local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2) if totalVelocity > velocityThreshold then self._momentumScrolling = true else self._scrollVelocityX = 0 self._scrollVelocityY = 0 end else self._scrollVelocityX = 0 self._scrollVelocityY = 0 end return true 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 self:_updateBounce(dt) end return end -- Apply velocity to scroll position local dx = self._scrollVelocityX * dt local dy = self._scrollVelocityY * dt if self.bounceEnabled then -- Allow overscroll during momentum self._scrollX = self._scrollX + dx self._scrollY = self._scrollY + dy else self:scrollBy(dx, dy) end -- Apply friction (exponential decay) self._scrollVelocityX = self._scrollVelocityX * self.scrollFriction self._scrollVelocityY = self._scrollVelocityY * self.scrollFriction -- Stop momentum when velocity is very low local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2) if totalVelocity < 1 then self._momentumScrolling = false self._scrollVelocityX = 0 self._scrollVelocityY = 0 end -- Handle bounce back if overscrolled if self.bounceEnabled then self:_updateBounce(dt) end end --- Update bounce effect when overscrolled (internal) ---@param dt number Delta time in seconds function ScrollManager:_updateBounce(dt) local bounced = false -- Bounce back horizontal overscroll if self._scrollX < 0 then local springForce = -self._scrollX * self.bounceStiffness self._scrollX = self._scrollX + springForce if math.abs(self._scrollX) < 0.5 then self._scrollX = 0 end bounced = true elseif self._scrollX > self._maxScrollX then local overflow = self._scrollX - self._maxScrollX local springForce = -overflow * self.bounceStiffness self._scrollX = self._scrollX + springForce if math.abs(overflow) < 0.5 then self._scrollX = self._maxScrollX end bounced = true end -- Bounce back vertical overscroll if self._scrollY < 0 then local springForce = -self._scrollY * self.bounceStiffness self._scrollY = self._scrollY + springForce if math.abs(self._scrollY) < 0.5 then self._scrollY = 0 end bounced = true elseif self._scrollY > self._maxScrollY then local overflow = self._scrollY - self._maxScrollY local springForce = -overflow * self.bounceStiffness self._scrollY = self._scrollY + springForce if math.abs(overflow) < 0.5 then self._scrollY = self._maxScrollY end bounced = true end -- Stop momentum if bouncing if bounced and self._momentumScrolling then -- Reduce velocity during bounce self._scrollVelocityX = self._scrollVelocityX * 0.9 self._scrollVelocityY = self._scrollVelocityY * 0.9 end end --- Check if currently touch scrolling ---@return boolean function ScrollManager:isTouchScrolling() return self._touchScrolling end --- Check if currently momentum scrolling ---@return boolean function ScrollManager:isMomentumScrolling() return self._momentumScrolling end return ScrollManager