better callback(event) system

This commit is contained in:
Michael Freno
2025-10-12 17:12:12 -04:00
parent ea8a0ca17b
commit 18ff2c8223
4 changed files with 692 additions and 22 deletions

View File

@@ -475,6 +475,56 @@ end
-- Simple GUI library for LOVE2D
-- Provides element and button creation, drawing, and click handling.
-- ====================
-- Event System
-- ====================
---@class InputEvent
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"
---@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle)
---@field x number -- Mouse X position
---@field y number -- Mouse Y position
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, cmd:boolean}
---@field clickCount number -- Number of clicks (for double/triple click detection)
---@field timestamp number -- Time when event occurred
local InputEvent = {}
InputEvent.__index = InputEvent
---@class InputEventProps
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"
---@field button number
---@field x number
---@field y number
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, cmd:boolean}
---@field clickCount number?
---@field timestamp number?
--- Create a new input event
---@param props InputEventProps
---@return InputEvent
function InputEvent.new(props)
local self = setmetatable({}, InputEvent)
self.type = props.type
self.button = props.button
self.x = props.x
self.y = props.y
self.modifiers = props.modifiers
self.clickCount = props.clickCount or 1
self.timestamp = props.timestamp or love.timer.getTime()
return self
end
--- Get current keyboard modifiers state
---@return {shift:boolean, ctrl:boolean, alt:boolean, cmd:boolean}
local function getModifiers()
return {
shift = love.keyboard.isDown("lshift", "rshift"),
ctrl = love.keyboard.isDown("lctrl", "rctrl"),
alt = love.keyboard.isDown("lalt", "ralt"),
cmd = love.keyboard.isDown("lgui", "rgui") -- Mac Command key
}
end
---@class Animation
---@field duration number
---@field start {width?:number, height?:number, opacity?:number}
@@ -685,8 +735,13 @@ end
---@field autoScaleText boolean -- Whether text should auto-scale with window size (default: true)
---@field transform TransformProps -- Transform properties for animations and styling
---@field transition TransitionProps -- Transition settings for animations
---@field callback function? -- Callback function for click events
---@field callback fun(element:Element, event:InputEvent)? -- Callback function for interaction events
---@field units table -- Original unit specifications for responsive behavior
---@field _pressed table<number, boolean> -- Track pressed state per mouse button
---@field _lastClickTime number? -- Timestamp of last click for double-click detection
---@field _lastClickButton number? -- Button of last click
---@field _clickCount number -- Current click count for multi-click detection
---@field _touchPressed table<any, boolean> -- Track touch pressed state
---@field gridRows number? -- Number of rows in the grid
---@field gridColumns number? -- Number of columns in the grid
---@field columnGap number|string? -- Gap between grid columns
@@ -727,7 +782,7 @@ Element.__index = Element
---@field flexWrap FlexWrap? -- Whether children wrap to multiple lines (default: NOWRAP)
---@field justifySelf JustifySelf? -- Alignment of the item itself along main axis (default: AUTO)
---@field alignSelf AlignSelf? -- Alignment of the item itself along cross axis (default: AUTO)
---@field callback function? -- Callback function for click events
---@field callback fun(element:Element, event:InputEvent)? -- Callback function for interaction events
---@field transform table? -- Transform properties for animations and styling
---@field transition table? -- Transition settings for animations
---@field gridRows number? -- Number of rows in the grid (default: 1)
@@ -744,6 +799,13 @@ function Element.new(props)
self.callback = props.callback
self.id = props.id or ""
-- Initialize click tracking for event system
self._pressed = {} -- Track pressed state per mouse button
self._lastClickTime = nil
self._lastClickButton = nil
self._clickCount = 0
self._touchPressed = {}
-- Set parent first so it's available for size calculations
self.parent = props.parent
@@ -1788,7 +1850,16 @@ function Element:draw()
end
-- Draw visual feedback when element is pressed (if it has a callback)
if self.callback and self._pressed then
if self.callback then
-- Check if any button is pressed
local anyPressed = false
for _, pressed in pairs(self._pressed) do
if pressed then
anyPressed = true
break
end
end
if anyPressed then
love.graphics.setColor(0.5, 0.5, 0.5, 0.3 * self.opacity) -- Semi-transparent gray for pressed state with opacity
love.graphics.rectangle(
"fill",
@@ -1798,6 +1869,7 @@ function Element:draw()
self.height + self.padding.top + self.padding.bottom
)
end
end
-- Sort children by z-index before drawing
local sortedChildren = {}
@@ -1838,7 +1910,7 @@ function Element:update(dt)
end
end
-- Handle click detection for element
-- Handle click detection for element with enhanced event system
if self.callback then
local mx, my = love.mouse.getPosition()
-- Clickable area is the border box (x, y already includes padding)
@@ -1846,25 +1918,104 @@ function Element:update(dt)
local by = self.y
local bw = self.width + self.padding.left + self.padding.right
local bh = self.height + self.padding.top + self.padding.bottom
if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then
if love.mouse.isDown(1) then
-- set pressed flag
self._pressed = true
elseif not love.mouse.isDown(1) and self._pressed then
self.callback(self)
self._pressed = false
local isHovering = mx >= bx and mx <= bx + bw and my >= by and my <= by + bh
-- Check all three mouse buttons
local buttons = {1, 2, 3} -- left, right, middle
for _, button in ipairs(buttons) do
if isHovering then
if love.mouse.isDown(button) then
-- Button is pressed down
if not self._pressed[button] then
-- Just pressed - fire press event
local modifiers = getModifiers()
local pressEvent = InputEvent.new({
type = "press",
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 1,
})
self.callback(self, pressEvent)
self._pressed[button] = true
end
elseif self._pressed[button] then
-- Button was just released - fire click event
local currentTime = love.timer.getTime()
local modifiers = getModifiers()
-- Determine click count (double-click detection)
local clickCount = 1
local doubleClickThreshold = 0.3 -- 300ms for double-click
if self._lastClickTime
and self._lastClickButton == button
and (currentTime - self._lastClickTime) < doubleClickThreshold then
clickCount = self._clickCount + 1
else
self._pressed = false
clickCount = 1
end
self._clickCount = clickCount
self._lastClickTime = currentTime
self._lastClickButton = button
-- Determine event type based on button
local eventType = "click"
if button == 2 then
eventType = "rightclick"
elseif button == 3 then
eventType = "middleclick"
end
local clickEvent = InputEvent.new({
type = eventType,
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = clickCount,
})
self.callback(self, clickEvent)
self._pressed[button] = false
-- Fire release event
local releaseEvent = InputEvent.new({
type = "release",
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = clickCount,
})
self.callback(self, releaseEvent)
end
else
-- Mouse left the element - reset pressed state
self._pressed[button] = false
end
end
-- Handle touch events (maintain backward compatibility)
local touches = love.touch.getTouches()
for _, id in ipairs(touches) do
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
self.callback(self)
-- Create touch event (treat as left click)
local touchEvent = InputEvent.new({
type = "click",
button = 1,
x = tx,
y = ty,
modifiers = getModifiers(),
clickCount = 1,
})
self.callback(self, touchEvent)
self._touchPressed[id] = false
end
end

