gesture handling

This commit is contained in:
Michael Freno
2025-11-18 19:49:39 -05:00
parent 0b0f0e1eb7
commit a26f7f0acc
8 changed files with 1672 additions and 26 deletions

View File

@@ -9,7 +9,10 @@
---@field _dragStartY table<number, number>
---@field _lastMouseX table<number, number>
---@field _lastMouseY table<number, number>
---@field _touchPressed table<number, boolean>
---@field _touches table<string, table> -- Multi-touch state per touch ID
---@field _touchStartPositions table<string, table> -- Touch start positions
---@field _lastTouchPositions table<string, table> -- Last touch positions for delta
---@field _touchHistory table<string, table> -- Touch position history for gestures (last 5)
---@field _hovered boolean
---@field _element Element?
---@field _scrollbarPressHandled boolean
@@ -46,7 +49,11 @@ function EventHandler.new(config, deps)
self._lastMouseX = config._lastMouseX or {}
self._lastMouseY = config._lastMouseY or {}
self._touchPressed = config._touchPressed or {}
-- Multi-touch tracking
self._touches = config._touches or {}
self._touchStartPositions = config._touchStartPositions or {}
self._lastTouchPositions = config._lastTouchPositions or {}
self._touchHistory = config._touchHistory or {}
self._hovered = config._hovered or false
@@ -75,6 +82,10 @@ function EventHandler:getState()
_dragStartY = self._dragStartY,
_lastMouseX = self._lastMouseX,
_lastMouseY = self._lastMouseY,
_touches = self._touches,
_touchStartPositions = self._touchStartPositions,
_lastTouchPositions = self._lastTouchPositions,
_touchHistory = self._touchHistory,
_hovered = self._hovered,
}
end
@@ -94,6 +105,10 @@ function EventHandler:setState(state)
self._dragStartY = state._dragStartY or {}
self._lastMouseX = state._lastMouseX or {}
self._lastMouseY = state._lastMouseY or {}
self._touches = state._touches or {}
self._touchStartPositions = state._touchStartPositions or {}
self._lastTouchPositions = state._lastTouchPositions or {}
self._touchHistory = state._touchHistory or {}
self._hovered = state._hovered or false
end
@@ -395,36 +410,232 @@ end
--- Process touch events in the update cycle
function EventHandler:processTouchEvents()
-- Start performance timing
local Performance = package.loaded["modules.Performance"] or package.loaded["libs.modules.Performance"]
if Performance and Performance.isEnabled() then
Performance.startTimer("event_touch")
end
if not self._element then
if Performance and Performance.isEnabled() then
Performance.stopTimer("event_touch")
end
return
end
local element = self._element
-- Check if element can process events
local canProcessEvents = (self.onEvent or element.editable) and not element.disabled
if not canProcessEvents then
if Performance and Performance.isEnabled() then
Performance.stopTimer("event_touch")
end
return
end
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- Get current active touches from LÖVE
local activeTouches = {}
local touches = love.touch.getTouches()
for _, id in ipairs(touches) do
activeTouches[tostring(id)] = true
end
-- Process active touches
for _, id in ipairs(touches) do
local touchId = tostring(id)
local tx, ty = love.touch.getPosition(id)
if tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh then
self._touchPressed[id] = true
elseif self._touchPressed[id] then
-- Create touch event (treat as left click)
local touchEvent = self._InputEvent.new({
type = "click",
button = 1,
x = tx,
y = ty,
modifiers = self._utils.getModifiers(),
clickCount = 1,
})
self:_invokeCallback(element, touchEvent)
self._touchPressed[id] = false
local pressure = 1.0 -- LÖVE doesn't provide pressure by default
-- Check if touch is within element bounds
local isInside = tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh
if isInside then
if not self._touches[touchId] then
-- New touch began
self:_handleTouchBegan(touchId, tx, ty, pressure)
else
-- Touch moved
self:_handleTouchMoved(touchId, tx, ty, pressure)
end
elseif self._touches[touchId] then
-- Touch moved outside or ended
if activeTouches[touchId] then
-- Still active but outside - fire moved event
self:_handleTouchMoved(touchId, tx, ty, pressure)
else
-- Touch ended
self:_handleTouchEnded(touchId, tx, ty, pressure)
end
end
end
-- Check for ended touches (touches that were tracked but are no longer active)
for touchId, _ in pairs(self._touches) do
if not activeTouches[touchId] then
-- Touch ended or cancelled
local lastPos = self._lastTouchPositions[touchId]
if lastPos then
self:_handleTouchEnded(touchId, lastPos.x, lastPos.y, 1.0)
else
-- Cleanup orphaned touch
self:_cleanupTouch(touchId)
end
end
end
-- Stop performance timing
if Performance and Performance.isEnabled() then
Performance.stopTimer("event_touch")
end
end
--- Handle touch began event
---@param touchId string Touch ID
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchBegan(touchId, x, y, pressure)
if not self._element then
return
end
local element = self._element
-- Create touch state
self._touches[touchId] = {
x = x,
y = y,
pressure = pressure,
timestamp = love.timer.getTime(),
phase = "began",
}
-- Record start position
self._touchStartPositions[touchId] = { x = x, y = y }
self._lastTouchPositions[touchId] = { x = x, y = y }
-- Initialize touch history
self._touchHistory[touchId] = { { x = x, y = y, timestamp = love.timer.getTime() } }
-- Create and fire touch press event
local touchEvent = self._InputEvent.fromTouch(touchId, x, y, "began", pressure)
touchEvent.type = "touchpress"
touchEvent.dx = 0
touchEvent.dy = 0
self:_invokeCallback(element, touchEvent)
end
--- Handle touch moved event
---@param touchId string Touch ID
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchMoved(touchId, x, y, pressure)
if not self._element then
return
end
local element = self._element
local touchState = self._touches[touchId]
if not touchState then
-- Touch not tracked, ignore
return
end
local lastPos = self._lastTouchPositions[touchId]
if not lastPos or lastPos.x ~= x or lastPos.y ~= y then
-- Touch position changed
local startPos = self._touchStartPositions[touchId]
local dx = x - startPos.x
local dy = y - startPos.y
-- Update touch state
touchState.x = x
touchState.y = y
touchState.pressure = pressure
touchState.phase = "moved"
-- Update last position
self._lastTouchPositions[touchId] = { x = x, y = y }
-- Add to touch history (keep last 5 positions)
local history = self._touchHistory[touchId] or {}
table.insert(history, { x = x, y = y, timestamp = love.timer.getTime() })
if #history > 5 then
table.remove(history, 1)
end
self._touchHistory[touchId] = history
-- Create and fire touch move event
local touchEvent = self._InputEvent.fromTouch(touchId, x, y, "moved", pressure)
touchEvent.type = "touchmove"
touchEvent.dx = dx
touchEvent.dy = dy
self:_invokeCallback(element, touchEvent)
end
end
--- Handle touch ended event
---@param touchId string Touch ID
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchEnded(touchId, x, y, pressure)
if not self._element then
return
end
local element = self._element
local touchState = self._touches[touchId]
if not touchState then
-- Touch not tracked, ignore
return
end
local startPos = self._touchStartPositions[touchId]
local dx = x - startPos.x
local dy = y - startPos.y
-- Create and fire touch release event
local touchEvent = self._InputEvent.fromTouch(touchId, x, y, "ended", pressure)
touchEvent.type = "touchrelease"
touchEvent.dx = dx
touchEvent.dy = dy
self:_invokeCallback(element, touchEvent)
-- Cleanup touch state
self:_cleanupTouch(touchId)
end
--- Cleanup touch state
---@param touchId string Touch ID
function EventHandler:_cleanupTouch(touchId)
self._touches[touchId] = nil
self._touchStartPositions[touchId] = nil
self._lastTouchPositions[touchId] = nil
self._touchHistory[touchId] = nil
end
--- Get active touches on this element
---@return table<string, table> Active touches
function EventHandler:getActiveTouches()
return self._touches
end
--- Get touch history for gesture recognition
---@param touchId string Touch ID
---@return table? Touch history (last 5 positions)
function EventHandler:getTouchHistory(touchId)
return self._touchHistory[touchId]
end
--- Reset scrollbar press flag (called each frame)

