mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
265 lines
12 KiB
Lua
265 lines
12 KiB
Lua
-- modules/behaviors/Scrollable.lua
|
|
--
|
|
-- Concrete behavior: ScrollManager lifecycle (creation + immediate-mode
|
|
-- scrollbar interaction-state restore).
|
|
--
|
|
-- Scrollable owns the per-element ScrollManager instance — the subsystem that
|
|
-- manages overflow detection, scrollbar geometry, scroll position, and scrollbar
|
|
-- drag/hover interaction. It is the behavior-mode-unification replacement for
|
|
-- the former `Element:_initScrollManager` phase (~84 LOC) of Element.new
|
|
-- (behavior-mode-unification task 03 / landed as part of the task 08 capstone).
|
|
--
|
|
-- Attachment rule (shouldAttach): an element owns a ScrollManager exactly when
|
|
-- it declares an `overflow`, `overflowX`, or `overflowY` prop — mirroring the
|
|
-- legacy `if props.overflow or props.overflowX or props.overflowY then` guard
|
|
-- in `Element:_initScrollManager`. The ScrollManager is created and its
|
|
-- normalized fields are exposed back onto the element (so the Renderer /
|
|
-- ScrollManager delegates read `element.overflow` / `element.scrollbarWidth`
|
|
-- etc.) exactly as the legacy inline phase did.
|
|
--
|
|
-- Why onAttach reads `element._initProps` (not element fields): the scrollbar
|
|
-- configuration props (scrollbarWidth / scrollbarColor / scrollSpeed /
|
|
-- scrollbarPlacement / scrollbarBalance / invertScroll / smoothScrollEnabled /
|
|
-- scrollBarStyle / scrollbarKnobOffset / hideScrollbars / scrollbarRadius /
|
|
-- scrollbarPadding / scrollbarTrackColor / _scrollX / _scrollY) are listed in
|
|
-- SPECIAL_PROPS and therefore NOT bound onto the element by the schema-driven
|
|
-- `_applyProps` loop — they are consumed only by the ScrollManager constructor.
|
|
-- The locked behavior hook signature is `(element, ...)` with no props arg, so
|
|
-- the original construction props are stashed on the element as `_initProps` by
|
|
-- `Element:_construct` and read back here. (`overflow` / `overflowX` /
|
|
-- `overflowY` ARE bound onto the element by `_applyProps` so that
|
|
-- `Element:addChild`'s scroll-container auto-size guard sees them during
|
|
-- declarative-children processing in `_finalizeConstruction`, which runs BEFORE
|
|
-- this onAttach; onAttach then overwrites them with the ScrollManager's
|
|
-- normalized values, matching the legacy field-exposure order.)
|
|
--
|
|
-- onUpdate / onDraw / saveState / restoreState are deferred to the
|
|
-- behavior-driven update/draw tasks (09 / 12): the ScrollManager update,
|
|
-- interaction, scrollbar drawing, and state save/restore currently stay inline
|
|
-- in `Element:update` / `Element:draw` / `Element:saveState` /
|
|
-- `Element:restoreState` (delegated through the ScrollManager API bound in
|
|
-- `Element.init`). Those inline call sites are NOT behavioral `if` branches —
|
|
-- they are unconditional 1-line delegates — so leaving them in Element does not
|
|
-- regress the behavior-dispatch goals of tasks 09/12; task 09 will fold them
|
|
-- into Scrollable hooks.
|
|
--
|
|
-- State ownership (per the locked Behavior contract):
|
|
-- * Per-element runtime state lives ON THE ELEMENT (`element._scrollManager`,
|
|
-- `element.overflow`, `element._scrollX`, `element._scrollbarDragging`, ...).
|
|
-- * The behavior instance is stateless + immutable and shared across elements.
|
|
-- * Element-class-level dependencies (`Element._ScrollManager`,
|
|
-- `Element._scrollManagerDeps`, `Element._Context`, `Element._StateManager`)
|
|
-- are resolved from the owning element's metatable (the Element class set by
|
|
-- `Element:_construct`).
|
|
|
|
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
|
local Behavior = require(_pkg .. "Behavior")
|
|
|
|
-- Resolve the Element class from an element instance.
|
|
-- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE
|
|
-- the Element class, so this yields Element._ScrollManager,
|
|
-- Element._scrollManagerDeps, Element._Context, Element._StateManager without
|
|
-- threading deps through the hook signature.
|
|
local function ElementClass(element)
|
|
return getmetatable(element)
|
|
end
|
|
|
|
-- ----------------------------------------------------------------------------
|
|
-- shouldAttach (class-level predicate, no element required)
|
|
-- ----------------------------------------------------------------------------
|
|
|
|
-- Mirrors the legacy `if props.overflow or props.overflowX or props.overflowY`
|
|
-- guard. Uses `~= nil` (rather than truthiness) so that an explicit
|
|
-- `overflow = false` / `overflow = ""` does not spuriously attach — though in
|
|
-- practice overflow values are always strings or unset, matching the predicate
|
|
-- semantics of the other behaviors (Clickable / TextEditable / Selectable).
|
|
local function shouldAttach(props)
|
|
props = props or {}
|
|
return props.overflow ~= nil or props.overflowX ~= nil or props.overflowY ~= nil
|
|
end
|
|
|
|
-- ----------------------------------------------------------------------------
|
|
-- onAttach — create the ScrollManager + expose its fields + restore immediate-
|
|
-- mode scrollbar interaction state (formerly Element:_initScrollManager).
|
|
-- ----------------------------------------------------------------------------
|
|
|
|
local function onAttach(element)
|
|
local Element = ElementClass(element)
|
|
-- Construction props are stashed on the element by _construct (the scrollbar
|
|
-- config props are SPECIAL_PROPS and not bound as element fields).
|
|
local props = element._initProps or {}
|
|
|
|
element._scrollManager = Element._ScrollManager.new({
|
|
overflow = props.overflow,
|
|
overflowX = props.overflowX,
|
|
overflowY = props.overflowY,
|
|
scrollbarWidth = props.scrollbarWidth,
|
|
scrollbarColor = props.scrollbarColor,
|
|
scrollbarTrackColor = props.scrollbarTrackColor,
|
|
scrollbarRadius = props.scrollbarRadius,
|
|
scrollbarPadding = props.scrollbarPadding,
|
|
scrollSpeed = props.scrollSpeed,
|
|
invertScroll = props.invertScroll,
|
|
smoothScrollEnabled = props.smoothScrollEnabled,
|
|
scrollBarStyle = props.scrollBarStyle,
|
|
scrollbarKnobOffset = props.scrollbarKnobOffset,
|
|
hideScrollbars = props.hideScrollbars,
|
|
scrollbarPlacement = props.scrollbarPlacement,
|
|
scrollbarBalance = props.scrollbarBalance,
|
|
_scrollX = props._scrollX,
|
|
_scrollY = props._scrollY,
|
|
}, Element._scrollManagerDeps)
|
|
|
|
-- Expose ScrollManager properties for backward compatibility (Renderer access).
|
|
local sm = element._scrollManager
|
|
element.overflow = sm.overflow
|
|
element.overflowX = sm.overflowX
|
|
element.overflowY = sm.overflowY
|
|
element.scrollbarWidth = sm.scrollbarWidth
|
|
element.scrollbarColor = sm.scrollbarColor
|
|
element.scrollbarTrackColor = sm.scrollbarTrackColor
|
|
element.scrollbarRadius = sm.scrollbarRadius
|
|
element.scrollbarPadding = sm.scrollbarPadding
|
|
element.scrollSpeed = sm.scrollSpeed
|
|
element.invertScroll = sm.invertScroll
|
|
element.scrollBarStyle = sm.scrollBarStyle
|
|
element.scrollbarKnobOffset = sm.scrollbarKnobOffset
|
|
element.hideScrollbars = sm.hideScrollbars
|
|
element.scrollbarPlacement = sm.scrollbarPlacement
|
|
element.scrollbarBalance = sm.scrollbarBalance
|
|
|
|
-- Initialize state properties (will be synced from ScrollManager).
|
|
element._overflowX = false
|
|
element._overflowY = false
|
|
element._contentWidth = 0
|
|
element._contentHeight = 0
|
|
element._scrollX = 0
|
|
element._scrollY = 0
|
|
element._maxScrollX = 0
|
|
element._maxScrollY = 0
|
|
element._scrollbarHoveredVertical = false
|
|
element._scrollbarHoveredHorizontal = false
|
|
element._scrollbarDragging = false
|
|
element._hoveredScrollbar = nil
|
|
element._scrollbarDragOffset = 0
|
|
|
|
-- Restore scrollbar state from StateManager in immediate mode (must happen
|
|
-- before layout). Mirrors the legacy _initScrollManager restore block.
|
|
-- Mode-aware via Context.isImmediateMode (behavior-mode-unification task 11).
|
|
if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
|
|
local state = Element._StateManager.getState(element._stateId)
|
|
if state and state.scrollManager then
|
|
element._scrollbarHoveredVertical = state.scrollManager._scrollbarHoveredVertical or false
|
|
element._scrollbarHoveredHorizontal = state.scrollManager._scrollbarHoveredHorizontal or false
|
|
element._scrollbarDragging = state.scrollManager._scrollbarDragging or false
|
|
element._hoveredScrollbar = state.scrollManager._hoveredScrollbar
|
|
element._scrollbarDragOffset = state.scrollManager._scrollbarDragOffset or 0
|
|
|
|
-- Apply to ScrollManager immediately.
|
|
sm._scrollbarHoveredVertical = element._scrollbarHoveredVertical
|
|
sm._scrollbarHoveredHorizontal = element._scrollbarHoveredHorizontal
|
|
sm._scrollbarDragging = element._scrollbarDragging
|
|
sm._hoveredScrollbar = element._hoveredScrollbar
|
|
sm._scrollbarDragOffset = element._scrollbarDragOffset
|
|
|
|
-- Restore drag start positions for relative movement tracking.
|
|
sm._dragStartMouseX = state.scrollManager._dragStartMouseX or 0
|
|
sm._dragStartMouseY = state.scrollManager._dragStartMouseY or 0
|
|
sm._dragStartScrollX = state.scrollManager._dragStartScrollX or 0
|
|
sm._dragStartScrollY = state.scrollManager._dragStartScrollY or 0
|
|
end
|
|
end
|
|
end
|
|
|
|
-- --------------------------------------------------------------------------
|
|
-- onUpdate — scroll-position momentum + scrollbar hover/drag/press interaction
|
|
-- (formerly the inline ScrollManager blocks in Element:update).
|
|
-- Runs BEFORE Clickable.onUpdate in the registry so the scrollbar press flag
|
|
-- is set before Clickable's EventHandler processes mouse events.
|
|
-- --------------------------------------------------------------------------
|
|
|
|
local function onUpdate(element, dt)
|
|
local Element = ElementClass(element)
|
|
local sm = element._scrollManager
|
|
if not sm then
|
|
return
|
|
end
|
|
-- Restore scrollbar interaction state from StateManager in immediate mode
|
|
-- (no-op outside immediate mode / when no state is stored).
|
|
Element._ScrollManager.restoreImmediateState(element)
|
|
|
|
-- Smooth-scroll / momentum interpolation.
|
|
sm:update(dt)
|
|
element:_syncScrollManagerState()
|
|
|
|
-- Scrollbar hover / drag / press interaction. Captures the mouse here so the
|
|
-- interaction state is consistent across the rest of the frame's behaviors.
|
|
local mx, my = love.mouse.getPosition()
|
|
Element._ScrollManager.updateInteraction(element, mx, my)
|
|
end
|
|
|
|
-- --------------------------------------------------------------------------
|
|
-- onDraw — scrollbar rendering (post-children overlay). Marked
|
|
-- `drawLayer = "overlay"` so Element:draw dispatches it AFTER children, so
|
|
-- scrollbars paint on top of clipped child content and without parent clipping.
|
|
-- --------------------------------------------------------------------------
|
|
|
|
local function onDraw(element, _ctx)
|
|
local overflowX = element.overflowX or element.overflow
|
|
local overflowY = element.overflowY or element.overflow
|
|
if overflowX ~= "scroll" and overflowX ~= "auto" and overflowY ~= "scroll" and overflowY ~= "auto" then
|
|
return
|
|
end
|
|
local scrollbarDims = element:_calculateScrollbarDimensions()
|
|
if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then
|
|
return
|
|
end
|
|
-- Clear any parent scissor clipping before drawing scrollbars so they render
|
|
-- fully visible (scrollbars must not be clipped by ancestor overflow).
|
|
love.graphics.setScissor()
|
|
element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims)
|
|
end
|
|
|
|
-- --------------------------------------------------------------------------
|
|
-- saveState / restoreState — ScrollManager state snapshot for immediate-mode
|
|
-- recreation (formerly the inline blocks in Element:saveState/
|
|
-- Element:restoreState). Returns a table merged under the `scrollManager` key
|
|
-- by Element:saveState's behavior loop, mirroring the legacy contract.
|
|
-- --------------------------------------------------------------------------
|
|
|
|
local function saveState(element)
|
|
local sm = element._scrollManager
|
|
if not sm then
|
|
return nil
|
|
end
|
|
return { scrollManager = sm:getState() }
|
|
end
|
|
|
|
local function restoreState(element, state)
|
|
if not state then
|
|
return
|
|
end
|
|
local sm = element._scrollManager
|
|
local smState = state.scrollManager
|
|
if sm and smState then
|
|
sm:setState(smState)
|
|
end
|
|
end
|
|
|
|
local Scrollable = Behavior.new({
|
|
onAttach = onAttach,
|
|
onDetach = function() end,
|
|
onUpdate = onUpdate,
|
|
onDraw = onDraw,
|
|
saveState = saveState,
|
|
restoreState = restoreState,
|
|
drawLayer = "overlay",
|
|
})
|
|
|
|
-- Expose the predicate at module level so callers/tests can reference it
|
|
-- directly without an element instance (mirrors Clickable.shouldAttach /
|
|
-- Selectable.shouldAttach).
|
|
Scrollable.shouldAttach = shouldAttach
|
|
|
|
return Scrollable
|