View File

@@ -0,0 +1,171 @@
local FlexLove = require("FlexLove")
local Gui = FlexLove.GUI
local Color = FlexLove.Color
---@class EventSystemDemo
---@field window Element
---@field eventLog table<integer, string>
---@field logDisplay Element
local EventSystemDemo = {}
EventSystemDemo.__index = EventSystemDemo
function EventSystemDemo.init()
local self = setmetatable({}, EventSystemDemo)
self.eventLog = {}
-- Create main demo window
self.window = Gui.new({
x = 50,
y = 50,
width = 700,
height = 500,
background = Color.new(0.15, 0.15, 0.2, 0.95),
border = { top = true, bottom = true, left = true, right = true },
borderColor = Color.new(0.8, 0.8, 0.8, 1),
positioning = "flex",
flexDirection = "vertical",
gap = 20,
padding = { top = 20, right = 20, bottom = 20, left = 20 },
})
-- Title
local title = Gui.new({
parent = self.window,
height = 40,
text = "Event System Demo - Try different clicks and modifiers!",
textSize = 18,
textAlign = "center",
textColor = Color.new(1, 1, 1, 1),
background = Color.new(0.2, 0.2, 0.3, 1),
})
-- Button container
local buttonContainer = Gui.new({
parent = self.window,
height = 200,
positioning = "flex",
flexDirection = "horizontal",
gap = 15,
background = Color.new(0.1, 0.1, 0.15, 0.5),
padding = { top = 15, right = 15, bottom = 15, left = 15 },
})
-- Helper function to add event to log
local function logEvent(message)
table.insert(self.eventLog, 1, message) -- Add to beginning
if #self.eventLog > 10 then
table.remove(self.eventLog) -- Keep only last 10 events
end
self:updateLogDisplay()
end
-- Left Click Button
local leftClickBtn = Gui.new({
parent = buttonContainer,
width = 150,
height = 80,
text = "Left Click Me",
textAlign = "center",
textColor = Color.new(1, 1, 1, 1),
background = Color.new(0.2, 0.6, 0.9, 0.8),
border = { top = true, bottom = true, left = true, right = true },
borderColor = Color.new(0.4, 0.8, 1, 1),
callback = function(element, event)
local msg = string.format("[%s] Button: %d, Clicks: %d",
event.type, event.button, event.clickCount)
logEvent(msg)
end,
})
-- Right Click Button
local rightClickBtn = Gui.new({
parent = buttonContainer,
width = 150,
height = 80,
text = "Right Click Me",
textAlign = "center",
textColor = Color.new(1, 1, 1, 1),
background = Color.new(0.9, 0.4, 0.4, 0.8),
border = { top = true, bottom = true, left = true, right = true },
borderColor = Color.new(1, 0.6, 0.6, 1),
callback = function(element, event)
if event.type == "rightclick" then
logEvent("RIGHT CLICK detected!")
elseif event.type == "click" then
logEvent("Left click (try right click!)")
end
end,
})
-- Modifier Button
local modifierBtn = Gui.new({
parent = buttonContainer,
width = 150,
height = 80,
text = "Try Shift/Ctrl",
textAlign = "center",
textColor = Color.new(1, 1, 1, 1),
background = Color.new(0.6, 0.9, 0.4, 0.8),
border = { top = true, bottom = true, left = true, right = true },
borderColor = Color.new(0.8, 1, 0.6, 1),
callback = function(element, event)
if event.type == "click" then
local mods = {}
if event.modifiers.shift then table.insert(mods, "SHIFT") end
if event.modifiers.ctrl then table.insert(mods, "CTRL") end
if event.modifiers.alt then table.insert(mods, "ALT") end
if event.modifiers.cmd then table.insert(mods, "CMD") end
if #mods > 0 then
logEvent("Modifiers: " .. table.concat(mods, "+"))
else
logEvent("No modifiers (try holding Shift/Ctrl)")
end
end
end,
})
-- Multi-Event Button (shows all event types)
local multiEventBtn = Gui.new({
parent = buttonContainer,
width = 150,
height = 80,
text = "All Events",
textAlign = "center",
textColor = Color.new(1, 1, 1, 1),
background = Color.new(0.9, 0.7, 0.3, 0.8),
border = { top = true, bottom = true, left = true, right = true },
borderColor = Color.new(1, 0.9, 0.5, 1),
callback = function(element, event)
local msg = string.format("[%s] Btn:%d at (%d,%d)",
event.type, event.button, math.floor(event.x), math.floor(event.y))
logEvent(msg)
end,
})
-- Event log display area
self.logDisplay = Gui.new({
parent = self.window,
height = 200,
text = "Event log will appear here...",
textSize = 14,
textAlign = "start",
textColor = Color.new(0.9, 0.9, 1, 1),
background = Color.new(0.05, 0.05, 0.1, 1),
border = { top = true, bottom = true, left = true, right = true },
borderColor = Color.new(0.3, 0.3, 0.4, 1),
padding = { top = 10, right = 10, bottom = 10, left = 10 },
})
return self
end
function EventSystemDemo:updateLogDisplay()
if #self.eventLog == 0 then
self.logDisplay.text = "Event log will appear here..."
else
self.logDisplay.text = table.concat(self.eventLog, "\n")
end
end
return EventSystemDemo.init()