View File

@@ -0,0 +1,594 @@
---@class GestureRecognizer
---@field _touches table<string, table> -- Current touch states
---@field _gestureStates table -- Active gesture states
---@field _config table -- Gesture configuration (thresholds, etc.)
---@field _InputEvent table
---@field _utils table
local GestureRecognizer = {}
GestureRecognizer.__index = GestureRecognizer
-- Gesture types enum
local GestureType = {
TAP = "tap",
DOUBLE_TAP = "double_tap",
LONG_PRESS = "long_press",
SWIPE = "swipe",
PAN = "pan",
PINCH = "pinch",
ROTATE = "rotate",
}
-- Gesture states
local GestureState = {
POSSIBLE = "possible",
BEGAN = "began",
CHANGED = "changed",
ENDED = "ended",
CANCELLED = "cancelled",
FAILED = "failed",
}
-- Default configuration
local defaultConfig = {
-- Tap gesture
tapMaxDuration = 0.3, -- seconds
tapMaxMovement = 10, -- pixels
-- Double-tap gesture
doubleTapInterval = 0.3, -- seconds between taps
-- Long-press gesture
longPressMinDuration = 0.5, -- seconds
longPressMaxMovement = 10, -- pixels
-- Swipe gesture
swipeMinDistance = 50, -- pixels
swipeMaxDuration = 0.2, -- seconds
swipeMinVelocity = 200, -- pixels per second
-- Pan gesture
panMinMovement = 5, -- pixels to start pan
-- Pinch gesture
pinchMinScaleChange = 0.1, -- 10% scale change
-- Rotate gesture
rotateMinAngleChange = 5, -- degrees
}
--- Create a new GestureRecognizer instance
---@param config table? Optional configuration options
---@param deps table Dependencies {InputEvent, utils}
---@return GestureRecognizer
function GestureRecognizer.new(config, deps)
config = config or {}
local self = setmetatable({}, GestureRecognizer)
self._InputEvent = deps.InputEvent
self._utils = deps.utils
-- Merge configuration with defaults
self._config = {}
for key, value in pairs(defaultConfig) do
self._config[key] = config[key] or value
end
self._touches = {}
self._gestureStates = {
tap = nil,
doubleTap = { lastTapTime = 0, tapCount = 0 },
longPress = {},
swipe = {},
pan = {},
pinch = {},
rotate = {},
}
return self
end
--- Update gesture recognizer with touch event
---@param event InputEvent Touch event
function GestureRecognizer:processTouchEvent(event)
if not event.touchId then
return
end
local touchId = event.touchId
-- Update touch state
if event.type == "touchpress" then
self._touches[touchId] = {
startX = event.x,
startY = event.y,
x = event.x,
y = event.y,
startTime = event.timestamp,
lastTime = event.timestamp,
phase = "began",
}
-- Initialize gesture detection
self:_detectTapBegan(touchId, event)
self:_detectLongPressBegan(touchId, event)
elseif event.type == "touchmove" then
local touch = self._touches[touchId]
if touch then
touch.x = event.x
touch.y = event.y
touch.lastTime = event.timestamp
touch.phase = "moved"
-- Update gesture detection
self:_detectPan(touchId, event)
self:_detectSwipe(touchId, event)
-- Multi-touch gestures
if self:_getTouchCount() >= 2 then
self:_detectPinch(event)
self:_detectRotate(event)
end
end
elseif event.type == "touchrelease" then
local touch = self._touches[touchId]
if touch then
touch.phase = "ended"
-- Finalize gesture detection
self:_detectTapEnded(touchId, event)
self:_detectSwipeEnded(touchId, event)
self:_detectPanEnded(touchId, event)
-- Cleanup touch
self._touches[touchId] = nil
end
elseif event.type == "touchcancel" then
-- Cancel all active gestures for this touch
self._touches[touchId] = nil
self:_cancelAllGestures()
end
end
--- Get number of active touches
---@return number
function GestureRecognizer:_getTouchCount()
local count = 0
for _ in pairs(self._touches) do
count = count + 1
end
return count
end
--- Detect tap gesture began
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectTapBegan(touchId, event)
-- Tap detection happens on touch end
-- Just record the touch for now
end
--- Detect tap gesture ended
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectTapEnded(touchId, event)
local touch = self._touches[touchId]
if not touch then
return
end
local duration = event.timestamp - touch.startTime
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
-- Check if it's a valid tap
if duration < self._config.tapMaxDuration and distance < self._config.tapMaxMovement then
local currentTime = event.timestamp
local doubleTapState = self._gestureStates.doubleTap
-- Check for double-tap
if currentTime - doubleTapState.lastTapTime < self._config.doubleTapInterval then
doubleTapState.tapCount = doubleTapState.tapCount + 1
if doubleTapState.tapCount >= 2 then
-- Fire double-tap gesture
return {
type = GestureType.DOUBLE_TAP,
state = GestureState.ENDED,
x = event.x,
y = event.y,
timestamp = event.timestamp,
}
end
else
doubleTapState.tapCount = 1
end
doubleTapState.lastTapTime = currentTime
-- Fire tap gesture
return {
type = GestureType.TAP,
state = GestureState.ENDED,
x = event.x,
y = event.y,
timestamp = event.timestamp,
}
end
end
--- Detect long-press gesture began
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectLongPressBegan(touchId, event)
-- Long-press detection happens continuously during touch
self._gestureStates.longPress[touchId] = {
startX = event.x,
startY = event.y,
startTime = event.timestamp,
triggered = false,
}
end
--- Update long-press detection
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_updateLongPress(touchId, event)
local lpState = self._gestureStates.longPress[touchId]
if not lpState or lpState.triggered then
return nil
end
local duration = event.timestamp - lpState.startTime
local dx = event.x - lpState.startX
local dy = event.y - lpState.startY
local distance = math.sqrt(dx * dx + dy * dy)
-- Check if long-press duration reached and movement within threshold
if duration >= self._config.longPressMinDuration and distance < self._config.longPressMaxMovement then
lpState.triggered = true
return {
type = GestureType.LONG_PRESS,
state = GestureState.BEGAN,
x = event.x,
y = event.y,
timestamp = event.timestamp,
duration = duration,
}
end
return nil
end
--- Detect pan gesture
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPan(touchId, event)
local touch = self._touches[touchId]
if not touch then
return nil
end
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
local panState = self._gestureStates.pan[touchId]
if not panState then
-- Check if pan should begin
if distance >= self._config.panMinMovement then
self._gestureStates.pan[touchId] = {
active = true,
lastX = touch.startX,
lastY = touch.startY,
}
panState = self._gestureStates.pan[touchId]
return {
type = GestureType.PAN,
state = GestureState.BEGAN,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
timestamp = event.timestamp,
}
end
else
-- Pan is active, fire changed event
local panDx = event.x - panState.lastX
local panDy = event.y - panState.lastY
panState.lastX = event.x
panState.lastY = event.y
return {
type = GestureType.PAN,
state = GestureState.CHANGED,
x = event.x,
y = event.y,
dx = panDx,
dy = panDy,
totalDx = dx,
totalDy = dy,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect pan ended
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPanEnded(touchId, event)
local panState = self._gestureStates.pan[touchId]
if panState and panState.active then
self._gestureStates.pan[touchId] = nil
local touch = self._touches[touchId]
local dx = event.x - touch.startX
local dy = event.y - touch.startY
return {
type = GestureType.PAN,
state = GestureState.ENDED,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect swipe gesture
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectSwipe(touchId, event)
-- Swipe detection happens on touch end
end
--- Detect swipe ended
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectSwipeEnded(touchId, event)
local touch = self._touches[touchId]
if not touch then
return nil
end
local duration = event.timestamp - touch.startTime
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
-- Check if it's a valid swipe
if distance >= self._config.swipeMinDistance and duration <= self._config.swipeMaxDuration then
local velocity = distance / duration
if velocity >= self._config.swipeMinVelocity then
-- Determine swipe direction
local angle = math.atan2(dy, dx)
local direction = "right"
if angle >= -math.pi / 4 and angle < math.pi / 4 then
direction = "right"
elseif angle >= math.pi / 4 and angle < 3 * math.pi / 4 then
direction = "down"
elseif angle >= -3 * math.pi / 4 and angle < -math.pi / 4 then
direction = "up"
else
direction = "left"
end
return {
type = GestureType.SWIPE,
state = GestureState.ENDED,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
direction = direction,
velocity = velocity,
timestamp = event.timestamp,
}
end
end
return nil
end
--- Detect pinch gesture
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPinch(event)
-- Get two touches for pinch
local touches = {}
for touchId, touch in pairs(self._touches) do
table.insert(touches, { id = touchId, touch = touch })
if #touches >= 2 then
break
end
end
if #touches < 2 then
return nil
end
local t1 = touches[1].touch
local t2 = touches[2].touch
-- Calculate current distance
local currentDx = t2.x - t1.x
local currentDy = t2.y - t1.y
local currentDistance = math.sqrt(currentDx * currentDx + currentDy * currentDy)
-- Calculate initial distance
local initialDx = t2.startX - t1.startX
local initialDy = t2.startY - t1.startY
local initialDistance = math.sqrt(initialDx * initialDx + initialDy * initialDy)
if initialDistance == 0 then
return nil
end
-- Calculate scale
local scale = currentDistance / initialDistance
local pinchState = self._gestureStates.pinch
if not pinchState.active then
-- Check if pinch should begin
if math.abs(scale - 1.0) >= self._config.pinchMinScaleChange then
pinchState.active = true
pinchState.initialScale = scale
pinchState.lastScale = scale
-- Calculate center point
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
return {
type = GestureType.PINCH,
state = GestureState.BEGAN,
scale = scale,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
else
-- Pinch is active, fire changed event
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
local scaleChange = scale - pinchState.lastScale
pinchState.lastScale = scale
return {
type = GestureType.PINCH,
state = GestureState.CHANGED,
scale = scale,
scaleChange = scaleChange,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect rotate gesture
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectRotate(event)
-- Get two touches for rotation
local touches = {}
for touchId, touch in pairs(self._touches) do
table.insert(touches, { id = touchId, touch = touch })
if #touches >= 2 then
break
end
end
if #touches < 2 then
return nil
end
local t1 = touches[1].touch
local t2 = touches[2].touch
-- Calculate current angle
local currentAngle = math.atan2(t2.y - t1.y, t2.x - t1.x)
-- Calculate initial angle
local initialAngle = math.atan2(t2.startY - t1.startY, t2.startX - t1.startX)
-- Calculate rotation (in degrees)
local rotation = (currentAngle - initialAngle) * 180 / math.pi
local rotateState = self._gestureStates.rotate
if not rotateState.active then
-- Check if rotation should begin
if math.abs(rotation) >= self._config.rotateMinAngleChange then
rotateState.active = true
rotateState.initialRotation = rotation
rotateState.lastRotation = rotation
-- Calculate center point
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
return {
type = GestureType.ROTATE,
state = GestureState.BEGAN,
rotation = rotation,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
else
-- Rotation is active, fire changed event
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
local rotationChange = rotation - rotateState.lastRotation
rotateState.lastRotation = rotation
return {
type = GestureType.ROTATE,
state = GestureState.CHANGED,
rotation = rotation,
rotationChange = rotationChange,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
return nil
end
--- Cancel all active gestures
function GestureRecognizer:_cancelAllGestures()
for gestureType, state in pairs(self._gestureStates) do
if type(state) == "table" and state.active then
state.active = false
end
end
end
--- Reset gesture recognizer state
function GestureRecognizer:reset()
self._touches = {}
self._gestureStates = {
tap = nil,
doubleTap = { lastTapTime = 0, tapCount = 0 },
longPress = {},
swipe = {},
pan = {},
pinch = { active = false },
rotate = { active = false },
}
end
-- Export gesture types and states
GestureRecognizer.GestureType = GestureType
GestureRecognizer.GestureState = GestureState
return GestureRecognizer

View File

@@ -1,18 +1,21 @@
---@class InputEvent
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
---@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle)
---@field x number -- Mouse X position
---@field y number -- Mouse Y position
---@field dx number? -- Delta X from drag start (only for drag events)
---@field dy number? -- Delta Y from drag start (only for drag events)
---@field x number -- Mouse/Touch X position
---@field y number -- Mouse/Touch Y position
---@field dx number? -- Delta X from drag/touch start (only for drag/touch events)
---@field dy number? -- Delta Y from drag/touch start (only for drag/touch events)
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
---@field clickCount number -- Number of clicks (for double/triple click detection)
---@field timestamp number -- Time when event occurred
---@field touchId string? -- Touch identifier (for multi-touch)
---@field pressure number? -- Touch pressure (0-1, defaults to 1.0)
---@field phase string? -- Touch phase: "began", "moved", "ended", "cancelled"
local InputEvent = {}
InputEvent.__index = InputEvent
---@class InputEventProps
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
---@field button number
---@field x number
---@field y number
@@ -21,6 +24,9 @@ InputEvent.__index = InputEvent
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
---@field clickCount number?
---@field timestamp number?
---@field touchId string?
---@field pressure number?
---@field phase string?
--- Create a new input event
---@param props InputEventProps
@@ -36,7 +42,47 @@ function InputEvent.new(props)
self.modifiers = props.modifiers
self.clickCount = props.clickCount or 1
self.timestamp = props.timestamp or love.timer.getTime()
-- Touch-specific properties
self.touchId = props.touchId
self.pressure = props.pressure or 1.0
self.phase = props.phase
return self
end
--- Create an InputEvent from LÖVE touch data
---@param id userdata Touch ID from LÖVE
---@param x number Touch X position
---@param y number Touch Y position
---@param phase string Touch phase: "began", "moved", "ended", "cancelled"
---@param pressure number? Touch pressure (0-1, defaults to 1.0)
---@return InputEvent
function InputEvent.fromTouch(id, x, y, phase, pressure)
local touchIdStr = tostring(id)
local eventType = "touchpress"
if phase == "moved" then
eventType = "touchmove"
elseif phase == "ended" then
eventType = "touchrelease"
elseif phase == "cancelled" then
eventType = "touchcancel"
end
return InputEvent.new({
type = eventType,
button = 1, -- Treat touch as left button
x = x,
y = y,
dx = 0,
dy = 0,
modifiers = {shift = false, ctrl = false, alt = false, super = false},
clickCount = 1,
timestamp = love.timer.getTime(),
touchId = touchIdStr,
pressure = pressure or 1.0,
phase = phase,
})
end
return InputEvent

View File

@@ -9,6 +9,12 @@
---@field scrollbarPadding number -- Padding around scrollbar
---@field scrollSpeed number -- Scroll speed for wheel events (pixels per wheel unit)
---@field hideScrollbars table -- {vertical: boolean, horizontal: boolean}
---@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 _element Element? -- Reference to parent Element (set via initialize)
---@field _overflowX boolean -- True if content overflows horizontally
---@field _overflowY boolean -- True if content overflows vertically
@@ -24,6 +30,13 @@
---@field _hoveredScrollbar string? -- "vertical" or "horizontal" when dragging
---@field _scrollbarDragOffset number -- Offset from thumb top 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?
@@ -61,6 +74,14 @@ function ScrollManager.new(config, deps)
-- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean}
self.hideScrollbars = self._utils.normalizeBooleanTable(config.hideScrollbars, false)
-- 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
@@ -81,6 +102,15 @@ function ScrollManager.new(config, deps)
self._scrollbarDragOffset = 0
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
-- Element reference (set via initialize)
self._element = nil
@@ -668,4 +698,224 @@ function ScrollManager:setState(state)
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)
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