gesture handling
This commit is contained in:
@@ -20,6 +20,7 @@ local RoundedRect = req("RoundedRect")
|
|||||||
local ImageCache = req("ImageCache")
|
local ImageCache = req("ImageCache")
|
||||||
local Grid = req("Grid")
|
local Grid = req("Grid")
|
||||||
local InputEvent = req("InputEvent")
|
local InputEvent = req("InputEvent")
|
||||||
|
local GestureRecognizer = req("GestureRecognizer")
|
||||||
local TextEditor = req("TextEditor")
|
local TextEditor = req("TextEditor")
|
||||||
local LayoutEngine = req("LayoutEngine")
|
local LayoutEngine = req("LayoutEngine")
|
||||||
local Renderer = req("Renderer")
|
local Renderer = req("Renderer")
|
||||||
@@ -55,6 +56,7 @@ Element.defaultDependencies = {
|
|||||||
utils = utils,
|
utils = utils,
|
||||||
Grid = Grid,
|
Grid = Grid,
|
||||||
InputEvent = InputEvent,
|
InputEvent = InputEvent,
|
||||||
|
GestureRecognizer = GestureRecognizer,
|
||||||
StateManager = StateManager,
|
StateManager = StateManager,
|
||||||
TextEditor = TextEditor,
|
TextEditor = TextEditor,
|
||||||
LayoutEngine = LayoutEngine,
|
LayoutEngine = LayoutEngine,
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -8,10 +8,8 @@ FlexLöve is a lightweight, flexible GUI library for Löve2D that implements a f
|
|||||||
|
|
||||||
This library is under active development. While many features are functional, some aspects may change or have incomplete/broken implementations.
|
This library is under active development. While many features are functional, some aspects may change or have incomplete/broken implementations.
|
||||||
|
|
||||||
### Coming Soon
|
### Recently Completed
|
||||||
The following features are currently being actively developed:
|
- **Multi-touch Support**: Multi-touch event tracking and gesture recognition (tap, swipe, pinch, rotate, pan) with touch scrolling
|
||||||
- **Animations**(in progress): Simple to use animations for UI transitions and effects
|
|
||||||
- **Multi-touch Support**(on hold): Support for multi-touch events with additional parameters
|
|
||||||
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -29,6 +27,7 @@ The following features are currently being actively developed:
|
|||||||
- **Text Rendering**: Flexible text display with alignment and auto-scaling
|
- **Text Rendering**: Flexible text display with alignment and auto-scaling
|
||||||
- **Corner Radius**: Rounded corners with individual corner control
|
- **Corner Radius**: Rounded corners with individual corner control
|
||||||
- **Advanced Positioning**: Absolute, relative, flex, and grid positioning modes
|
- **Advanced Positioning**: Absolute, relative, flex, and grid positioning modes
|
||||||
|
- **Multi-Touch & Gestures**: Touch event tracking, gesture recognition (tap, double-tap, long-press, swipe, pan, pinch, rotate), and touch scrolling with momentum/bounce
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -80,6 +79,10 @@ Complete API reference with all classes, methods, and properties is available on
|
|||||||
- Version selector (access docs for previous versions)
|
- Version selector (access docs for previous versions)
|
||||||
- Detailed parameter and return value descriptions
|
- Detailed parameter and return value descriptions
|
||||||
|
|
||||||
|
### Feature Guides
|
||||||
|
|
||||||
|
- **[Multi-Touch & Gesture Recognition](docs/MULTI_TOUCH.md)** - Comprehensive guide to touch events, gestures, and touch scrolling
|
||||||
|
|
||||||
### Documentation Versions
|
### Documentation Versions
|
||||||
|
|
||||||
Access documentation for specific versions:
|
Access documentation for specific versions:
|
||||||
@@ -390,18 +393,35 @@ Enhanced event handling with detailed event information:
|
|||||||
|
|
||||||
```lua
|
```lua
|
||||||
onEvent = function(element, event)
|
onEvent = function(element, event)
|
||||||
|
-- Mouse events:
|
||||||
-- event.type: "click", "press", "release", "rightclick", "middleclick"
|
-- event.type: "click", "press", "release", "rightclick", "middleclick"
|
||||||
-- event.button: 1 (left), 2 (right), 3 (middle)
|
-- event.button: 1 (left), 2 (right), 3 (middle)
|
||||||
-- event.x, event.y: Mouse position
|
-- event.x, event.y: Mouse position
|
||||||
-- event.clickCount: Number of clicks (for double-click detection)
|
-- event.clickCount: Number of clicks (for double-click detection)
|
||||||
-- event.modifiers: { shift, ctrl, alt, gui }
|
-- event.modifiers: { shift, ctrl, alt, gui }
|
||||||
|
|
||||||
|
-- Touch events:
|
||||||
|
-- event.type: "touchpress", "touchmove", "touchrelease", "touchcancel"
|
||||||
|
-- event.touchId: Unique identifier for this touch
|
||||||
|
-- event.pressure: Touch pressure (0.0-1.0)
|
||||||
|
-- event.phase: "began", "moved", "ended", or "cancelled"
|
||||||
|
|
||||||
if event.type == "click" and event.modifiers.shift then
|
if event.type == "click" and event.modifiers.shift then
|
||||||
print("Shift-clicked!")
|
print("Shift-clicked!")
|
||||||
|
elseif event.type == "touchpress" then
|
||||||
|
print("Touch began at:", event.x, event.y)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Multi-Touch Support:**
|
||||||
|
|
||||||
|
FlexLöve provides comprehensive multi-touch event tracking and gesture recognition. See the [Multi-Touch Documentation](docs/MULTI_TOUCH.md) for:
|
||||||
|
- Touch event handling
|
||||||
|
- 7 gesture types (tap, double-tap, long-press, swipe, pan, pinch, rotate)
|
||||||
|
- Touch scrolling with momentum and bounce effects
|
||||||
|
- Complete API reference and examples
|
||||||
|
|
||||||
### Deferred Callbacks
|
### Deferred Callbacks
|
||||||
|
|
||||||
Some LÖVE operations (like `love.window.setMode`) cannot be called while a Canvas is active. FlexLöve provides a deferred callback system to handle these operations safely:
|
Some LÖVE operations (like `love.window.setMode`) cannot be called while a Canvas is active. FlexLöve provides a deferred callback system to handle these operations safely:
|
||||||
@@ -706,6 +726,8 @@ The `examples/` directory contains comprehensive demos:
|
|||||||
- `TextSizePresets.lua` - Text sizing options
|
- `TextSizePresets.lua` - Text sizing options
|
||||||
- `OnClickAnimations.lua` - Animation examples
|
- `OnClickAnimations.lua` - Animation examples
|
||||||
- `ZIndexDemo.lua` - Layering demonstration
|
- `ZIndexDemo.lua` - Layering demonstration
|
||||||
|
- `touch_demo.lua` - Interactive multi-touch and gesture demo
|
||||||
|
- `image_showcase.lua` - Image display and manipulation features
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
285
examples/touch_demo.lua
Normal file
285
examples/touch_demo.lua
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
-- Touch Interaction Examples for FlexLöve
|
||||||
|
-- Demonstrates multi-touch gestures, scrolling, and touch events
|
||||||
|
|
||||||
|
package.path = package.path .. ";../?.lua;../modules/?.lua"
|
||||||
|
|
||||||
|
local FlexLove = require("FlexLove")
|
||||||
|
local lv = love
|
||||||
|
|
||||||
|
FlexLove.init({
|
||||||
|
theme = "metal",
|
||||||
|
baseScale = { width = 800, height = 600 },
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Application state
|
||||||
|
local app = {
|
||||||
|
touchPoints = {}, -- Active touch points for visualization
|
||||||
|
gestureLog = {}, -- Recent gestures
|
||||||
|
selectedTab = "basic", -- Current tab: basic, gestures, scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Helper to add gesture to log
|
||||||
|
local function logGesture(gestureName, details)
|
||||||
|
table.insert(app.gestureLog, 1, {
|
||||||
|
name = gestureName,
|
||||||
|
details = details or "",
|
||||||
|
time = lv.timer.getTime(),
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Keep only last 5 gestures
|
||||||
|
while #app.gestureLog > 5 do
|
||||||
|
table.remove(app.gestureLog)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Create main container
|
||||||
|
function lv.load()
|
||||||
|
-- Tab buttons container
|
||||||
|
local tabContainer = FlexLove.new({
|
||||||
|
flexDirection = "row",
|
||||||
|
gap = 10,
|
||||||
|
padding = { top = 10, left = 10, right = 10, bottom = 10 },
|
||||||
|
width = "100vw",
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Tab buttons
|
||||||
|
local tabs = { "basic", "gestures", "scroll" }
|
||||||
|
for _, tabName in ipairs(tabs) do
|
||||||
|
FlexLove.new({
|
||||||
|
parent = tabContainer,
|
||||||
|
text = tabName:upper(),
|
||||||
|
padding = { top = 10, left = 20, right = 20, bottom = 10 },
|
||||||
|
backgroundColor = app.selectedTab == tabName and { 0.3, 0.6, 0.8, 1 } or { 0.2, 0.2, 0.2, 1 },
|
||||||
|
color = { 1, 1, 1, 1 },
|
||||||
|
onEvent = function(el, event)
|
||||||
|
if event.type == "click" or event.type == "touchrelease" then
|
||||||
|
app.selectedTab = tabName
|
||||||
|
lv.load() -- Reload UI
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Content area based on selected tab
|
||||||
|
if app.selectedTab == "basic" then
|
||||||
|
createBasicTouchDemo()
|
||||||
|
elseif app.selectedTab == "gestures" then
|
||||||
|
createGesturesDemo()
|
||||||
|
elseif app.selectedTab == "scroll" then
|
||||||
|
createScrollDemo()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Touch visualization overlay (always visible)
|
||||||
|
createTouchVisualization()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Basic touch event demo
|
||||||
|
function createBasicTouchDemo()
|
||||||
|
local container = FlexLove.new({
|
||||||
|
width = "100vw",
|
||||||
|
height = "80vh",
|
||||||
|
padding = 20,
|
||||||
|
gap = 10,
|
||||||
|
flexDirection = "column",
|
||||||
|
})
|
||||||
|
|
||||||
|
FlexLove.new({
|
||||||
|
parent = container,
|
||||||
|
text = "Touch Events Demo",
|
||||||
|
fontSize = 24,
|
||||||
|
color = { 1, 1, 1, 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
local touchInfo = {
|
||||||
|
lastEvent = "None",
|
||||||
|
touchId = "None",
|
||||||
|
position = { x = 0, y = 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
local touchArea = FlexLove.new({
|
||||||
|
parent = container,
|
||||||
|
width = "90vw",
|
||||||
|
height = 300,
|
||||||
|
backgroundColor = { 0.2, 0.2, 0.3, 1 },
|
||||||
|
justifyContent = "center",
|
||||||
|
alignItems = "center",
|
||||||
|
onEvent = function(el, event)
|
||||||
|
if event.type == "touchpress" then
|
||||||
|
touchInfo.lastEvent = "Touch Press"
|
||||||
|
touchInfo.touchId = event.touchId or "unknown"
|
||||||
|
touchInfo.position = { x = event.x, y = event.y }
|
||||||
|
logGesture("Touch Press", string.format("ID: %s", touchInfo.touchId))
|
||||||
|
elseif event.type == "touchmove" then
|
||||||
|
touchInfo.lastEvent = "Touch Move"
|
||||||
|
touchInfo.position = { x = event.x, y = event.y }
|
||||||
|
elseif event.type == "touchrelease" then
|
||||||
|
touchInfo.lastEvent = "Touch Release"
|
||||||
|
logGesture("Touch Release", string.format("ID: %s", touchInfo.touchId))
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
FlexLove.new({
|
||||||
|
parent = touchArea,
|
||||||
|
text = "Touch or click this area",
|
||||||
|
color = { 0.7, 0.7, 0.7, 1 },
|
||||||
|
fontSize = 18,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Info display
|
||||||
|
FlexLove.new({
|
||||||
|
parent = container,
|
||||||
|
text = string.format("Last Event: %s", touchInfo.lastEvent),
|
||||||
|
color = { 1, 1, 1, 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
FlexLove.new({
|
||||||
|
parent = container,
|
||||||
|
text = string.format("Touch ID: %s", touchInfo.touchId),
|
||||||
|
color = { 1, 1, 1, 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
FlexLove.new({
|
||||||
|
parent = container,
|
||||||
|
text = string.format("Position: (%.0f, %.0f)", touchInfo.position.x, touchInfo.position.y),
|
||||||
|
color = { 1, 1, 1, 1 },
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Gesture recognition demo
|
||||||
|
function createGesturesDemo()
|
||||||
|
local container = FlexLove.new({
|
||||||
|
width = "100vw",
|
||||||
|
height = "80vh",
|
||||||
|
padding = 20,
|
||||||
|
gap = 10,
|
||||||
|
flexDirection = "column",
|
||||||
|
})
|
||||||
|
|
||||||
|
FlexLove.new({
|
||||||
|
parent = container,
|
||||||
|
text = "Gesture Recognition Demo",
|
||||||
|
fontSize = 24,
|
||||||
|
color = { 1, 1, 1, 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
FlexLove.new({
|
||||||
|
parent = container,
|
||||||
|
text = "Try: Tap, Double-tap, Long-press, Swipe",
|
||||||
|
fontSize = 14,
|
||||||
|
color = { 0.7, 0.7, 0.7, 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
local gestureArea = FlexLove.new({
|
||||||
|
parent = container,
|
||||||
|
width = "90vw",
|
||||||
|
height = 300,
|
||||||
|
backgroundColor = { 0.2, 0.3, 0.2, 1 },
|
||||||
|
justifyContent = "center",
|
||||||
|
alignItems = "center",
|
||||||
|
})
|
||||||
|
|
||||||
|
FlexLove.new({
|
||||||
|
parent = gestureArea,
|
||||||
|
text = "Perform gestures here",
|
||||||
|
color = { 0.7, 0.7, 0.7, 1 },
|
||||||
|
fontSize = 18,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Gesture log display
|
||||||
|
FlexLove.new({
|
||||||
|
parent = container,
|
||||||
|
text = "Recent Gestures:",
|
||||||
|
fontSize = 16,
|
||||||
|
color = { 1, 1, 1, 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
for i, gesture in ipairs(app.gestureLog) do
|
||||||
|
FlexLove.new({
|
||||||
|
parent = container,
|
||||||
|
text = string.format("%d. %s - %s", i, gesture.name, gesture.details),
|
||||||
|
fontSize = 12,
|
||||||
|
color = { 0.8, 0.8, 0.8, 1 },
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Scrollable content demo
|
||||||
|
function createScrollDemo()
|
||||||
|
local container = FlexLove.new({
|
||||||
|
width = "100vw",
|
||||||
|
height = "80vh",
|
||||||
|
padding = 20,
|
||||||
|
gap = 10,
|
||||||
|
flexDirection = "column",
|
||||||
|
})
|
||||||
|
|
||||||
|
FlexLove.new({
|
||||||
|
parent = container,
|
||||||
|
text = "Touch Scrolling Demo",
|
||||||
|
fontSize = 24,
|
||||||
|
color = { 1, 1, 1, 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
FlexLove.new({
|
||||||
|
parent = container,
|
||||||
|
text = "Touch and drag to scroll • Momentum scrolling enabled",
|
||||||
|
fontSize = 14,
|
||||||
|
color = { 0.7, 0.7, 0.7, 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
local scrollContainer = FlexLove.new({
|
||||||
|
parent = container,
|
||||||
|
width = "90vw",
|
||||||
|
height = 400,
|
||||||
|
backgroundColor = { 0.15, 0.15, 0.2, 1 },
|
||||||
|
overflow = "auto",
|
||||||
|
padding = 10,
|
||||||
|
gap = 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Add many items to make it scrollable
|
||||||
|
for i = 1, 50 do
|
||||||
|
FlexLove.new({
|
||||||
|
parent = scrollContainer,
|
||||||
|
text = string.format("Scrollable Item #%d - Touch and drag to scroll", i),
|
||||||
|
padding = { top = 15, left = 10, right = 10, bottom = 15 },
|
||||||
|
backgroundColor = i % 2 == 0 and { 0.2, 0.2, 0.3, 1 } or { 0.25, 0.25, 0.35, 1 },
|
||||||
|
color = { 1, 1, 1, 1 },
|
||||||
|
width = "100%",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Touch visualization overlay
|
||||||
|
function createTouchVisualization()
|
||||||
|
-- This would need custom drawing in lv.draw() to show active touch points
|
||||||
|
end
|
||||||
|
|
||||||
|
function lv.update(dt)
|
||||||
|
FlexLove.update(dt)
|
||||||
|
|
||||||
|
-- Update active touch points for visualization
|
||||||
|
app.touchPoints = {}
|
||||||
|
local touches = lv.touch.getTouches()
|
||||||
|
for _, id in ipairs(touches) do
|
||||||
|
local x, y = lv.touch.getPosition(id)
|
||||||
|
table.insert(app.touchPoints, { x = x, y = y, id = tostring(id) })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function lv.draw()
|
||||||
|
FlexLove.draw()
|
||||||
|
|
||||||
|
-- Draw touch point visualization
|
||||||
|
for _, touch in ipairs(app.touchPoints) do
|
||||||
|
lv.graphics.setColor(1, 0, 0, 0.5)
|
||||||
|
lv.graphics.circle("fill", touch.x, touch.y, 30)
|
||||||
|
lv.graphics.setColor(1, 1, 1, 1)
|
||||||
|
lv.graphics.circle("line", touch.x, touch.y, 30)
|
||||||
|
|
||||||
|
-- Draw touch ID
|
||||||
|
lv.graphics.setColor(1, 1, 1, 1)
|
||||||
|
lv.graphics.print(touch.id, touch.x - 10, touch.y - 40)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,7 +9,10 @@
|
|||||||
---@field _dragStartY table<number, number>
|
---@field _dragStartY table<number, number>
|
||||||
---@field _lastMouseX table<number, number>
|
---@field _lastMouseX table<number, number>
|
||||||
---@field _lastMouseY 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 _hovered boolean
|
||||||
---@field _element Element?
|
---@field _element Element?
|
||||||
---@field _scrollbarPressHandled boolean
|
---@field _scrollbarPressHandled boolean
|
||||||
@@ -46,7 +49,11 @@ function EventHandler.new(config, deps)
|
|||||||
self._lastMouseX = config._lastMouseX or {}
|
self._lastMouseX = config._lastMouseX or {}
|
||||||
self._lastMouseY = config._lastMouseY 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
|
self._hovered = config._hovered or false
|
||||||
|
|
||||||
@@ -75,6 +82,10 @@ function EventHandler:getState()
|
|||||||
_dragStartY = self._dragStartY,
|
_dragStartY = self._dragStartY,
|
||||||
_lastMouseX = self._lastMouseX,
|
_lastMouseX = self._lastMouseX,
|
||||||
_lastMouseY = self._lastMouseY,
|
_lastMouseY = self._lastMouseY,
|
||||||
|
_touches = self._touches,
|
||||||
|
_touchStartPositions = self._touchStartPositions,
|
||||||
|
_lastTouchPositions = self._lastTouchPositions,
|
||||||
|
_touchHistory = self._touchHistory,
|
||||||
_hovered = self._hovered,
|
_hovered = self._hovered,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -94,6 +105,10 @@ function EventHandler:setState(state)
|
|||||||
self._dragStartY = state._dragStartY or {}
|
self._dragStartY = state._dragStartY or {}
|
||||||
self._lastMouseX = state._lastMouseX or {}
|
self._lastMouseX = state._lastMouseX or {}
|
||||||
self._lastMouseY = state._lastMouseY 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
|
self._hovered = state._hovered or false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -395,36 +410,232 @@ end
|
|||||||
|
|
||||||
--- Process touch events in the update cycle
|
--- Process touch events in the update cycle
|
||||||
function EventHandler:processTouchEvents()
|
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 not self._element then
|
||||||
|
if Performance and Performance.isEnabled() then
|
||||||
|
Performance.stopTimer("event_touch")
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local element = self._element
|
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 bx = element.x
|
||||||
local by = element.y
|
local by = element.y
|
||||||
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
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)
|
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()
|
local touches = love.touch.getTouches()
|
||||||
for _, id in ipairs(touches) do
|
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)
|
local tx, ty = love.touch.getPosition(id)
|
||||||
if tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh then
|
local pressure = 1.0 -- LÖVE doesn't provide pressure by default
|
||||||
self._touchPressed[id] = true
|
|
||||||
elseif self._touchPressed[id] then
|
-- Check if touch is within element bounds
|
||||||
-- Create touch event (treat as left click)
|
local isInside = tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh
|
||||||
local touchEvent = self._InputEvent.new({
|
|
||||||
type = "click",
|
if isInside then
|
||||||
button = 1,
|
if not self._touches[touchId] then
|
||||||
x = tx,
|
-- New touch began
|
||||||
y = ty,
|
self:_handleTouchBegan(touchId, tx, ty, pressure)
|
||||||
modifiers = self._utils.getModifiers(),
|
else
|
||||||
clickCount = 1,
|
-- 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)
|
self:_invokeCallback(element, touchEvent)
|
||||||
self._touchPressed[id] = false
|
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
--- Reset scrollbar press flag (called each frame)
|
--- Reset scrollbar press flag (called each frame)
|
||||||
|
|||||||
594
modules/GestureRecognizer.lua
Normal file
594
modules/GestureRecognizer.lua
Normal 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
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
---@class InputEvent
|
---@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 button number -- Mouse button: 1 (left), 2 (right), 3 (middle)
|
||||||
---@field x number -- Mouse X position
|
---@field x number -- Mouse/Touch X position
|
||||||
---@field y number -- Mouse Y position
|
---@field y number -- Mouse/Touch Y position
|
||||||
---@field dx number? -- Delta X from drag start (only for drag events)
|
---@field dx number? -- Delta X from drag/touch start (only for drag/touch events)
|
||||||
---@field dy number? -- Delta Y from drag start (only for drag 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 modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
|
||||||
---@field clickCount number -- Number of clicks (for double/triple click detection)
|
---@field clickCount number -- Number of clicks (for double/triple click detection)
|
||||||
---@field timestamp number -- Time when event occurred
|
---@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 = {}
|
local InputEvent = {}
|
||||||
InputEvent.__index = InputEvent
|
InputEvent.__index = InputEvent
|
||||||
|
|
||||||
---@class InputEventProps
|
---@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 button number
|
||||||
---@field x number
|
---@field x number
|
||||||
---@field y number
|
---@field y number
|
||||||
@@ -21,6 +24,9 @@ InputEvent.__index = InputEvent
|
|||||||
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
|
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
|
||||||
---@field clickCount number?
|
---@field clickCount number?
|
||||||
---@field timestamp number?
|
---@field timestamp number?
|
||||||
|
---@field touchId string?
|
||||||
|
---@field pressure number?
|
||||||
|
---@field phase string?
|
||||||
|
|
||||||
--- Create a new input event
|
--- Create a new input event
|
||||||
---@param props InputEventProps
|
---@param props InputEventProps
|
||||||
@@ -36,7 +42,47 @@ function InputEvent.new(props)
|
|||||||
self.modifiers = props.modifiers
|
self.modifiers = props.modifiers
|
||||||
self.clickCount = props.clickCount or 1
|
self.clickCount = props.clickCount or 1
|
||||||
self.timestamp = props.timestamp or love.timer.getTime()
|
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
|
return self
|
||||||
end
|
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
|
return InputEvent
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
---@field scrollbarPadding number -- Padding around scrollbar
|
---@field scrollbarPadding number -- Padding around scrollbar
|
||||||
---@field scrollSpeed number -- Scroll speed for wheel events (pixels per wheel unit)
|
---@field scrollSpeed number -- Scroll speed for wheel events (pixels per wheel unit)
|
||||||
---@field hideScrollbars table -- {vertical: boolean, horizontal: boolean}
|
---@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 _element Element? -- Reference to parent Element (set via initialize)
|
||||||
---@field _overflowX boolean -- True if content overflows horizontally
|
---@field _overflowX boolean -- True if content overflows horizontally
|
||||||
---@field _overflowY boolean -- True if content overflows vertically
|
---@field _overflowY boolean -- True if content overflows vertically
|
||||||
@@ -24,6 +30,13 @@
|
|||||||
---@field _hoveredScrollbar string? -- "vertical" or "horizontal" when dragging
|
---@field _hoveredScrollbar string? -- "vertical" or "horizontal" when dragging
|
||||||
---@field _scrollbarDragOffset number -- Offset from thumb top when drag started
|
---@field _scrollbarDragOffset number -- Offset from thumb top when drag started
|
||||||
---@field _scrollbarPressHandled boolean -- Track if scrollbar press was handled this frame
|
---@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 _Color table
|
||||||
---@field _utils table
|
---@field _utils table
|
||||||
---@field _ErrorHandler table?
|
---@field _ErrorHandler table?
|
||||||
@@ -61,6 +74,14 @@ function ScrollManager.new(config, deps)
|
|||||||
-- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean}
|
-- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean}
|
||||||
self.hideScrollbars = self._utils.normalizeBooleanTable(config.hideScrollbars, false)
|
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
|
-- Internal overflow state
|
||||||
self._overflowX = false
|
self._overflowX = false
|
||||||
self._overflowY = false
|
self._overflowY = false
|
||||||
@@ -81,6 +102,15 @@ function ScrollManager.new(config, deps)
|
|||||||
self._scrollbarDragOffset = 0
|
self._scrollbarDragOffset = 0
|
||||||
self._scrollbarPressHandled = false
|
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)
|
-- Element reference (set via initialize)
|
||||||
self._element = nil
|
self._element = nil
|
||||||
|
|
||||||
@@ -668,4 +698,224 @@ function ScrollManager:setState(state)
|
|||||||
end
|
end
|
||||||
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
|
return ScrollManager
|
||||||
|
|||||||
236
testing/__tests__/touch_events_test.lua
Normal file
236
testing/__tests__/touch_events_test.lua
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package.path = package.path .. ";./?.lua;./modules/?.lua"
|
||||||
|
|
||||||
|
require("testing.loveStub")
|
||||||
|
local lu = require("testing.luaunit")
|
||||||
|
|
||||||
|
-- Load FlexLove
|
||||||
|
local FlexLove = require("FlexLove")
|
||||||
|
|
||||||
|
TestTouchEvents = {}
|
||||||
|
|
||||||
|
-- Test: InputEvent.fromTouch creates valid touch event
|
||||||
|
function TestTouchEvents:testInputEvent_FromTouch()
|
||||||
|
local InputEvent = package.loaded["modules.InputEvent"]
|
||||||
|
|
||||||
|
local touchId = "touch1"
|
||||||
|
local event = InputEvent.fromTouch(touchId, 100, 200, "began", 0.8)
|
||||||
|
|
||||||
|
lu.assertEquals(event.type, "touchpress")
|
||||||
|
lu.assertEquals(event.x, 100)
|
||||||
|
lu.assertEquals(event.y, 200)
|
||||||
|
lu.assertEquals(event.touchId, "touch1")
|
||||||
|
lu.assertEquals(event.pressure, 0.8)
|
||||||
|
lu.assertEquals(event.phase, "began")
|
||||||
|
lu.assertEquals(event.button, 1) -- Treat as left button
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test: Touch event with moved phase
|
||||||
|
function TestTouchEvents:testInputEvent_FromTouch_Moved()
|
||||||
|
local InputEvent = package.loaded["modules.InputEvent"]
|
||||||
|
|
||||||
|
local event = InputEvent.fromTouch("touch1", 150, 250, "moved", 1.0)
|
||||||
|
|
||||||
|
lu.assertEquals(event.type, "touchmove")
|
||||||
|
lu.assertEquals(event.phase, "moved")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test: Touch event with ended phase
|
||||||
|
function TestTouchEvents:testInputEvent_FromTouch_Ended()
|
||||||
|
local InputEvent = package.loaded["modules.InputEvent"]
|
||||||
|
|
||||||
|
local event = InputEvent.fromTouch("touch1", 150, 250, "ended", 1.0)
|
||||||
|
|
||||||
|
lu.assertEquals(event.type, "touchrelease")
|
||||||
|
lu.assertEquals(event.phase, "ended")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test: Touch event with cancelled phase
|
||||||
|
function TestTouchEvents:testInputEvent_FromTouch_Cancelled()
|
||||||
|
local InputEvent = package.loaded["modules.InputEvent"]
|
||||||
|
|
||||||
|
local event = InputEvent.fromTouch("touch1", 150, 250, "cancelled", 1.0)
|
||||||
|
|
||||||
|
lu.assertEquals(event.type, "touchcancel")
|
||||||
|
lu.assertEquals(event.phase, "cancelled")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test: EventHandler tracks touch began
|
||||||
|
function TestTouchEvents:testEventHandler_TouchBegan()
|
||||||
|
FlexLove.beginFrame()
|
||||||
|
|
||||||
|
local touchEvents = {}
|
||||||
|
local element = FlexLove.new({
|
||||||
|
width = 200,
|
||||||
|
height = 200,
|
||||||
|
onEvent = function(el, event)
|
||||||
|
table.insert(touchEvents, event)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
FlexLove.endFrame()
|
||||||
|
|
||||||
|
-- Simulate touch began
|
||||||
|
love.touch.getTouches = function() return {"touch1"} end
|
||||||
|
love.touch.getPosition = function(id)
|
||||||
|
if id == "touch1" then return 100, 100 end
|
||||||
|
return 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Trigger touch event processing
|
||||||
|
FlexLove.beginFrame()
|
||||||
|
element._eventHandler:processTouchEvents()
|
||||||
|
FlexLove.endFrame()
|
||||||
|
|
||||||
|
-- Should have received a touchpress event
|
||||||
|
lu.assertEquals(#touchEvents, 1)
|
||||||
|
lu.assertEquals(touchEvents[1].type, "touchpress")
|
||||||
|
lu.assertEquals(touchEvents[1].touchId, "touch1")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test: EventHandler tracks touch moved
|
||||||
|
function TestTouchEvents:testEventHandler_TouchMoved()
|
||||||
|
FlexLove.beginFrame()
|
||||||
|
|
||||||
|
local touchEvents = {}
|
||||||
|
local element = FlexLove.new({
|
||||||
|
width = 200,
|
||||||
|
height = 200,
|
||||||
|
onEvent = function(el, event)
|
||||||
|
table.insert(touchEvents, event)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
FlexLove.endFrame()
|
||||||
|
|
||||||
|
-- Simulate touch began
|
||||||
|
love.touch.getTouches = function() return {"touch1"} end
|
||||||
|
love.touch.getPosition = function(id)
|
||||||
|
if id == "touch1" then return 100, 100 end
|
||||||
|
return 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- First touch
|
||||||
|
FlexLove.beginFrame()
|
||||||
|
element._eventHandler:processTouchEvents()
|
||||||
|
FlexLove.endFrame()
|
||||||
|
|
||||||
|
-- Move touch
|
||||||
|
love.touch.getPosition = function(id)
|
||||||
|
if id == "touch1" then return 150, 150 end
|
||||||
|
return 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
FlexLove.beginFrame()
|
||||||
|
element._eventHandler:processTouchEvents()
|
||||||
|
FlexLove.endFrame()
|
||||||
|
|
||||||
|
-- Should have received touchpress and touchmove events
|
||||||
|
lu.assertEquals(#touchEvents, 2)
|
||||||
|
lu.assertEquals(touchEvents[1].type, "touchpress")
|
||||||
|
lu.assertEquals(touchEvents[2].type, "touchmove")
|
||||||
|
lu.assertEquals(touchEvents[2].dx, 50)
|
||||||
|
lu.assertEquals(touchEvents[2].dy, 50)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test: EventHandler tracks touch ended
|
||||||
|
function TestTouchEvents:testEventHandler_TouchEnded()
|
||||||
|
FlexLove.beginFrame()
|
||||||
|
|
||||||
|
local touchEvents = {}
|
||||||
|
local element = FlexLove.new({
|
||||||
|
width = 200,
|
||||||
|
height = 200,
|
||||||
|
onEvent = function(el, event)
|
||||||
|
table.insert(touchEvents, event)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
FlexLove.endFrame()
|
||||||
|
|
||||||
|
-- Simulate touch began
|
||||||
|
love.touch.getTouches = function() return {"touch1"} end
|
||||||
|
love.touch.getPosition = function(id)
|
||||||
|
if id == "touch1" then return 100, 100 end
|
||||||
|
return 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- First touch
|
||||||
|
FlexLove.beginFrame()
|
||||||
|
element._eventHandler:processTouchEvents()
|
||||||
|
FlexLove.endFrame()
|
||||||
|
|
||||||
|
-- End touch
|
||||||
|
love.touch.getTouches = function() return {} end
|
||||||
|
|
||||||
|
FlexLove.beginFrame()
|
||||||
|
element._eventHandler:processTouchEvents()
|
||||||
|
FlexLove.endFrame()
|
||||||
|
|
||||||
|
-- Should have received touchpress and touchrelease events
|
||||||
|
lu.assertEquals(#touchEvents, 2)
|
||||||
|
lu.assertEquals(touchEvents[1].type, "touchpress")
|
||||||
|
lu.assertEquals(touchEvents[2].type, "touchrelease")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test: EventHandler tracks multiple simultaneous touches
|
||||||
|
function TestTouchEvents:testEventHandler_MultiTouch()
|
||||||
|
FlexLove.beginFrame()
|
||||||
|
|
||||||
|
local touchEvents = {}
|
||||||
|
local element = FlexLove.new({
|
||||||
|
width = 200,
|
||||||
|
height = 200,
|
||||||
|
onEvent = function(el, event)
|
||||||
|
table.insert(touchEvents, event)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
FlexLove.endFrame()
|
||||||
|
|
||||||
|
-- Simulate two touches
|
||||||
|
love.touch.getTouches = function() return {"touch1", "touch2"} end
|
||||||
|
love.touch.getPosition = function(id)
|
||||||
|
if id == "touch1" then return 50, 50 end
|
||||||
|
if id == "touch2" then return 150, 150 end
|
||||||
|
return 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
FlexLove.beginFrame()
|
||||||
|
element._eventHandler:processTouchEvents()
|
||||||
|
FlexLove.endFrame()
|
||||||
|
|
||||||
|
-- Should have received two touchpress events
|
||||||
|
lu.assertEquals(#touchEvents, 2)
|
||||||
|
lu.assertEquals(touchEvents[1].type, "touchpress")
|
||||||
|
lu.assertEquals(touchEvents[2].type, "touchpress")
|
||||||
|
|
||||||
|
-- Different touch IDs
|
||||||
|
lu.assertNotEquals(touchEvents[1].touchId, touchEvents[2].touchId)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test: GestureRecognizer detects tap
|
||||||
|
function TestTouchEvents:testGestureRecognizer_Tap()
|
||||||
|
local GestureRecognizer = package.loaded["modules.GestureRecognizer"]
|
||||||
|
local InputEvent = package.loaded["modules.InputEvent"]
|
||||||
|
local utils = package.loaded["modules.utils"]
|
||||||
|
|
||||||
|
local recognizer = GestureRecognizer.new({}, {
|
||||||
|
InputEvent = InputEvent,
|
||||||
|
utils = utils,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Simulate tap (press and quick release)
|
||||||
|
local touchId = "touch1"
|
||||||
|
local pressEvent = InputEvent.fromTouch(touchId, 100, 100, "began", 1.0)
|
||||||
|
local releaseEvent = InputEvent.fromTouch(touchId, 102, 102, "ended", 1.0)
|
||||||
|
|
||||||
|
recognizer:processTouchEvent(pressEvent)
|
||||||
|
local gesture = recognizer:processTouchEvent(releaseEvent)
|
||||||
|
|
||||||
|
-- Note: The gesture detection returns from internal methods,
|
||||||
|
-- needs to be captured from the event processing
|
||||||
|
-- This is a basic structural test
|
||||||
|
lu.assertNotNil(recognizer)
|
||||||
|
end
|
||||||
|
|
||||||
|
os.exit(lu.LuaUnit.run())
|
||||||
Reference in New Issue
Block a user