View File

@@ -0,0 +1,347 @@
-- Event System Tests
-- Tests for the enhanced callback system with InputEvent objects
package.path = package.path .. ";?.lua"
local lu = require("testing/luaunit")
require("testing/loveStub") -- Required to mock LOVE functions
local FlexLove = require("FlexLove")
local Gui = FlexLove.GUI
local Color = FlexLove.Color
TestEventSystem = {}
function TestEventSystem:setUp()
-- Initialize GUI before each test
Gui.init({ baseScale = { width = 1920, height = 1080 } })
love.window.setMode(1920, 1080)
end
function TestEventSystem:tearDown()
-- Clean up after each test
Gui.destroy()
end
-- Test 1: Event object structure
function TestEventSystem:test_event_object_has_required_fields()
local eventReceived = nil
local button = Gui.new({
x = 100,
y = 100,
width = 200,
height = 100,
callback = function(element, event)
eventReceived = event
end,
})
-- Simulate mouse press and release
love.mouse.setPosition(150, 150)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
-- Verify event object structure
lu.assertNotNil(eventReceived, "Event should be received")
lu.assertNotNil(eventReceived.type, "Event should have type field")
lu.assertNotNil(eventReceived.button, "Event should have button field")
lu.assertNotNil(eventReceived.x, "Event should have x field")
lu.assertNotNil(eventReceived.y, "Event should have y field")
lu.assertNotNil(eventReceived.modifiers, "Event should have modifiers field")
lu.assertNotNil(eventReceived.clickCount, "Event should have clickCount field")
lu.assertNotNil(eventReceived.timestamp, "Event should have timestamp field")
end
-- Test 2: Left click event
function TestEventSystem:test_left_click_generates_click_event()
local eventsReceived = {}
local button = Gui.new({
x = 100,
y = 100,
width = 200,
height = 100,
callback = function(element, event)
table.insert(eventsReceived, {type = event.type, button = event.button})
end,
})
-- Simulate left click
love.mouse.setPosition(150, 150)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
-- Should receive press, click, and release events
lu.assertTrue(#eventsReceived >= 2, "Should receive at least 2 events")
-- Check for click event
local hasClickEvent = false
for _, evt in ipairs(eventsReceived) do
if evt.type == "click" and evt.button == 1 then
hasClickEvent = true
break
end
end
lu.assertTrue(hasClickEvent, "Should receive click event for left button")
end
-- Test 3: Right click event
function TestEventSystem:test_right_click_generates_rightclick_event()
local eventsReceived = {}
local button = Gui.new({
x = 100,
y = 100,
width = 200,
height = 100,
callback = function(element, event)
table.insert(eventsReceived, {type = event.type, button = event.button})
end,
})
-- Simulate right click
love.mouse.setPosition(150, 150)
love.mouse.setDown(2, true)
button:update(0.016)
love.mouse.setDown(2, false)
button:update(0.016)
-- Check for rightclick event
local hasRightClickEvent = false
for _, evt in ipairs(eventsReceived) do
if evt.type == "rightclick" and evt.button == 2 then
hasRightClickEvent = true
break
end
end
lu.assertTrue(hasRightClickEvent, "Should receive rightclick event for right button")
end
-- Test 4: Middle click event
function TestEventSystem:test_middle_click_generates_middleclick_event()
local eventsReceived = {}
local button = Gui.new({
x = 100,
y = 100,
width = 200,
height = 100,
callback = function(element, event)
table.insert(eventsReceived, {type = event.type, button = event.button})
end,
})
-- Simulate middle click
love.mouse.setPosition(150, 150)
love.mouse.setDown(3, true)
button:update(0.016)
love.mouse.setDown(3, false)
button:update(0.016)
-- Check for middleclick event
local hasMiddleClickEvent = false
for _, evt in ipairs(eventsReceived) do
if evt.type == "middleclick" and evt.button == 3 then
hasMiddleClickEvent = true
break
end
end
lu.assertTrue(hasMiddleClickEvent, "Should receive middleclick event for middle button")
end
-- Test 5: Modifier keys detection
function TestEventSystem:test_modifier_keys_are_detected()
local eventReceived = nil
local button = Gui.new({
x = 100,
y = 100,
width = 200,
height = 100,
callback = function(element, event)
if event.type == "click" then
eventReceived = event
end
end,
})
-- Simulate shift + click
love.keyboard.setDown("lshift", true)
love.mouse.setPosition(150, 150)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
lu.assertNotNil(eventReceived, "Should receive click event")
lu.assertTrue(eventReceived.modifiers.shift, "Shift modifier should be detected")
end
-- Test 6: Double click detection
function TestEventSystem:test_double_click_increments_click_count()
local clickEvents = {}
local button = Gui.new({
x = 100,
y = 100,
width = 200,
height = 100,
callback = function(element, event)
if event.type == "click" then
table.insert(clickEvents, event.clickCount)
end
end,
})
-- Simulate first click
love.mouse.setPosition(150, 150)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
-- Simulate second click quickly (double-click)
love.timer.setTime(love.timer.getTime() + 0.1) -- 100ms later
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
lu.assertEquals(#clickEvents, 2, "Should receive 2 click events")
lu.assertEquals(clickEvents[1], 1, "First click should have clickCount = 1")
lu.assertEquals(clickEvents[2], 2, "Second click should have clickCount = 2")
end
-- Test 7: Press and release events
function TestEventSystem:test_press_and_release_events_are_fired()
local eventsReceived = {}
local button = Gui.new({
x = 100,
y = 100,
width = 200,
height = 100,
callback = function(element, event)
table.insert(eventsReceived, event.type)
end,
})
-- Simulate click
love.mouse.setPosition(150, 150)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
-- Should receive press, click, and release
lu.assertTrue(#eventsReceived >= 2, "Should receive multiple events")
local hasPress = false
local hasRelease = false
for _, eventType in ipairs(eventsReceived) do
if eventType == "press" then hasPress = true end
if eventType == "release" then hasRelease = true end
end
lu.assertTrue(hasPress, "Should receive press event")
lu.assertTrue(hasRelease, "Should receive release event")
end
-- Test 8: Mouse position in event
function TestEventSystem:test_event_contains_mouse_position()
local eventReceived = nil
local button = Gui.new({
x = 100,
y = 100,
width = 200,
height = 100,
callback = function(element, event)
if event.type == "click" then
eventReceived = event
end
end,
})
-- Simulate click at specific position
local mouseX, mouseY = 175, 125
love.mouse.setPosition(mouseX, mouseY)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
lu.assertNotNil(eventReceived, "Should receive click event")
lu.assertEquals(eventReceived.x, mouseX, "Event should contain correct mouse X position")
lu.assertEquals(eventReceived.y, mouseY, "Event should contain correct mouse Y position")
end
-- Test 9: No callback when mouse outside element
function TestEventSystem:test_no_callback_when_clicking_outside_element()
local callbackCalled = false
local button = Gui.new({
x = 100,
y = 100,
width = 200,
height = 100,
callback = function(element, event)
callbackCalled = true
end,
})
-- Click outside element
love.mouse.setPosition(50, 50)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
lu.assertFalse(callbackCalled, "Callback should not be called when clicking outside element")
end
-- Test 10: Multiple modifiers
function TestEventSystem:test_multiple_modifiers_detected()
local eventReceived = nil
local button = Gui.new({
x = 100,
y = 100,
width = 200,
height = 100,
callback = function(element, event)
if event.type == "click" then
eventReceived = event
end
end,
})
-- Simulate shift + ctrl + click
love.keyboard.setDown("lshift", true)
love.keyboard.setDown("lctrl", true)
love.mouse.setPosition(150, 150)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
lu.assertNotNil(eventReceived, "Should receive click event")
lu.assertTrue(eventReceived.modifiers.shift, "Shift modifier should be detected")
lu.assertTrue(eventReceived.modifiers.ctrl, "Ctrl modifier should be detected")
end
return TestEventSystem

View File

@@ -19,6 +19,7 @@ local testFiles = {
"testing/__tests__/13_relative_positioning_tests.lua",
"testing/__tests__/14_text_scaling_basic_tests.lua",
"testing/__tests__/15_grid_layout_tests.lua",
"testing/__tests__/16_event_system_tests.lua",
}
-- testingun all tests, but don't exit on error