From ae2e08ca1b5f685fc92ed708b541b46147b9143a Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 13 Oct 2025 11:07:47 -0400 Subject: [PATCH] change spacing logic --- FlexLove.lua | 137 +++++- README.md | 2 +- .../17_sibling_space_reservation_tests.lua | 437 ++++++++++++++++++ testing/runAll.lua | 1 + 4 files changed, 555 insertions(+), 22 deletions(-) create mode 100644 testing/__tests__/17_sibling_space_reservation_tests.lua diff --git a/FlexLove.lua b/FlexLove.lua index 0161893..49f1a67 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -778,9 +778,37 @@ function Grid.layoutGridItems(element) local rows = element.gridRows or 1 local columns = element.gridColumns or 1 - -- Calculate available space - local availableWidth = element.width - element.padding.left - element.padding.right - local availableHeight = element.height - element.padding.top - element.padding.bottom + -- Calculate space reserved by absolutely positioned siblings + local reservedLeft = 0 + local reservedRight = 0 + local reservedTop = 0 + local reservedBottom = 0 + + for _, child in ipairs(element.children) do + -- Only consider absolutely positioned children with explicit positioning + if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute then + if child.left then + local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right + reservedLeft = math.max(reservedLeft, child.left + childTotalWidth) + end + if child.right then + local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right + reservedRight = math.max(reservedRight, child.right + childTotalWidth) + end + if child.top then + local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom + reservedTop = math.max(reservedTop, child.top + childTotalHeight) + end + if child.bottom then + local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom + reservedBottom = math.max(reservedBottom, child.bottom + childTotalHeight) + end + end + end + + -- Calculate available space (accounting for padding and reserved space) + local availableWidth = element.width - element.padding.left - element.padding.right - reservedLeft - reservedRight + local availableHeight = element.height - element.padding.top - element.padding.bottom - reservedTop - reservedBottom -- Get gaps local columnGap = element.columnGap or 0 @@ -812,9 +840,9 @@ function Grid.layoutGridItems(element) break end - -- Calculate cell position - local cellX = element.x + element.padding.left + (col * (cellWidth + columnGap)) - local cellY = element.y + element.padding.top + (row * (cellHeight + rowGap)) + -- Calculate cell position (accounting for reserved space) + local cellX = element.x + element.padding.left + reservedLeft + (col * (cellWidth + columnGap)) + local cellY = element.y + element.padding.top + reservedTop + (row * (cellHeight + rowGap)) -- Apply alignment within grid cell (default to stretch) local effectiveAlignItems = element.alignItems or AlignItems.STRETCH @@ -2016,15 +2044,80 @@ function Element:layoutChildren() return end - -- Calculate available space (accounting for padding) + -- Calculate space reserved by absolutely positioned siblings with explicit positioning + local reservedMainStart = 0 -- Space reserved at the start of main axis (left for horizontal, top for vertical) + local reservedMainEnd = 0 -- Space reserved at the end of main axis (right for horizontal, bottom for vertical) + local reservedCrossStart = 0 -- Space reserved at the start of cross axis (top for horizontal, left for vertical) + local reservedCrossEnd = 0 -- Space reserved at the end of cross axis (bottom for horizontal, right for vertical) + + for _, child in ipairs(self.children) do + -- Only consider absolutely positioned children with explicit positioning + if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute then + if self.flexDirection == FlexDirection.HORIZONTAL then + -- Horizontal layout: main axis is X, cross axis is Y + -- Check for left positioning (reserves space at main axis start) + if child.left then + local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right + local spaceNeeded = child.left + childTotalWidth + reservedMainStart = math.max(reservedMainStart, spaceNeeded) + end + -- Check for right positioning (reserves space at main axis end) + if child.right then + local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right + local spaceNeeded = child.right + childTotalWidth + reservedMainEnd = math.max(reservedMainEnd, spaceNeeded) + end + -- Check for top positioning (reserves space at cross axis start) + if child.top then + local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom + local spaceNeeded = child.top + childTotalHeight + reservedCrossStart = math.max(reservedCrossStart, spaceNeeded) + end + -- Check for bottom positioning (reserves space at cross axis end) + if child.bottom then + local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom + local spaceNeeded = child.bottom + childTotalHeight + reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded) + end + else + -- Vertical layout: main axis is Y, cross axis is X + -- Check for top positioning (reserves space at main axis start) + if child.top then + local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom + local spaceNeeded = child.top + childTotalHeight + reservedMainStart = math.max(reservedMainStart, spaceNeeded) + end + -- Check for bottom positioning (reserves space at main axis end) + if child.bottom then + local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom + local spaceNeeded = child.bottom + childTotalHeight + reservedMainEnd = math.max(reservedMainEnd, spaceNeeded) + end + -- Check for left positioning (reserves space at cross axis start) + if child.left then + local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right + local spaceNeeded = child.left + childTotalWidth + reservedCrossStart = math.max(reservedCrossStart, spaceNeeded) + end + -- Check for right positioning (reserves space at cross axis end) + if child.right then + local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right + local spaceNeeded = child.right + childTotalWidth + reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded) + end + end + end + end + + -- Calculate available space (accounting for padding and reserved space) local availableMainSize = 0 local availableCrossSize = 0 if self.flexDirection == FlexDirection.HORIZONTAL then - availableMainSize = self.width - self.padding.left - self.padding.right - availableCrossSize = self.height - self.padding.top - self.padding.bottom + availableMainSize = self.width - self.padding.left - self.padding.right - reservedMainStart - reservedMainEnd + availableCrossSize = self.height - self.padding.top - self.padding.bottom - reservedCrossStart - reservedCrossEnd else - availableMainSize = self.height - self.padding.top - self.padding.bottom - availableCrossSize = self.width - self.padding.left - self.padding.right + availableMainSize = self.height - self.padding.top - self.padding.bottom - reservedMainStart - reservedMainEnd + availableCrossSize = self.width - self.padding.left - self.padding.right - reservedCrossStart - reservedCrossEnd end -- Handle flex wrap: create lines of children @@ -2208,20 +2301,21 @@ function Element:layoutChildren() if self.flexDirection == FlexDirection.HORIZONTAL then -- Horizontal layout: main axis is X, cross axis is Y -- Position child at border box (x, y represents top-left including padding) - child.x = self.x + self.padding.left + currentMainPos + -- Add reservedMainStart to account for absolutely positioned siblings + child.x = self.x + self.padding.left + reservedMainStart + currentMainPos if effectiveAlign == AlignItems.FLEX_START then - child.y = self.y + self.padding.top + currentCrossPos + child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos elseif effectiveAlign == AlignItems.CENTER then local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom - child.y = self.y + self.padding.top + currentCrossPos + ((lineHeight - childTotalHeight) / 2) + child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalHeight) / 2) elseif effectiveAlign == AlignItems.FLEX_END then local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom - child.y = self.y + self.padding.top + currentCrossPos + lineHeight - childTotalHeight + child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + lineHeight - childTotalHeight elseif effectiveAlign == AlignItems.STRETCH then -- STRETCH always stretches children in cross-axis direction child.height = lineHeight - child.padding.top - child.padding.bottom - child.y = self.y + self.padding.top + currentCrossPos + child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos end -- Apply positioning offsets (top, right, bottom, left) @@ -2243,20 +2337,21 @@ function Element:layoutChildren() else -- Vertical layout: main axis is Y, cross axis is X -- Position child at border box (x, y represents top-left including padding) - child.y = self.y + self.padding.top + currentMainPos + -- Add reservedMainStart to account for absolutely positioned siblings + child.y = self.y + self.padding.top + reservedMainStart + currentMainPos if effectiveAlign == AlignItems.FLEX_START then - child.x = self.x + self.padding.left + currentCrossPos + child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos elseif effectiveAlign == AlignItems.CENTER then local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right - child.x = self.x + self.padding.left + currentCrossPos + ((lineHeight - childTotalWidth) / 2) + child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalWidth) / 2) elseif effectiveAlign == AlignItems.FLEX_END then local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right - child.x = self.x + self.padding.left + currentCrossPos + lineHeight - childTotalWidth + child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + lineHeight - childTotalWidth elseif effectiveAlign == AlignItems.STRETCH then -- STRETCH always stretches children in cross-axis direction child.width = lineHeight - child.padding.left - child.padding.right - child.x = self.x + self.padding.left + currentCrossPos + child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos end -- Apply positioning offsets (top, right, bottom, left) diff --git a/README.md b/README.md index b7106ec..b80325d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ FlexLöve is a lightweight, flexible GUI library for Löve2D that implements a f ## ⚠️ Development Status -This library is under active development. While many features are functional, some aspects may change or have incomplete implementations. +This library is under active development. While many features are functional, some aspects may change or have incomplete/broken implementations. ## Features diff --git a/testing/__tests__/17_sibling_space_reservation_tests.lua b/testing/__tests__/17_sibling_space_reservation_tests.lua new file mode 100644 index 0000000..bcafb27 --- /dev/null +++ b/testing/__tests__/17_sibling_space_reservation_tests.lua @@ -0,0 +1,437 @@ +-- Test: Sibling Space Reservation in Flex and Grid Layouts +-- Purpose: Verify that absolutely positioned siblings with explicit positioning +-- properly reserve space in flex and grid containers + +local lu = require("testing.luaunit") +local FlexLove = require("libs.FlexLove") +local Gui = FlexLove.GUI +local Color = FlexLove.Color + +-- Mock love.graphics and love.window +_G.love = require("testing.loveStub") + +TestSiblingSpaceReservation = {} + +function TestSiblingSpaceReservation:setUp() + -- Reset GUI state before each test + Gui.destroy() + -- Set up a standard viewport + love.window.setMode(1920, 1080) +end + +function TestSiblingSpaceReservation:tearDown() + Gui.destroy() +end + +-- ==================== +-- Flex Layout Tests +-- ==================== + +function TestSiblingSpaceReservation:test_flex_horizontal_left_positioned_sibling_reserves_space() + -- Create a flex container + local container = Gui.new({ + x = 0, + y = 0, + width = 1000, + height = 200, + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "flex-start", + padding = { top = 0, right = 0, bottom = 0, left = 0 }, + }) + + -- Add an absolutely positioned sibling with left positioning + local absoluteSibling = Gui.new({ + parent = container, + positioning = "absolute", + left = 10, -- 10px from left edge + width = 50, + height = 50, + backgroundColor = Color.new(1, 0, 0, 1), + }) + + -- Add a flex child that should start after the absolutely positioned sibling + local flexChild = Gui.new({ + parent = container, + width = 100, + height = 50, + backgroundColor = Color.new(0, 1, 0, 1), + }) + + -- Layout children + container:layoutChildren() + + -- The absolutely positioned sibling reserves: left (10) + width (50) + padding (0) = 60px + -- The flex child should start at x = container.x + padding.left + reservedLeft + -- = 0 + 0 + 60 = 60 + lu.assertEquals(flexChild.x, 60, "Flex child should start after absolutely positioned sibling") +end + +function TestSiblingSpaceReservation:test_flex_horizontal_right_positioned_sibling_reserves_space() + -- Create a flex container + local container = Gui.new({ + x = 0, + y = 0, + width = 1000, + height = 200, + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "flex-start", + padding = { top = 0, right = 0, bottom = 0, left = 0 }, + }) + + -- Add an absolutely positioned sibling with right positioning + local absoluteSibling = Gui.new({ + parent = container, + positioning = "absolute", + right = 10, -- 10px from right edge + width = 50, + height = 50, + backgroundColor = Color.new(1, 0, 0, 1), + }) + + -- Add a flex child + local flexChild = Gui.new({ + parent = container, + width = 100, + height = 50, + backgroundColor = Color.new(0, 1, 0, 1), + }) + + -- Layout children + container:layoutChildren() + + -- The absolutely positioned sibling reserves: right (10) + width (50) + padding (0) = 60px + -- Available space = 1000 - 0 (padding) - 0 (reservedLeft) - 60 (reservedRight) = 940px + -- The flex child (width 100) should fit within this space + -- Child should start at x = 0 + lu.assertEquals(flexChild.x, 0, "Flex child should start at container left edge") + + -- The absolutely positioned sibling should be at the right edge + -- x = container.x + container.width + padding.left - right - (width + padding) + -- = 0 + 1000 + 0 - 10 - 50 = 940 + lu.assertEquals(absoluteSibling.x, 940, "Absolutely positioned sibling should be at right edge") +end + +function TestSiblingSpaceReservation:test_flex_vertical_top_positioned_sibling_reserves_space() + -- Create a vertical flex container + local container = Gui.new({ + x = 0, + y = 0, + width = 200, + height = 1000, + positioning = "flex", + flexDirection = "vertical", + justifyContent = "flex-start", + padding = { top = 0, right = 0, bottom = 0, left = 0 }, + }) + + -- Add an absolutely positioned sibling with top positioning + local absoluteSibling = Gui.new({ + parent = container, + positioning = "absolute", + top = 10, -- 10px from top edge + width = 50, + height = 50, + backgroundColor = Color.new(1, 0, 0, 1), + }) + + -- Add a flex child that should start after the absolutely positioned sibling + local flexChild = Gui.new({ + parent = container, + width = 50, + height = 100, + backgroundColor = Color.new(0, 1, 0, 1), + }) + + -- Layout children + container:layoutChildren() + + -- The absolutely positioned sibling reserves: top (10) + height (50) + padding (0) = 60px + -- The flex child should start at y = container.y + padding.top + reservedTop + -- = 0 + 0 + 60 = 60 + lu.assertEquals(flexChild.y, 60, "Flex child should start after absolutely positioned sibling") +end + +function TestSiblingSpaceReservation:test_flex_horizontal_multiple_positioned_siblings() + -- Create a flex container + local container = Gui.new({ + x = 0, + y = 0, + width = 1000, + height = 200, + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "flex-start", + padding = { top = 0, right = 0, bottom = 0, left = 0 }, + }) + + -- Add two absolutely positioned siblings (left and right) + local leftSibling = Gui.new({ + parent = container, + positioning = "absolute", + left = 5, + width = 40, + height = 50, + backgroundColor = Color.new(1, 0, 0, 1), + }) + + local rightSibling = Gui.new({ + parent = container, + positioning = "absolute", + right = 5, + width = 40, + height = 50, + backgroundColor = Color.new(0, 0, 1, 1), + }) + + -- Add flex children + local flexChild1 = Gui.new({ + parent = container, + width = 100, + height = 50, + backgroundColor = Color.new(0, 1, 0, 1), + }) + + local flexChild2 = Gui.new({ + parent = container, + width = 100, + height = 50, + backgroundColor = Color.new(0, 1, 1, 1), + }) + + -- Layout children + container:layoutChildren() + + -- Reserved left: 5 + 40 = 45px + -- Reserved right: 5 + 40 = 45px + -- Available space: 1000 - 45 - 45 = 910px + -- First flex child should start at x = 0 + 0 + 45 = 45 + lu.assertEquals(flexChild1.x, 45, "First flex child should start after left sibling") + + -- Second flex child should start at x = 45 + 100 + gap = 145 (assuming gap=10) + lu.assertIsTrue(flexChild2.x >= 145, "Second flex child should be positioned after first") +end + +-- ==================== +-- Grid Layout Tests +-- ==================== + +function TestSiblingSpaceReservation:test_grid_left_positioned_sibling_reserves_space() + -- Create a grid container + local container = Gui.new({ + x = 0, + y = 0, + width = 1000, + height = 500, + positioning = "grid", + gridRows = 2, + gridColumns = 3, + columnGap = 10, + rowGap = 10, + padding = { top = 0, right = 0, bottom = 0, left = 0 }, + }) + + -- Add an absolutely positioned sibling with left positioning + local absoluteSibling = Gui.new({ + parent = container, + positioning = "absolute", + left = 10, + width = 50, + height = 50, + backgroundColor = Color.new(1, 0, 0, 1), + }) + + -- Add grid children + local gridChild1 = Gui.new({ + parent = container, + backgroundColor = Color.new(0, 1, 0, 1), + }) + + -- Layout children + container:layoutChildren() + + -- Reserved left: 10 + 50 = 60px + -- Available width: 1000 - 60 = 940px + -- Column gaps: 2 * 10 = 20px + -- Cell width: (940 - 20) / 3 = 306.67px + -- First grid child should start at x = 0 + 0 + 60 = 60 + lu.assertEquals(gridChild1.x, 60, "Grid child should start after absolutely positioned sibling") +end + +function TestSiblingSpaceReservation:test_grid_top_positioned_sibling_reserves_space() + -- Create a grid container + local container = Gui.new({ + x = 0, + y = 0, + width = 1000, + height = 500, + positioning = "grid", + gridRows = 2, + gridColumns = 3, + columnGap = 10, + rowGap = 10, + padding = { top = 0, right = 0, bottom = 0, left = 0 }, + }) + + -- Add an absolutely positioned sibling with top positioning + local absoluteSibling = Gui.new({ + parent = container, + positioning = "absolute", + top = 10, + width = 50, + height = 50, + backgroundColor = Color.new(1, 0, 0, 1), + }) + + -- Add grid children + local gridChild1 = Gui.new({ + parent = container, + backgroundColor = Color.new(0, 1, 0, 1), + }) + + -- Layout children + container:layoutChildren() + + -- Reserved top: 10 + 50 = 60px + -- Available height: 500 - 60 = 440px + -- Row gaps: 1 * 10 = 10px + -- Cell height: (440 - 10) / 2 = 215px + -- First grid child should start at y = 0 + 0 + 60 = 60 + lu.assertEquals(gridChild1.y, 60, "Grid child should start after absolutely positioned sibling") +end + +function TestSiblingSpaceReservation:test_grid_multiple_positioned_siblings() + -- Create a grid container + local container = Gui.new({ + x = 0, + y = 0, + width = 1000, + height = 500, + positioning = "grid", + gridRows = 2, + gridColumns = 2, + columnGap = 0, + rowGap = 0, + padding = { top = 0, right = 0, bottom = 0, left = 0 }, + }) + + -- Add absolutely positioned siblings at all corners + local topLeftSibling = Gui.new({ + parent = container, + positioning = "absolute", + left = 10, + top = 10, + width = 40, + height = 40, + backgroundColor = Color.new(1, 0, 0, 1), + }) + + local bottomRightSibling = Gui.new({ + parent = container, + positioning = "absolute", + right = 10, + bottom = 10, + width = 40, + height = 40, + backgroundColor = Color.new(0, 0, 1, 1), + }) + + -- Add grid children + local gridChild1 = Gui.new({ + parent = container, + backgroundColor = Color.new(0, 1, 0, 1), + }) + + -- Layout children + container:layoutChildren() + + -- Reserved left: 10 + 40 = 50px + -- Reserved right: 10 + 40 = 50px + -- Reserved top: 10 + 40 = 50px + -- Reserved bottom: 10 + 40 = 50px + -- Available width: 1000 - 50 - 50 = 900px + -- Available height: 500 - 50 - 50 = 400px + -- Cell width: 900 / 2 = 450px + -- Cell height: 400 / 2 = 200px + -- First grid child should start at (50, 50) + lu.assertEquals(gridChild1.x, 50, "Grid child X should account for left sibling") + lu.assertEquals(gridChild1.y, 50, "Grid child Y should account for top sibling") + lu.assertEquals(gridChild1.width, 450, "Grid cell width should account for reserved space") + lu.assertEquals(gridChild1.height, 200, "Grid cell height should account for reserved space") +end + +-- ==================== +-- Edge Cases +-- ==================== + +function TestSiblingSpaceReservation:test_non_explicitly_absolute_children_dont_reserve_space() + -- Children that default to absolute positioning (not explicitly set) + -- should NOT reserve space in flex layouts + local container = Gui.new({ + x = 0, + y = 0, + width = 1000, + height = 200, + positioning = "flex", + flexDirection = "horizontal", + padding = { top = 0, right = 0, bottom = 0, left = 0 }, + }) + + -- This child has positioning="flex" so it participates in layout + local flexChild = Gui.new({ + parent = container, + positioning = "flex", + left = 10, -- This should be ignored since it's a flex child + width = 100, + height = 50, + backgroundColor = Color.new(0, 1, 0, 1), + }) + + -- Layout children + container:layoutChildren() + + -- Flex child should start at x = 0 (no reserved space) + lu.assertEquals(flexChild.x, 0, "Flex children with positioning offsets should not reserve space") +end + +function TestSiblingSpaceReservation:test_absolute_without_positioning_offsets_doesnt_reserve_space() + -- Absolutely positioned children without left/right/top/bottom + -- should NOT reserve space + local container = Gui.new({ + x = 0, + y = 0, + width = 1000, + height = 200, + positioning = "flex", + flexDirection = "horizontal", + padding = { top = 0, right = 0, bottom = 0, left = 0 }, + }) + + -- Absolutely positioned but no positioning offsets + local absoluteChild = Gui.new({ + parent = container, + positioning = "absolute", + x = 50, + y = 50, + width = 50, + height = 50, + backgroundColor = Color.new(1, 0, 0, 1), + }) + + -- Flex child + local flexChild = Gui.new({ + parent = container, + width = 100, + height = 50, + backgroundColor = Color.new(0, 1, 0, 1), + }) + + -- Layout children + container:layoutChildren() + + -- Flex child should start at x = 0 (no reserved space) + lu.assertEquals(flexChild.x, 0, "Absolute children without positioning offsets should not reserve space") +end + +return TestSiblingSpaceReservation diff --git a/testing/runAll.lua b/testing/runAll.lua index 6c91c62..b930df0 100644 --- a/testing/runAll.lua +++ b/testing/runAll.lua @@ -20,6 +20,7 @@ local testFiles = { "testing/__tests__/14_text_scaling_basic_tests.lua", "testing/__tests__/15_grid_layout_tests.lua", "testing/__tests__/16_event_system_tests.lua", + "testing/__tests__/17_sibling_space_reservation_tests.lua", } -- testingun all tests, but don't exit on error