feat: scrollbar balance

This commit is contained in:
Michael Freno
2026-01-06 00:18:18 -05:00
parent ce690aa5dc
commit 1024fd81de
5 changed files with 123 additions and 8 deletions

View File

@@ -141,6 +141,7 @@
---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars) ---@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 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 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 _overflowX boolean? -- Internal: whether content overflows horizontally
---@field _overflowY boolean? -- Internal: whether content overflows vertically ---@field _overflowY boolean? -- Internal: whether content overflows vertically
---@field _contentWidth number? -- Internal: total content width ---@field _contentWidth number? -- Internal: total content width
@@ -1941,6 +1942,7 @@ function Element.new(props)
scrollbarKnobOffset = props.scrollbarKnobOffset, scrollbarKnobOffset = props.scrollbarKnobOffset,
hideScrollbars = props.hideScrollbars, hideScrollbars = props.hideScrollbars,
scrollbarPlacement = props.scrollbarPlacement, scrollbarPlacement = props.scrollbarPlacement,
scrollbarBalance = props.scrollbarBalance,
_scrollX = props._scrollX, _scrollX = props._scrollX,
_scrollY = props._scrollY, _scrollY = props._scrollY,
}, scrollManagerDeps) }, scrollManagerDeps)
@@ -1960,6 +1962,7 @@ function Element.new(props)
self.scrollbarKnobOffset = self._scrollManager.scrollbarKnobOffset self.scrollbarKnobOffset = self._scrollManager.scrollbarKnobOffset
self.hideScrollbars = self._scrollManager.hideScrollbars self.hideScrollbars = self._scrollManager.hideScrollbars
self.scrollbarPlacement = self._scrollManager.scrollbarPlacement self.scrollbarPlacement = self._scrollManager.scrollbarPlacement
self.scrollbarBalance = self._scrollManager.scrollbarBalance
-- Initialize state properties (will be synced from ScrollManager) -- Initialize state properties (will be synced from ScrollManager)
self._overflowX = false self._overflowX = false

View File

@@ -466,20 +466,32 @@ function LayoutEngine:layoutChildren()
local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL
for _, child in ipairs(flexChildren) do for _, child in ipairs(flexChildren) do
if isHorizontal then 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 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 newBorderBoxHeight = (child.units.height.value / 100) * availableCrossSize
local newHeight = math.max(0, newBorderBoxHeight - child.padding.top - child.padding.bottom) local newHeight = math.max(0, newBorderBoxHeight - child.padding.top - child.padding.bottom)
child.height = newHeight child.height = newHeight
child._borderBoxHeight = newBorderBoxHeight child._borderBoxHeight = newBorderBoxHeight
end end
else 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 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 newBorderBoxWidth = (child.units.width.value / 100) * availableCrossSize
local newWidth = math.max(0, newBorderBoxWidth - child.padding.left - child.padding.right) local newWidth = math.max(0, newBorderBoxWidth - child.padding.left - child.padding.right)
child.width = newWidth child.width = newWidth

View File

@@ -13,6 +13,7 @@
---@field scrollbarKnobOffset table -- {x: number, y: number, horizontal: number, vertical: number} -- Offset for scrollbar knob/handle position ---@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 hideScrollbars table -- {vertical: boolean, horizontal: boolean}
---@field scrollbarPlacement string -- "reserve-space"|"overlay" -- Whether scrollbar reserves space or overlays content (default: "reserve-space") ---@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 touchScrollEnabled boolean -- Enable touch scrolling
---@field momentumScrollEnabled boolean -- Enable momentum scrolling ---@field momentumScrollEnabled boolean -- Enable momentum scrolling
---@field bounceEnabled boolean -- Enable bounce effects at boundaries ---@field bounceEnabled boolean -- Enable bounce effects at boundaries
@@ -101,6 +102,9 @@ function ScrollManager.new(config, deps)
-- Scrollbar placement: "reserve-space" (default) or "overlay" -- Scrollbar placement: "reserve-space" (default) or "overlay"
self.scrollbarPlacement = config.scrollbarPlacement or "reserve-space" 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 -- Touch scrolling configuration
self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true 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 -- Reserve space for vertical scrollbar if overflow mode requires it
if (overflowY == "scroll" or overflowY == "auto") and not self.hideScrollbars.vertical then 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 end
-- Reserve space for horizontal scrollbar if overflow mode requires it -- Reserve space for horizontal scrollbar if overflow mode requires it
if (overflowX == "scroll" or overflowX == "auto") and not self.hideScrollbars.horizontal then 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 end
return reservedWidth, reservedHeight return reservedWidth, reservedHeight
@@ -715,6 +721,7 @@ function ScrollManager:getState()
scrollBarStyle = self.scrollBarStyle, scrollBarStyle = self.scrollBarStyle,
scrollbarKnobOffset = self.scrollbarKnobOffset, scrollbarKnobOffset = self.scrollbarKnobOffset,
scrollbarPlacement = self.scrollbarPlacement, scrollbarPlacement = self.scrollbarPlacement,
scrollbarBalance = self.scrollbarBalance,
_overflowX = self._overflowX, _overflowX = self._overflowX,
_overflowY = self._overflowY, _overflowY = self._overflowY,
_contentWidth = self._contentWidth, _contentWidth = self._contentWidth,
@@ -797,6 +804,10 @@ function ScrollManager:setState(state)
self.scrollbarPlacement = state.scrollbarPlacement self.scrollbarPlacement = state.scrollbarPlacement
end end
if state.scrollbarBalance ~= nil then
self.scrollbarBalance = state.scrollbarBalance
end
if state._overflowX ~= nil then if state._overflowX ~= nil then
self._overflowX = state._overflowX self._overflowX = state._overflowX
end end

View File

@@ -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 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 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 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 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 imagePath string? -- Path to image file (auto-loads via ImageCache)
---@field image love.Image? -- Image object to display ---@field image love.Image? -- Image object to display

View File

@@ -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") luaunit.assertEquals(child1.width, 184, "Child width should be reduced for scrollbar")
end 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 if not _G.RUNNING_ALL_TESTS then
os.exit(luaunit.LuaUnit.run()) os.exit(luaunit.LuaUnit.run())
end end