diff --git a/modules/Element.lua b/modules/Element.lua index 24e4470..88549a3 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -141,6 +141,7 @@ ---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars) ---@field scrollbarKnobOffset number|table? -- Scrollbar knob/handle offset (number or {x, y} or {horizontal, vertical}) ---@field scrollbarPlacement string? -- "reserve-space"|"overlay" -- Whether scrollbar reserves space or overlays content (default: "reserve-space") +---@field scrollbarBalance boolean? -- When true, reserve space on both sides of content for visual balance (default: false) ---@field _overflowX boolean? -- Internal: whether content overflows horizontally ---@field _overflowY boolean? -- Internal: whether content overflows vertically ---@field _contentWidth number? -- Internal: total content width @@ -1941,6 +1942,7 @@ function Element.new(props) scrollbarKnobOffset = props.scrollbarKnobOffset, hideScrollbars = props.hideScrollbars, scrollbarPlacement = props.scrollbarPlacement, + scrollbarBalance = props.scrollbarBalance, _scrollX = props._scrollX, _scrollY = props._scrollY, }, scrollManagerDeps) @@ -1960,6 +1962,7 @@ function Element.new(props) self.scrollbarKnobOffset = self._scrollManager.scrollbarKnobOffset self.hideScrollbars = self._scrollManager.hideScrollbars self.scrollbarPlacement = self._scrollManager.scrollbarPlacement + self.scrollbarBalance = self._scrollManager.scrollbarBalance -- Initialize state properties (will be synced from ScrollManager) self._overflowX = false diff --git a/modules/LayoutEngine.lua b/modules/LayoutEngine.lua index 465e440..a216bd6 100644 --- a/modules/LayoutEngine.lua +++ b/modules/LayoutEngine.lua @@ -466,20 +466,32 @@ function LayoutEngine:layoutChildren() local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL for _, child in ipairs(flexChildren) do if isHorizontal then - -- Horizontal flex: cross-axis is height + -- Horizontal flex: main-axis is width, cross-axis is height + -- Adjust main-axis width if percentage-based + if child.units and child.units.width and child.units.width.unit == "%" then + local newBorderBoxWidth = (child.units.width.value / 100) * availableMainSize + local newWidth = math.max(0, newBorderBoxWidth - child.padding.left - child.padding.right) + child.width = newWidth + child._borderBoxWidth = newBorderBoxWidth + end + -- Adjust cross-axis height if percentage-based if child.units and child.units.height and child.units.height.unit == "%" then - -- Re-resolve percentage height against reduced cross-axis size - -- The percentage applies to border-box, so we need to subtract padding to get content height local newBorderBoxHeight = (child.units.height.value / 100) * availableCrossSize local newHeight = math.max(0, newBorderBoxHeight - child.padding.top - child.padding.bottom) child.height = newHeight child._borderBoxHeight = newBorderBoxHeight end else - -- Vertical flex: cross-axis is width + -- Vertical flex: main-axis is height, cross-axis is width + -- Adjust main-axis height if percentage-based + if child.units and child.units.height and child.units.height.unit == "%" then + local newBorderBoxHeight = (child.units.height.value / 100) * availableMainSize + local newHeight = math.max(0, newBorderBoxHeight - child.padding.top - child.padding.bottom) + child.height = newHeight + child._borderBoxHeight = newBorderBoxHeight + end + -- Adjust cross-axis width if percentage-based if child.units and child.units.width and child.units.width.unit == "%" then - -- Re-resolve percentage width against reduced cross-axis size - -- The percentage applies to border-box, so we need to subtract padding to get content width local newBorderBoxWidth = (child.units.width.value / 100) * availableCrossSize local newWidth = math.max(0, newBorderBoxWidth - child.padding.left - child.padding.right) child.width = newWidth diff --git a/modules/ScrollManager.lua b/modules/ScrollManager.lua index a783910..4223f59 100644 --- a/modules/ScrollManager.lua +++ b/modules/ScrollManager.lua @@ -13,6 +13,7 @@ ---@field scrollbarKnobOffset table -- {x: number, y: number, horizontal: number, vertical: number} -- Offset for scrollbar knob/handle position ---@field hideScrollbars table -- {vertical: boolean, horizontal: boolean} ---@field scrollbarPlacement string -- "reserve-space"|"overlay" -- Whether scrollbar reserves space or overlays content (default: "reserve-space") +---@field scrollbarBalance boolean -- When true, reserve space on both sides of content for visual balance (default: false) ---@field touchScrollEnabled boolean -- Enable touch scrolling ---@field momentumScrollEnabled boolean -- Enable momentum scrolling ---@field bounceEnabled boolean -- Enable bounce effects at boundaries @@ -101,6 +102,9 @@ function ScrollManager.new(config, deps) -- Scrollbar placement: "reserve-space" (default) or "overlay" self.scrollbarPlacement = config.scrollbarPlacement or "reserve-space" + + -- Scrollbar balance: when true, reserve space on both sides for visual balance + self.scrollbarBalance = config.scrollbarBalance or false -- Touch scrolling configuration self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true @@ -167,12 +171,14 @@ function ScrollManager:getReservedSpace(element) -- Reserve space for vertical scrollbar if overflow mode requires it if (overflowY == "scroll" or overflowY == "auto") and not self.hideScrollbars.vertical then - reservedWidth = self.scrollbarWidth + (self.scrollbarPadding * 2) + local scrollbarSpace = self.scrollbarWidth + (self.scrollbarPadding * 2) + reservedWidth = self.scrollbarBalance and (scrollbarSpace * 2) or scrollbarSpace end -- Reserve space for horizontal scrollbar if overflow mode requires it if (overflowX == "scroll" or overflowX == "auto") and not self.hideScrollbars.horizontal then - reservedHeight = self.scrollbarWidth + (self.scrollbarPadding * 2) + local scrollbarSpace = self.scrollbarWidth + (self.scrollbarPadding * 2) + reservedHeight = self.scrollbarBalance and (scrollbarSpace * 2) or scrollbarSpace end return reservedWidth, reservedHeight @@ -715,6 +721,7 @@ function ScrollManager:getState() scrollBarStyle = self.scrollBarStyle, scrollbarKnobOffset = self.scrollbarKnobOffset, scrollbarPlacement = self.scrollbarPlacement, + scrollbarBalance = self.scrollbarBalance, _overflowX = self._overflowX, _overflowY = self._overflowY, _contentWidth = self._contentWidth, @@ -797,6 +804,10 @@ function ScrollManager:setState(state) self.scrollbarPlacement = state.scrollbarPlacement end + if state.scrollbarBalance ~= nil then + self.scrollbarBalance = state.scrollbarBalance + end + if state._overflowX ~= nil then self._overflowX = state._overflowX end diff --git a/modules/types.lua b/modules/types.lua index 2fa4af1..f0ac85b 100644 --- a/modules/types.lua +++ b/modules/types.lua @@ -131,6 +131,7 @@ local AnimationProps = {} ---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars, default: uses first scrollbar or fallback rendering) ---@field scrollbarKnobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}? -- Offset for scrollbar knob/handle position in pixels (number for both axes, or table for per-axis control, default: 0, adds to theme offset) ---@field scrollbarPlacement "reserve-space"|"overlay"? -- Scrollbar rendering mode: "reserve-space" (reduces content area, default) or "overlay" (renders over content) +---@field scrollbarBalance boolean? -- When true, reserve scrollbar space on both sides of content for visual balance (default: false) ---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control, default: false) ---@field imagePath string? -- Path to image file (auto-loads via ImageCache) ---@field image love.Image? -- Image object to display diff --git a/testing/__tests__/scrollbar_placement_test.lua b/testing/__tests__/scrollbar_placement_test.lua index 12de530..d761444 100644 --- a/testing/__tests__/scrollbar_placement_test.lua +++ b/testing/__tests__/scrollbar_placement_test.lua @@ -171,6 +171,94 @@ function TestScrollbarPlacement:test_vertical_overflow_detected_with_reserved_sp luaunit.assertEquals(child1.width, 184, "Child width should be reduced for scrollbar") end +function TestScrollbarPlacement:test_scrollbar_balance_vertical() + -- Test scrollbarBalance with vertical scrollbar + local container = FlexLove.new({ + width = 200, + height = 200, + positioning = "flex", + flexDirection = "column", + overflow = "scroll", + scrollbarPlacement = "reserve-space", + scrollbarBalance = true, + }) + + local child1 = FlexLove.new({ + width = "100%", + height = 100, + parent = container + }) + + container:layoutChildren() + + local reservedW, reservedH = container._scrollManager:getReservedSpace(container) + + -- Should reserve double the space (16 * 2 = 32) + luaunit.assertEquals(reservedW, 32, "Should reserve doubled width for balance") + + -- Child width should account for balanced space + luaunit.assertEquals(child1.width, 168, "Child width should be 200 - 32") +end + +function TestScrollbarPlacement:test_scrollbar_balance_horizontal() + -- Test scrollbarBalance with horizontal scrollbar + local container = FlexLove.new({ + width = 200, + height = 200, + positioning = "flex", + flexDirection = "horizontal", + overflow = "scroll", + scrollbarPlacement = "reserve-space", + scrollbarBalance = true, + }) + + local child1 = FlexLove.new({ + width = 100, + height = "100%", + parent = container + }) + + container:layoutChildren() + + local reservedW, reservedH = container._scrollManager:getReservedSpace(container) + + -- Should reserve double the space (16 * 2 = 32) + luaunit.assertEquals(reservedH, 32, "Should reserve doubled height for balance") + + -- Child height should account for balanced space + luaunit.assertEquals(child1.height, 168, "Child height should be 200 - 32") +end + +function TestScrollbarPlacement:test_scrollbar_balance_both() + -- Test scrollbarBalance with both scrollbars + local container = FlexLove.new({ + width = 200, + height = 200, + positioning = "flex", + overflow = "scroll", + scrollbarPlacement = "reserve-space", + scrollbarBalance = true, + }) + + local child1 = FlexLove.new({ + width = "100%", + height = "100%", + parent = container + }) + + container:layoutChildren() + + local reservedW, reservedH = container._scrollManager:getReservedSpace(container) + + -- Both should reserve double the space + luaunit.assertEquals(reservedW, 32, "Should reserve doubled width for balance") + luaunit.assertEquals(reservedH, 32, "Should reserve doubled height for balance") + + -- Child should be sized to balanced available space + luaunit.assertEquals(child1.width, 168, "Child width should be 200 - 32") + luaunit.assertEquals(child1.height, 168, "Child height should be 200 - 32") +end + if not _G.RUNNING_ALL_TESTS then os.exit(luaunit.LuaUnit.run()) end