feat: scrollbar balance
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -102,6 +103,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
|
||||||
self.momentumScrollEnabled = config.momentumScrollEnabled ~= false -- Default true
|
self.momentumScrollEnabled = config.momentumScrollEnabled ~= 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user