diff --git a/FlexLove.lua b/FlexLove.lua index 2804c10..0882d81 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -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 -- 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 -- 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,15 +1850,25 @@ function Element:draw() end -- Draw visual feedback when element is pressed (if it has a callback) - if self.callback and self._pressed 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", - self.x, - self.y, - self.width + self.padding.left + self.padding.right, - self.height + self.padding.top + self.padding.bottom - ) + 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", + self.x, + self.y, + self.width + self.padding.left + self.padding.right, + self.height + self.padding.top + self.padding.bottom + ) + end end -- Sort children by z-index before drawing @@ -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 + 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 - else - self._pressed = false 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 diff --git a/examples/EventSystemDemo.lua b/examples/EventSystemDemo.lua new file mode 100644 index 0000000..82d6bf0 --- /dev/null +++ b/examples/EventSystemDemo.lua @@ -0,0 +1,171 @@ +local FlexLove = require("FlexLove") +local Gui = FlexLove.GUI +local Color = FlexLove.Color + +---@class EventSystemDemo +---@field window Element +---@field eventLog table +---@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() diff --git a/testing/__tests__/16_event_system_tests.lua b/testing/__tests__/16_event_system_tests.lua new file mode 100644 index 0000000..cc90c64 --- /dev/null +++ b/testing/__tests__/16_event_system_tests.lua @@ -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 diff --git a/testing/runAll.lua b/testing/runAll.lua index 5868794..6c91c62 100644 --- a/testing/runAll.lua +++ b/testing/runAll.lua @@ -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