feat: gesture/multi-touch progress

This commit is contained in:
2026-02-25 10:17:16 -05:00
parent 998469141a
commit 309ebde985
11 changed files with 2283 additions and 11 deletions

View File

@@ -160,6 +160,12 @@
---@field _mouseDownPosition number? -- Internal: mouse down position for drag tracking
---@field _textDragOccurred boolean? -- Internal: whether text drag occurred
---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil)
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false)
---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures
---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false)
---@field touchEnabled boolean -- Whether the element responds to touch events (default: true)
---@field multiTouchEnabled boolean -- Whether the element supports multiple simultaneous touches (default: false)
---@field animation table? -- Animation instance for this element
local Element = {}
Element.__index = Element
@@ -366,6 +372,14 @@ function Element.new(props)
self.customDraw = props.customDraw -- Custom rendering callback
-- Touch event properties
self.onTouchEvent = props.onTouchEvent
self.onTouchEventDeferred = props.onTouchEventDeferred or false
self.onGesture = props.onGesture
self.onGestureDeferred = props.onGestureDeferred or false
self.touchEnabled = props.touchEnabled ~= false -- Default true
self.multiTouchEnabled = props.multiTouchEnabled or false -- Default false
-- Initialize state manager ID for immediate mode (use self.id which may be auto-generated)
self._stateId = self.id
@@ -373,6 +387,12 @@ function Element.new(props)
local eventHandlerConfig = {
onEvent = self.onEvent,
onEventDeferred = props.onEventDeferred,
onTouchEvent = self.onTouchEvent,
onTouchEventDeferred = self.onTouchEventDeferred,
onGesture = self.onGesture,
onGestureDeferred = self.onGestureDeferred,
touchEnabled = self.touchEnabled,
multiTouchEnabled = self.multiTouchEnabled,
}
if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then
local state = Element._StateManager.getState(self._stateId)
@@ -2603,6 +2623,10 @@ function Element:destroy()
-- Clear onEvent to prevent closure leaks
self.onEvent = nil
-- Clear touch callbacks to prevent closure leaks
self.onTouchEvent = nil
self.onGesture = nil
end
--- Draw element and its children
@@ -3082,6 +3106,39 @@ function Element:update(dt)
end
end
--- Handle a touch event directly (for external touch routing)
--- Invokes both onEvent and onTouchEvent callbacks if set
---@param touchEvent InputEvent The touch event to handle
function Element:handleTouchEvent(touchEvent)
if not self.touchEnabled or self.disabled then
return
end
if self._eventHandler then
self._eventHandler:_invokeCallback(self, touchEvent)
self._eventHandler:_invokeTouchCallback(self, touchEvent)
end
end
--- Handle a gesture event (from GestureRecognizer or external routing)
---@param gesture table The gesture data (type, position, velocity, etc.)
function Element:handleGesture(gesture)
if not self.touchEnabled or self.disabled then
return
end
if self._eventHandler then
self._eventHandler:_invokeGestureCallback(self, gesture)
end
end
--- Get active touches currently tracked on this element
---@return table<string, table> Active touches keyed by touch ID
function Element:getTouches()
if self._eventHandler then
return self._eventHandler:getActiveTouches()
end
return {}
end
---@param newViewportWidth number
---@param newViewportHeight number
function Element:recalculateUnits(newViewportWidth, newViewportHeight)
@@ -4066,6 +4123,8 @@ function Element:_cleanup()
self.onEnter = nil
self.onImageLoad = nil
self.onImageError = nil
self.onTouchEvent = nil
self.onGesture = nil
end
return Element

View File

