From 6e9b17b7dca34174f15cf150332233847114c02d Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 30 Oct 2025 13:42:34 -0400 Subject: [PATCH] drag event --- FlexLove.lua | 67 ++++- examples/14_drag_slider.lua | 306 ++++++++++++++++++++++ testing/__tests__/29_drag_event_tests.lua | 282 ++++++++++++++++++++ 3 files changed, 650 insertions(+), 5 deletions(-) create mode 100644 examples/14_drag_slider.lua create mode 100644 testing/__tests__/29_drag_event_tests.lua diff --git a/FlexLove.lua b/FlexLove.lua index 23c3247..89c8114 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -2590,10 +2590,12 @@ end -- ==================== ---@class InputEvent ----@field type "click"|"press"|"release"|"rightclick"|"middleclick" +---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag" ---@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle) ---@field x number -- Mouse X position ---@field y number -- Mouse Y position +---@field dx number? -- Delta X from drag start (only for drag events) +---@field dy number? -- Delta Y from drag start (only for drag events) ---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean} ---@field clickCount number -- Number of clicks (for double/triple click detection) ---@field timestamp number -- Time when event occurred @@ -2601,10 +2603,12 @@ local InputEvent = {} InputEvent.__index = InputEvent ---@class InputEventProps ----@field type "click"|"press"|"release"|"rightclick"|"middleclick" +---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag" ---@field button number ---@field x number ---@field y number +---@field dx number? +---@field dy number? ---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean} ---@field clickCount number? ---@field timestamp number? @@ -2618,6 +2622,8 @@ function InputEvent.new(props) self.button = props.button self.x = props.x self.y = props.y + self.dx = props.dx + self.dy = props.dy self.modifiers = props.modifiers self.clickCount = props.clickCount or 1 self.timestamp = props.timestamp or love.timer.getTime() @@ -2980,6 +2986,10 @@ Public API methods to access internal state: ---@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 _dragStartX table? -- Track drag start X position per mouse button +---@field _dragStartY table? -- Track drag start Y position per mouse button +---@field _lastMouseX table? -- Last known mouse X position per button for drag tracking +---@field _lastMouseY table? -- Last known mouse Y position per button for drag tracking ---@field _explicitlyAbsolute boolean? ---@field gridRows number? -- Number of rows in the grid ---@field gridColumns number? -- Number of columns in the grid @@ -3126,6 +3136,12 @@ function Element.new(props) self._lastClickButton = nil self._clickCount = 0 self._touchPressed = {} + + -- Initialize drag tracking for event system + self._dragStartX = {} -- Track drag start X position per mouse button + self._dragStartY = {} -- Track drag start Y position per mouse button + self._lastMouseX = {} -- Track last mouse X position per button + self._lastMouseY = {} -- Track last mouse Y position per button -- Initialize theme self._themeState = "normal" @@ -5118,7 +5134,7 @@ function Element:update(dt) if love.mouse.isDown(button) then -- Button is pressed down if not self._pressed[button] then - -- Just pressed - fire press event + -- Just pressed - fire press event and record drag start position local modifiers = getModifiers() local pressEvent = InputEvent.new({ type = "press", @@ -5130,6 +5146,39 @@ function Element:update(dt) }) self.callback(self, pressEvent) self._pressed[button] = true + + -- Record drag start position per button + self._dragStartX[button] = mx + self._dragStartY[button] = my + self._lastMouseX[button] = mx + self._lastMouseY[button] = my + else + -- Button is still pressed - check for mouse movement (drag) + local lastX = self._lastMouseX[button] or mx + local lastY = self._lastMouseY[button] or my + + if lastX ~= mx or lastY ~= my then + -- Mouse has moved - fire drag event + local modifiers = getModifiers() + local dx = mx - self._dragStartX[button] + local dy = my - self._dragStartY[button] + + local dragEvent = InputEvent.new({ + type = "drag", + button = button, + x = mx, + y = my, + dx = dx, + dy = dy, + modifiers = modifiers, + clickCount = 1, + }) + self.callback(self, dragEvent) + + -- Update last known position for this button + self._lastMouseX[button] = mx + self._lastMouseY[button] = my + end end elseif self._pressed[button] then -- Button was just released - fire click event @@ -5169,6 +5218,10 @@ function Element:update(dt) self.callback(self, clickEvent) self._pressed[button] = false + + -- Clean up drag tracking + self._dragStartX[button] = nil + self._dragStartY[button] = nil -- Focus editable elements on left click if button == 1 and self.editable then @@ -5187,8 +5240,12 @@ function Element:update(dt) self.callback(self, releaseEvent) end else - -- Mouse left the element - reset pressed state - self._pressed[button] = false + -- Mouse left the element - reset pressed state and drag tracking + if self._pressed[button] then + self._pressed[button] = false + self._dragStartX[button] = nil + self._dragStartY[button] = nil + end end end end -- end if self.callback diff --git a/examples/14_drag_slider.lua b/examples/14_drag_slider.lua new file mode 100644 index 0000000..a228521 --- /dev/null +++ b/examples/14_drag_slider.lua @@ -0,0 +1,306 @@ +--[[ + FlexLove Example 14: Drag Event - Slider Implementation + + This example demonstrates how to use the new drag event to create + interactive sliders without any first-party slider component. + + Features demonstrated: + - Using drag events for continuous mouse tracking + - Converting mouse coordinates to element-relative positions + - Updating UI elements based on drag position + - Creating reusable slider components + + Run with: love /path/to/libs/examples/14_drag_slider.lua +]] + +local Lv = love + +local FlexLove = require("../FlexLove") +local Gui = FlexLove.Gui +local Color = FlexLove.Color +local enums = FlexLove.enums + +-- Slider state +local volume = 0.5 -- 0.0 to 1.0 +local brightness = 0.75 -- 0.0 to 1.0 +local temperature = 20 -- 0 to 40 (degrees) + +-- UI elements +local volumeValueText +local brightnessValueText +local temperatureValueText +local volumeHandle +local brightnessHandle +local temperatureHandle + +--- Helper function to create a slider +---@param x string|number +---@param y string|number +---@param width string|number +---@param label string +---@param min number +---@param max number +---@param initialValue number +---@param onValueChange function +---@return table -- Returns { bg, handle, valueText } +local function createSlider(x, y, width, label, min, max, initialValue, onValueChange) + -- Container for the slider + local container = Gui.new({ + x = x, + y = y, + width = width, + height = "12vh", + positioning = enums.Positioning.FLEX, + flexDirection = enums.FlexDirection.VERTICAL, + gap = 5, + }) + + -- Label + Gui.new({ + parent = container, + height = "3vh", + text = label, + textSize = "2vh", + textColor = Color.new(0.9, 0.9, 0.9, 1), + }) + + -- Slider track background + local sliderBg = Gui.new({ + parent = container, + height = "4vh", + backgroundColor = Color.new(0.2, 0.2, 0.25, 1), + cornerRadius = 5, + positioning = enums.Positioning.RELATIVE, + }) + + -- Slider handle + local normalized = (initialValue - min) / (max - min) + local handle = Gui.new({ + parent = sliderBg, + x = (normalized * 95) .. "%", + y = "50%", + width = "5%", + height = "80%", + backgroundColor = Color.new(0.4, 0.6, 0.9, 1), + cornerRadius = 3, + positioning = enums.Positioning.ABSOLUTE, + -- Center the handle vertically + top = "10%", + }) + + -- Value display + local valueText = Gui.new({ + parent = container, + height = "3vh", + text = string.format("%.2f", initialValue), + textSize = "2vh", + textColor = Color.new(0.7, 0.8, 1, 1), + textAlign = enums.TextAlign.CENTER, + }) + + -- Make the background track interactive + sliderBg.callback = function(element, event) + if event.type == "press" or event.type == "drag" then + -- Get element bounds + local bg_x = element.x + local bg_width = element.width + + -- Calculate relative position (0 to 1) + local mouse_x = event.x + local relative_x = mouse_x - bg_x + local new_normalized = math.max(0, math.min(1, relative_x / bg_width)) + + -- Calculate actual value + local new_value = min + (new_normalized * (max - min)) + + -- Update handle position (use percentage for responsiveness) + handle.x = (new_normalized * 95) .. "%" + + -- Update value text + if max - min > 10 then + -- For larger ranges (like temperature), show integers + valueText.text = string.format("%d", new_value) + else + -- For smaller ranges (like 0-1), show decimals + valueText.text = string.format("%.2f", new_value) + end + + -- Call the value change callback + if onValueChange then + onValueChange(new_value) + end + + -- Re-layout to apply position changes + element:recalculateUnits(Lv.graphics.getWidth(), Lv.graphics.getHeight()) + end + end + + return { + container = container, + bg = sliderBg, + handle = handle, + valueText = valueText, + } +end + +function Lv.load() + Gui.init({ + baseScale = { width = 1920, height = 1080 }, + }) + + -- Title + Gui.new({ + x = "2vw", + y = "2vh", + width = "96vw", + height = "6vh", + text = "FlexLove Example 14: Drag Event - Slider Implementation", + textSize = "4vh", + textColor = Color.new(1, 1, 1, 1), + textAlign = enums.TextAlign.CENTER, + }) + + -- Subtitle + Gui.new({ + x = "2vw", + y = "9vh", + width = "96vw", + height = "3vh", + text = "Drag the sliders to change values - built using only the drag event primitive!", + textSize = "2vh", + textColor = Color.new(0.7, 0.7, 0.7, 1), + textAlign = enums.TextAlign.CENTER, + }) + + -- Volume Slider (0.0 - 1.0) + local volumeSlider = createSlider("10vw", "18vh", "80vw", "Volume (0.0 - 1.0)", 0.0, 1.0, volume, function(value) + volume = value + end) + volumeValueText = volumeSlider.valueText + volumeHandle = volumeSlider.handle + + -- Brightness Slider (0.0 - 1.0) + local brightnessSlider = createSlider("10vw", "35vh", "80vw", "Brightness (0.0 - 1.0)", 0.0, 1.0, brightness, function(value) + brightness = value + end) + brightnessValueText = brightnessSlider.valueText + brightnessHandle = brightnessSlider.handle + + -- Temperature Slider (0 - 40°C) + local temperatureSlider = createSlider("10vw", "52vh", "80vw", "Temperature (0 - 40°C)", 0, 40, temperature, function(value) + temperature = value + end) + temperatureValueText = temperatureSlider.valueText + temperatureHandle = temperatureSlider.handle + + -- Visual feedback section + Gui.new({ + x = "10vw", + y = "70vh", + width = "80vw", + height = "3vh", + text = "Visual Feedback:", + textSize = "2.5vh", + textColor = Color.new(1, 1, 1, 1), + }) + + -- Volume visualization + Gui.new({ + x = "10vw", + y = "75vh", + width = "25vw", + height = "20vh", + backgroundColor = Color.new(0.15, 0.15, 0.2, 1), + cornerRadius = 10, + border = { top = true, right = true, bottom = true, left = true }, + borderColor = Color.new(0.3, 0.3, 0.4, 1), + }) + + -- Brightness visualization + Gui.new({ + x = "37.5vw", + y = "75vh", + width = "25vw", + height = "20vh", + backgroundColor = Color.new(0.15, 0.15, 0.2, 1), + cornerRadius = 10, + border = { top = true, right = true, bottom = true, left = true }, + borderColor = Color.new(0.3, 0.3, 0.4, 1), + }) + + -- Temperature visualization + Gui.new({ + x = "65vw", + y = "75vh", + width = "25vw", + height = "20vh", + backgroundColor = Color.new(0.15, 0.15, 0.2, 1), + cornerRadius = 10, + border = { top = true, right = true, bottom = true, left = true }, + borderColor = Color.new(0.3, 0.3, 0.4, 1), + }) +end + +function Lv.update(dt) + Gui.update(dt) +end + +function Lv.draw() + Lv.graphics.clear(0.05, 0.05, 0.08, 1) + Gui.draw() + + -- Draw volume visualization (speaker icon with bars) + local volumeX = Lv.graphics.getWidth() * 0.10 + 20 + local volumeY = Lv.graphics.getHeight() * 0.75 + 30 + Lv.graphics.setColor(0.4, 0.6, 0.9, 1) + Lv.graphics.print("Volume:", volumeX, volumeY, 0, 2, 2) + + -- Volume bars + local barCount = 10 + for i = 1, barCount do + if i / barCount <= volume then + Lv.graphics.setColor(0.4, 0.8, 0.4, 1) + else + Lv.graphics.setColor(0.2, 0.2, 0.25, 1) + end + local barX = volumeX + 20 + (i - 1) * 30 + local barHeight = 20 + i * 5 + Lv.graphics.rectangle("fill", barX, volumeY + 60 - barHeight, 20, barHeight, 3) + end + + -- Draw brightness visualization (sun icon) + local brightnessX = Lv.graphics.getWidth() * 0.375 + 20 + local brightnessY = Lv.graphics.getHeight() * 0.75 + 30 + Lv.graphics.setColor(0.4, 0.6, 0.9, 1) + Lv.graphics.print("Brightness:", brightnessX, brightnessY, 0, 2, 2) + + -- Sun circle + Lv.graphics.setColor(1, 0.9, 0.3, brightness) + Lv.graphics.circle("fill", brightnessX + 150, brightnessY + 80, 30 * brightness + 10) + + -- Draw temperature visualization (thermometer) + local tempX = Lv.graphics.getWidth() * 0.65 + 20 + local tempY = Lv.graphics.getHeight() * 0.75 + 30 + Lv.graphics.setColor(0.4, 0.6, 0.9, 1) + Lv.graphics.print("Temperature:", tempX, tempY, 0, 2, 2) + + -- Thermometer + local tempNormalized = temperature / 40 + local tempColor = { + 1 - tempNormalized * 0.5, -- Red increases with temp + 0.3, + 1 - tempNormalized, -- Blue decreases with temp + } + Lv.graphics.setColor(tempColor[1], tempColor[2], tempColor[3], 1) + Lv.graphics.rectangle("fill", tempX + 100, tempY + 50, 40, 100 * tempNormalized, 5) + Lv.graphics.setColor(0.3, 0.3, 0.4, 1) + Lv.graphics.rectangle("line", tempX + 100, tempY + 50, 40, 100, 5) + + -- Temperature text + Lv.graphics.setColor(1, 1, 1, 1) + Lv.graphics.print(string.format("%.0f°C", temperature), tempX + 160, tempY + 90, 0, 2, 2) +end + +function Lv.resize(w, h) + Gui.resize(w, h) +end diff --git a/testing/__tests__/29_drag_event_tests.lua b/testing/__tests__/29_drag_event_tests.lua new file mode 100644 index 0000000..4fecdee --- /dev/null +++ b/testing/__tests__/29_drag_event_tests.lua @@ -0,0 +1,282 @@ +-- Drag Event Tests +-- Tests for the new drag event functionality + +package.path = package.path .. ";?.lua" + +local lu = require("testing.luaunit") +require("testing.loveStub") +local FlexLove = require("FlexLove") +local Gui = FlexLove.Gui + +TestDragEvent = {} + +function TestDragEvent:setUp() + -- Initialize GUI before each test + Gui.init({ baseScale = { width = 1920, height = 1080 } }) + love.window.setMode(1920, 1080) + Gui.resize(1920, 1080) -- Recalculate scale factors after setMode +end + +function TestDragEvent:tearDown() + -- Clean up after each test + Gui.destroy() +end + +-- Test 1: Drag event is fired when mouse moves while pressed +function TestDragEvent:test_drag_event_fired_on_mouse_movement() + local dragEventReceived = false + local dragEvent = nil + + local element = Gui.new({ + x = 100, + y = 100, + width = 200, + height = 100, + callback = function(el, event) + if event.type == "drag" then + dragEventReceived = true + dragEvent = event + end + end, + }) + + -- Simulate mouse press + love.mouse.setPosition(150, 150) + love.mouse.setDown(1, true) + element:update(0.016) + + -- Move mouse while pressed (drag) + love.mouse.setPosition(160, 155) + element:update(0.016) + + lu.assertTrue(dragEventReceived, "Drag event should be fired when mouse moves while pressed") + lu.assertNotNil(dragEvent, "Drag event object should exist") +end + +-- Test 2: Drag event contains dx and dy fields +function TestDragEvent:test_drag_event_contains_delta_values() + local dragEvent = nil + + local element = Gui.new({ + x = 100, + y = 100, + width = 200, + height = 100, + callback = function(el, event) + if event.type == "drag" then + dragEvent = event + end + end, + }) + + -- Simulate mouse press at (150, 150) + love.mouse.setPosition(150, 150) + love.mouse.setDown(1, true) + element:update(0.016) + + -- Move mouse to (160, 155) - delta should be (10, 5) + love.mouse.setPosition(160, 155) + element:update(0.016) + + lu.assertNotNil(dragEvent, "Drag event should be received") + lu.assertNotNil(dragEvent.dx, "Drag event should have dx field") + lu.assertNotNil(dragEvent.dy, "Drag event should have dy field") + lu.assertEquals(dragEvent.dx, 10, "dx should be 10 (160 - 150)") + lu.assertEquals(dragEvent.dy, 5, "dy should be 5 (155 - 150)") +end + +-- Test 3: Drag event updates dx/dy as mouse continues to move +function TestDragEvent:test_drag_event_updates_delta_continuously() + local dragEvents = {} + + local element = Gui.new({ + x = 100, + y = 100, + width = 200, + height = 100, + callback = function(el, event) + if event.type == "drag" then + table.insert(dragEvents, { dx = event.dx, dy = event.dy }) + end + end, + }) + + -- Press at (150, 150) + love.mouse.setPosition(150, 150) + love.mouse.setDown(1, true) + element:update(0.016) + + -- Move to (160, 155) + love.mouse.setPosition(160, 155) + element:update(0.016) + + -- Move to (170, 160) + love.mouse.setPosition(170, 160) + element:update(0.016) + + lu.assertEquals(#dragEvents, 2, "Should receive 2 drag events") + lu.assertEquals(dragEvents[1].dx, 10, "First drag dx should be 10") + lu.assertEquals(dragEvents[1].dy, 5, "First drag dy should be 5") + lu.assertEquals(dragEvents[2].dx, 20, "Second drag dx should be 20 (170 - 150)") + lu.assertEquals(dragEvents[2].dy, 10, "Second drag dy should be 10 (160 - 150)") +end + +-- Test 4: No drag event when mouse doesn't move +function TestDragEvent:test_no_drag_event_when_mouse_stationary() + local dragEventCount = 0 + + local element = Gui.new({ + x = 100, + y = 100, + width = 200, + height = 100, + callback = function(el, event) + if event.type == "drag" then + dragEventCount = dragEventCount + 1 + end + end, + }) + + -- Press at (150, 150) + love.mouse.setPosition(150, 150) + love.mouse.setDown(1, true) + element:update(0.016) + + -- Update again without moving mouse + element:update(0.016) + element:update(0.016) + + lu.assertEquals(dragEventCount, 0, "Should not receive drag events when mouse doesn't move") +end + +-- Test 5: Drag tracking is cleaned up on release +function TestDragEvent:test_drag_tracking_cleaned_up_on_release() + local dragEvents = {} + + local element = Gui.new({ + x = 100, + y = 100, + width = 200, + height = 100, + callback = function(el, event) + if event.type == "drag" then + table.insert(dragEvents, { dx = event.dx, dy = event.dy }) + end + end, + }) + + -- First drag sequence + love.mouse.setPosition(150, 150) + love.mouse.setDown(1, true) + element:update(0.016) + + love.mouse.setPosition(160, 155) + element:update(0.016) + + -- Release + love.mouse.setDown(1, false) + element:update(0.016) + + -- Second drag sequence - should start fresh + love.mouse.setPosition(150, 150) + love.mouse.setDown(1, true) + element:update(0.016) + + love.mouse.setPosition(155, 152) + element:update(0.016) + + lu.assertEquals(#dragEvents, 2, "Should receive 2 drag events total") + lu.assertEquals(dragEvents[1].dx, 10, "First drag dx should be 10") + lu.assertEquals(dragEvents[2].dx, 5, "Second drag dx should be 5 (new drag start)") +end + +-- Test 6: Drag works with different mouse buttons +function TestDragEvent:test_drag_works_with_different_buttons() + local dragEvents = {} + + local element = Gui.new({ + x = 100, + y = 100, + width = 200, + height = 100, + callback = function(el, event) + if event.type == "drag" then + table.insert(dragEvents, { button = event.button, dx = event.dx }) + end + end, + }) + + -- Right button drag + -- Make sure no other buttons are down + love.mouse.setDown(1, false) + love.mouse.setDown(3, false) + + love.mouse.setPosition(150, 150) + love.mouse.setDown(2, true) + element:update(0.016) + + love.mouse.setPosition(160, 150) + element:update(0.016) + + lu.assertEquals(#dragEvents, 1, "Should receive drag event for right button") + lu.assertEquals(dragEvents[1].button, 2, "Drag event should be for button 2") + lu.assertEquals(dragEvents[1].dx, 10, "Drag dx should be 10") +end + +-- Test 7: Drag event contains correct mouse position +function TestDragEvent:test_drag_event_contains_mouse_position() + local dragEvent = nil + + local element = Gui.new({ + x = 100, + y = 100, + width = 200, + height = 100, + callback = function(el, event) + if event.type == "drag" then + dragEvent = event + end + end, + }) + + love.mouse.setPosition(150, 150) + love.mouse.setDown(1, true) + element:update(0.016) + + love.mouse.setPosition(175, 165) + element:update(0.016) + + lu.assertNotNil(dragEvent, "Drag event should be received") + lu.assertEquals(dragEvent.x, 175, "Drag event x should match current mouse x") + lu.assertEquals(dragEvent.y, 165, "Drag event y should match current mouse y") +end + +-- Test 8: No drag event when mouse leaves element +function TestDragEvent:test_no_drag_when_mouse_leaves_element() + local dragEventCount = 0 + + local element = Gui.new({ + x = 100, + y = 100, + width = 200, + height = 100, + callback = function(el, event) + if event.type == "drag" then + dragEventCount = dragEventCount + 1 + end + end, + }) + + -- Press inside element + love.mouse.setPosition(150, 150) + love.mouse.setDown(1, true) + element:update(0.016) + + -- Move outside element + love.mouse.setPosition(50, 50) + element:update(0.016) + + lu.assertEquals(dragEventCount, 0, "Should not receive drag events when mouse leaves element") +end + +lu.LuaUnit.run()