@@ -1,6 +1,12 @@
---@class EventHandler
---@field onEvent fun(element:Element, event:InputEvent)?
---@field onEventDeferred boolean?
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Touch-specific callback
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent is deferred
---@field onGesture fun(element:Element, gesture:table)? -- Gesture callback
---@field onGestureDeferred boolean? -- Whether onGesture is deferred
---@field touchEnabled boolean -- Whether touch events are processed (default: true)
---@field multiTouchEnabled boolean -- Whether multi-touch is supported (default: false)
---@field _pressed table<number, boolean>
---@field _lastClickTime number?
---@field _lastClickButton number?
@@ -39,6 +45,12 @@ function EventHandler.new(config)
self.onEvent = config.onEvent
self.onEventDeferred = config.onEventDeferred
self.onTouchEvent = config.onTouchEvent
self.onTouchEventDeferred = config.onTouchEventDeferred or false
self.onGesture = config.onGesture
self.onGestureDeferred = config.onGestureDeferred or false
self.touchEnabled = config.touchEnabled ~= false -- Default true
self.multiTouchEnabled = config.multiTouchEnabled or false -- Default false
self._pressed = config._pressed or {}
@@ -462,7 +474,7 @@ function EventHandler:processTouchEvents(element)
local activeTouchIds = {}
-- Check if element can process events
local canProcessEvents = (self.onEvent or element.editable) and not element.disabled
local canProcessEvents = (self.onEvent or self.onTouchEvent or element.editable) and not element.disabled and self.touchEnabled
if not canProcessEvents then
if EventHandler._Performance and EventHandler._Performance.enabled then
@@ -483,6 +495,12 @@ function EventHandler:processTouchEvents(element)
activeTouches[tostring(id)] = true
end
-- Count active tracked touches for multi-touch filtering
local trackedTouchCount = 0
for _ in pairs(self._touches) do
trackedTouchCount = trackedTouchCount + 1
end
-- Process active touches
for _, id in ipairs(touches) do
local touchId = tostring(id)
@@ -494,8 +512,15 @@ function EventHandler:processTouchEvents(element)
if isInside then
if not self._touches[touchId] then
-- New touch began
self:_handleTouchBegan(element, touchId, tx, ty, pressure)
-- Multi-touch filtering: reject new touches when multiTouchEnabled=false
-- and we already have an active touch
if not self.multiTouchEnabled and trackedTouchCount > 0 then
-- Skip this new touch (single-touch mode, already tracking one)
else
-- New touch began
self:_handleTouchBegan(element, touchId, tx, ty, pressure)
trackedTouchCount = trackedTouchCount + 1
end
else
-- Touch moved
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
@@ -561,6 +586,7 @@ function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure)
touchEvent.dx = 0
touchEvent.dy = 0
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
end
--- Handle touch moved event
@@ -607,6 +633,7 @@ function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure)
touchEvent.dx = dx
touchEvent.dy = dy
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
end
end
@@ -634,6 +661,7 @@ function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure)
touchEvent.dx = dx
touchEvent.dy = dy
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
-- Cleanup touch state
self:_cleanupTouch(touchId)
@@ -709,4 +737,52 @@ function EventHandler:_invokeCallback(element, event)
end
end
--- Invoke the onTouchEvent callback, optionally deferring it
---@param element Element The element that triggered the event
---@param event InputEvent The touch event data
function EventHandler:_invokeTouchCallback(element, event)
if not self.onTouchEvent then
return
end
if self.onTouchEventDeferred then
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
self.onTouchEvent(element, event)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
eventType = event.type,
})
end
else
self.onTouchEvent(element, event)
end
end
--- Invoke the onGesture callback, optionally deferring it
---@param element Element The element that triggered the event
---@param gesture table The gesture data from GestureRecognizer
function EventHandler:_invokeGestureCallback(element, gesture)
if not self.onGesture then
return
end
if self.onGestureDeferred then
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
self.onGesture(element, gesture)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
gestureType = gesture.type,
})
end
else
self.onGesture(element, gesture)
end
end
return EventHandler

View File

@@ -92,10 +92,11 @@ end
---@param event InputEvent Touch event
function GestureRecognizer:processTouchEvent(event)
if not event.touchId then
return
return nil
end
local touchId = event.touchId
local gestures = {}
-- Update touch state
if event.type == "touchpress" then
@@ -122,13 +123,17 @@ function GestureRecognizer:processTouchEvent(event)
touch.phase = "moved"
-- Update gesture detection
self:_detectPan(touchId, event)
self:_detectSwipe(touchId, event)
local panGesture = self:_detectPan(touchId, event)
if panGesture then table.insert(gestures, panGesture) end
local swipeGesture = self:_detectSwipe(touchId, event)
if swipeGesture then table.insert(gestures, swipeGesture) end
-- Multi-touch gestures
if self:_getTouchCount() >= 2 then
self:_detectPinch(event)
self:_detectRotate(event)
local pinchGesture = self:_detectPinch(event)
if pinchGesture then table.insert(gestures, pinchGesture) end
local rotateGesture = self:_detectRotate(event)
if rotateGesture then table.insert(gestures, rotateGesture) end
end
end
@@ -138,9 +143,12 @@ function GestureRecognizer:processTouchEvent(event)
touch.phase = "ended"
-- Finalize gesture detection
self:_detectTapEnded(touchId, event)
self:_detectSwipeEnded(touchId, event)
self:_detectPanEnded(touchId, event)
local tapGesture = self:_detectTapEnded(touchId, event)
if tapGesture then table.insert(gestures, tapGesture) end
local swipeGesture = self:_detectSwipeEnded(touchId, event)
if swipeGesture then table.insert(gestures, swipeGesture) end
local panGesture = self:_detectPanEnded(touchId, event)
if panGesture then table.insert(gestures, panGesture) end
-- Cleanup touch
self._touches[touchId] = nil
@@ -151,6 +159,8 @@ function GestureRecognizer:processTouchEvent(event)
self._touches[touchId] = nil
self:_cancelAllGestures()
end
return #gestures > 0 and gestures or nil
end
--- Get number of active touches

View File

@@ -85,6 +85,12 @@ local AnimationProps = {}
---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false)
---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed
---@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false)
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events (touchpress, touchmove, touchrelease)
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false)
---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures (tap, swipe, pinch, etc.)
---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false)
---@field touchEnabled boolean? -- Whether the element responds to touch events (default: true)
---@field multiTouchEnabled boolean? -- Whether the element supports multiple simultaneous touches (default: false)
---@field transform TransformProps? -- Transform properties for animations and styling
---@field transition TransitionProps? -- Transition settings for animations
---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil)