better scaling

This commit is contained in:
Michael Freno
2025-10-09 18:39:36 -04:00
parent a1530c2376
commit f5d716bf38
3 changed files with 186 additions and 44 deletions

View File

@@ -175,6 +175,19 @@ function Units.getViewport()
end end
end end
--- Apply base scaling to a value
---@param value number
---@param axis "x"|"y" -- Which axis to scale on
---@param scaleFactors {x:number, y:number}
---@return number
function Units.applyBaseScale(value, axis, scaleFactors)
if axis == "x" then
return value * scaleFactors.x
else
return value * scaleFactors.y
end
end
--- Resolve units for spacing properties (padding, margin) --- Resolve units for spacing properties (padding, margin)
---@param spacingProps table? ---@param spacingProps table?
---@param parentWidth number ---@param parentWidth number
@@ -233,14 +246,50 @@ end
--- Top level GUI manager --- Top level GUI manager
---@class Gui ---@class Gui
---@field topElements table<integer, Element> ---@field topElements table<integer, Element>
---@field baseScale {width:number, height:number}?
---@field scaleFactors {x:number, y:number}
---@field init fun(config: {baseScale: {width:number, height:number}}): nil
---@field resize fun(): nil ---@field resize fun(): nil
---@field draw fun(): nil ---@field draw fun(): nil
---@field update fun(dt:number): nil ---@field update fun(dt:number): nil
---@field destroy fun(): nil ---@field destroy fun(): nil
local Gui = { topElements = {} } local Gui = {
topElements = {},
baseScale = nil,
scaleFactors = { x = 1.0, y = 1.0 },
}
--- Initialize FlexLove with configuration
---@param config {baseScale?: {width?:number, height?:number}} --Default: {width: 1920, height: 1080}
function Gui.init(config)
if config.baseScale then
Gui.baseScale = {
width = config.baseScale.width or 1920,
height = config.baseScale.height or 1080,
}
-- Calculate initial scale factors
local currentWidth, currentHeight = Units.getViewport()
Gui.scaleFactors.x = currentWidth / Gui.baseScale.width
Gui.scaleFactors.y = currentHeight / Gui.baseScale.height
end
end
--- Get current scale factors
---@return number, number -- scaleX, scaleY
function Gui.getScaleFactors()
return Gui.scaleFactors.x, Gui.scaleFactors.y
end
function Gui.resize() function Gui.resize()
local newWidth, newHeight = love.window.getMode() local newWidth, newHeight = love.window.getMode()
-- Update scale factors if base scale is set
if Gui.baseScale then
Gui.scaleFactors.x = newWidth / Gui.baseScale.width
Gui.scaleFactors.y = newHeight / Gui.baseScale.height
end
for _, win in ipairs(Gui.topElements) do for _, win in ipairs(Gui.topElements) do
win:resize(newWidth, newHeight) win:resize(newWidth, newHeight)
end end
@@ -572,6 +621,9 @@ function Element.new(props)
}, },
} }
-- Get scale factors from Gui (will be used later)
local scaleX, scaleY = Gui.getScaleFactors()
-- Handle width (both w and width properties, prefer w if both exist) -- Handle width (both w and width properties, prefer w if both exist)
local widthProp = props.width local widthProp = props.width
if widthProp then if widthProp then
@@ -581,7 +633,8 @@ function Element.new(props)
local parentWidth = self.parent and self.parent.width or viewportWidth local parentWidth = self.parent and self.parent.width or viewportWidth
self.width = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) self.width = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth)
else else
self.width = widthProp -- Apply base scaling to pixel values
self.width = Gui.baseScale and (widthProp * scaleX) or widthProp
self.units.width = { value = widthProp, unit = "px" } self.units.width = { value = widthProp, unit = "px" }
end end
else else
@@ -599,7 +652,8 @@ function Element.new(props)
local parentHeight = self.parent and self.parent.height or viewportHeight local parentHeight = self.parent and self.parent.height or viewportHeight
self.height = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) self.height = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight)
else else
self.height = heightProp -- Apply base scaling to pixel values
self.height = Gui.baseScale and (heightProp * scaleY) or heightProp
self.units.height = { value = heightProp, unit = "px" } self.units.height = { value = heightProp, unit = "px" }
end end
else else
@@ -664,9 +718,24 @@ function Element.new(props)
self.textSize = Units.resolve(value, unit, viewportWidth, viewportHeight, nil) self.textSize = Units.resolve(value, unit, viewportWidth, viewportHeight, nil)
end end
else else
self.textSize = props.textSize -- Validate pixel textSize value
if props.textSize <= 0 then
error("textSize must be greater than 0, got: " .. tostring(props.textSize))
end
-- Pixel textSize value
if self.autoScaleText then
-- Convert pixel value to viewport units for auto-scaling
-- Calculate what percentage of viewport height this represents
local vhValue = (props.textSize / viewportHeight) * 100
self.units.textSize = { value = vhValue, unit = "vh" }
self.textSize = props.textSize -- Initial size is the specified pixel value
else
-- Apply base scaling to pixel text sizes (no auto-scaling)
self.textSize = Gui.baseScale and (props.textSize * scaleY) or props.textSize
self.units.textSize = { value = props.textSize, unit = "px" } self.units.textSize = { value = props.textSize, unit = "px" }
end end
end
else else
-- No textSize specified - use auto-scaling default -- No textSize specified - use auto-scaling default
if self.autoScaleText then if self.autoScaleText then
@@ -674,18 +743,26 @@ function Element.new(props)
self.textSize = (1.5 / 100) * viewportHeight self.textSize = (1.5 / 100) * viewportHeight
self.units.textSize = { value = 1.5, unit = "vh" } self.units.textSize = { value = 1.5, unit = "vh" }
else else
-- Fixed 12px when auto-scaling is disabled -- Fixed 12px when auto-scaling is disabled (with base scaling if set)
self.textSize = 12 self.textSize = Gui.baseScale and (12 * scaleY) or 12
self.units.textSize = { value = nil, unit = "px" } self.units.textSize = { value = nil, unit = "px" }
end end
end end
-- Apply min/max constraints -- Apply min/max constraints (also scaled)
if self.minTextSize and self.textSize < self.minTextSize then local minSize = self.minTextSize and (Gui.baseScale and (self.minTextSize * scaleY) or self.minTextSize)
self.textSize = self.minTextSize local maxSize = self.maxTextSize and (Gui.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize)
if minSize and self.textSize < minSize then
self.textSize = minSize
end end
if self.maxTextSize and self.textSize > self.maxTextSize then if maxSize and self.textSize > maxSize then
self.textSize = self.maxTextSize self.textSize = maxSize
end
-- Protect against too-small text sizes (minimum 1px)
if self.textSize < 1 then
self.textSize = 1 -- Minimum 1px
end end
-- Store original spacing values for proper resize handling -- Store original spacing values for proper resize handling
@@ -730,7 +807,8 @@ function Element.new(props)
self.units.x = { value = value, unit = unit } self.units.x = { value = value, unit = unit }
self.x = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) self.x = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth)
else else
self.x = props.x -- Apply base scaling to pixel positions
self.x = Gui.baseScale and (props.x * scaleX) or props.x
self.units.x = { value = props.x, unit = "px" } self.units.x = { value = props.x, unit = "px" }
end end
else else
@@ -745,7 +823,8 @@ function Element.new(props)
self.units.y = { value = value, unit = unit } self.units.y = { value = value, unit = unit }
self.y = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) self.y = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight)
else else
self.y = props.y -- Apply base scaling to pixel positions
self.y = Gui.baseScale and (props.y * scaleY) or props.y
self.units.y = { value = props.y, unit = "px" } self.units.y = { value = props.y, unit = "px" }
end end
else else
@@ -798,7 +877,8 @@ function Element.new(props)
local parentWidth = self.parent.width local parentWidth = self.parent.width
self.x = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) self.x = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth)
else else
self.x = props.x -- Apply base scaling to pixel positions
self.x = Gui.baseScale and (props.x * scaleX) or props.x
self.units.x = { value = props.x, unit = "px" } self.units.x = { value = props.x, unit = "px" }
end end
else else
@@ -814,7 +894,8 @@ function Element.new(props)
local parentHeight = self.parent.height local parentHeight = self.parent.height
self.y = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) self.y = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight)
else else
self.y = props.y -- Apply base scaling to pixel positions
self.y = Gui.baseScale and (props.y * scaleY) or props.y
self.units.y = { value = props.y, unit = "px" } self.units.y = { value = props.y, unit = "px" }
end end
else else
@@ -836,7 +917,9 @@ function Element.new(props)
local offsetX = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) local offsetX = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth)
self.x = baseX + offsetX self.x = baseX + offsetX
else else
self.x = baseX + props.x -- Apply base scaling to pixel offsets
local scaledOffset = Gui.baseScale and (props.x * scaleX) or props.x
self.x = baseX + scaledOffset
self.units.x = { value = props.x, unit = "px" } self.units.x = { value = props.x, unit = "px" }
end end
else else
@@ -852,7 +935,9 @@ function Element.new(props)
local offsetY = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) local offsetY = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight)
self.y = baseY + offsetY self.y = baseY + offsetY
else else
self.y = baseY + props.y -- Apply base scaling to pixel offsets
local scaledOffset = Gui.baseScale and (props.y * scaleY) or props.y
self.y = baseY + scaledOffset
self.units.y = { value = props.y, unit = "px" } self.units.y = { value = props.y, unit = "px" }
end end
else else
@@ -1248,7 +1333,11 @@ function Element:layoutChildren()
child.y = self.y + self.padding.top + currentCrossPos + child.padding.top child.y = self.y + self.padding.top + currentCrossPos + child.padding.top
elseif effectiveAlign == AlignItems.CENTER then elseif effectiveAlign == AlignItems.CENTER then
local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom
child.y = self.y + self.padding.top + currentCrossPos + ((lineHeight - childTotalHeight) / 2) + child.padding.top child.y = self.y
+ self.padding.top
+ currentCrossPos
+ ((lineHeight - childTotalHeight) / 2)
+ child.padding.top
elseif effectiveAlign == AlignItems.FLEX_END then elseif effectiveAlign == AlignItems.FLEX_END then
local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom
child.y = self.y + self.padding.top + currentCrossPos + lineHeight - childTotalHeight + child.padding.top child.y = self.y + self.padding.top + currentCrossPos + lineHeight - childTotalHeight + child.padding.top
@@ -1283,7 +1372,11 @@ function Element:layoutChildren()
child.x = self.x + self.padding.left + currentCrossPos + child.padding.left child.x = self.x + self.padding.left + currentCrossPos + child.padding.left
elseif effectiveAlign == AlignItems.CENTER then elseif effectiveAlign == AlignItems.CENTER then
local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right
child.x = self.x + self.padding.left + currentCrossPos + ((lineHeight - childTotalWidth) / 2) + child.padding.left child.x = self.x
+ self.padding.left
+ currentCrossPos
+ ((lineHeight - childTotalWidth) / 2)
+ child.padding.left
elseif effectiveAlign == AlignItems.FLEX_END then elseif effectiveAlign == AlignItems.FLEX_END then
local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right
child.x = self.x + self.padding.left + currentCrossPos + lineHeight - childTotalWidth + child.padding.left child.x = self.x + self.padding.left + currentCrossPos + lineHeight - childTotalWidth + child.padding.left
@@ -1523,11 +1616,17 @@ end
---@param newViewportWidth number ---@param newViewportWidth number
---@param newViewportHeight number ---@param newViewportHeight number
function Element:recalculateUnits(newViewportWidth, newViewportHeight) function Element:recalculateUnits(newViewportWidth, newViewportHeight)
-- Get updated scale factors
local scaleX, scaleY = Gui.getScaleFactors()
-- Recalculate width if using viewport or percentage units (skip auto-sized) -- Recalculate width if using viewport or percentage units (skip auto-sized)
if self.units.width.unit ~= "px" and self.units.width.unit ~= "auto" then if self.units.width.unit ~= "px" and self.units.width.unit ~= "auto" then
local parentWidth = self.parent and self.parent.width or newViewportWidth local parentWidth = self.parent and self.parent.width or newViewportWidth
self.width = self.width =
Units.resolve(self.units.width.value, self.units.width.unit, newViewportWidth, newViewportHeight, parentWidth) Units.resolve(self.units.width.value, self.units.width.unit, newViewportWidth, newViewportHeight, parentWidth)
elseif self.units.width.unit == "px" and self.units.width.value and Gui.baseScale then
-- Reapply base scaling to pixel widths
self.width = self.units.width.value * scaleX
end end
-- Recalculate height if using viewport or percentage units (skip auto-sized) -- Recalculate height if using viewport or percentage units (skip auto-sized)
@@ -1535,6 +1634,9 @@ function Element:recalculateUnits(newViewportWidth, newViewportHeight)
local parentHeight = self.parent and self.parent.height or newViewportHeight local parentHeight = self.parent and self.parent.height or newViewportHeight
self.height = self.height =
Units.resolve(self.units.height.value, self.units.height.unit, newViewportWidth, newViewportHeight, parentHeight) Units.resolve(self.units.height.value, self.units.height.unit, newViewportWidth, newViewportHeight, parentHeight)
elseif self.units.height.unit == "px" and self.units.height.value and Gui.baseScale then
-- Reapply base scaling to pixel heights
self.height = self.units.height.value * scaleY
end end
-- Recalculate position if using viewport or percentage units -- Recalculate position if using viewport or percentage units
@@ -1545,10 +1647,14 @@ function Element:recalculateUnits(newViewportWidth, newViewportHeight)
Units.resolve(self.units.x.value, self.units.x.unit, newViewportWidth, newViewportHeight, parentWidth) Units.resolve(self.units.x.value, self.units.x.unit, newViewportWidth, newViewportHeight, parentWidth)
self.x = baseX + offsetX self.x = baseX + offsetX
else else
-- For pixel units, update position relative to parent's new position -- For pixel units, update position relative to parent's new position (with base scaling)
if self.parent then if self.parent then
local baseX = self.parent.x local baseX = self.parent.x
self.x = baseX + self.units.x.value local scaledOffset = Gui.baseScale and (self.units.x.value * scaleX) or self.units.x.value
self.x = baseX + scaledOffset
elseif Gui.baseScale then
-- Top-level element with pixel position - apply base scaling
self.x = self.units.x.value * scaleX
end end
end end
@@ -1559,10 +1665,14 @@ function Element:recalculateUnits(newViewportWidth, newViewportHeight)
Units.resolve(self.units.y.value, self.units.y.unit, newViewportWidth, newViewportHeight, parentHeight) Units.resolve(self.units.y.value, self.units.y.unit, newViewportWidth, newViewportHeight, parentHeight)
self.y = baseY + offsetY self.y = baseY + offsetY
else else
-- For pixel units, update position relative to parent's new position -- For pixel units, update position relative to parent's new position (with base scaling)
if self.parent then if self.parent then
local baseY = self.parent.y local baseY = self.parent.y
self.y = baseY + self.units.y.value local scaledOffset = Gui.baseScale and (self.units.y.value * scaleY) or self.units.y.value
self.y = baseY + scaledOffset
elseif Gui.baseScale then
-- Top-level element with pixel position - apply base scaling
self.y = self.units.y.value * scaleY
end end
end end
@@ -1587,13 +1697,34 @@ function Element:recalculateUnits(newViewportWidth, newViewportHeight)
self.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, nil) self.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, nil)
end end
-- Apply min/max constraints -- Apply min/max constraints (with base scaling)
if self.minTextSize and self.textSize < self.minTextSize then local minSize = self.minTextSize and (Gui.baseScale and (self.minTextSize * scaleY) or self.minTextSize)
self.textSize = self.minTextSize local maxSize = self.maxTextSize and (Gui.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize)
if minSize and self.textSize < minSize then
self.textSize = minSize
end end
if self.maxTextSize and self.textSize > self.maxTextSize then if maxSize and self.textSize > maxSize then
self.textSize = self.maxTextSize self.textSize = maxSize
end end
-- Protect against too-small text sizes (minimum 1px)
if self.textSize < 1 then
self.textSize = 1 -- Minimum 1px
end
elseif self.units.textSize.unit == "px" and self.units.textSize.value and Gui.baseScale then
-- Reapply base scaling to pixel text sizes
self.textSize = self.units.textSize.value * scaleY
-- Protect against too-small text sizes (minimum 1px)
if self.textSize < 1 then
self.textSize = 1 -- Minimum 1px
end
end
-- Final protection: ensure textSize is always at least 1px (catches all edge cases)
if self.text and self.textSize and self.textSize < 1 then
self.textSize = 1 -- Minimum 1px
end end
-- Recalculate gap if using viewport or percentage units -- Recalculate gap if using viewport or percentage units

View File

@@ -13,12 +13,13 @@ TestTextScaling = {}
-- Basic functionality tests -- Basic functionality tests
function TestTextScaling.testFixedTextSize() function TestTextScaling.testFixedTextSize()
-- Create an element with fixed textSize in pixels -- Create an element with fixed textSize in pixels (auto-scaling disabled)
local element = Gui.new({ local element = Gui.new({
id = "testElement", id = "testElement",
width = 100, width = 100,
height = 50, height = 50,
textSize = 16, -- Fixed size in pixels textSize = 16, -- Fixed size in pixels
autoScaleText = false, -- Disable auto-scaling for truly fixed size
text = "Hello World", text = "Hello World",
}) })
@@ -145,7 +146,7 @@ end
-- Edge case tests -- Edge case tests
function TestTextScaling.testZeroPercentageTextSize() function TestTextScaling.testZeroPercentageTextSize()
-- Create an element with 0% textSize -- Create an element with 0% textSize (protected to minimum 1px)
local element = Gui.new({ local element = Gui.new({
id = "testElement", id = "testElement",
width = 100, width = 100,
@@ -154,15 +155,15 @@ function TestTextScaling.testZeroPercentageTextSize()
text = "Hello World", text = "Hello World",
}) })
luaunit.assertEquals(element.textSize, 0.0) luaunit.assertEquals(element.textSize, 1) -- Protected to minimum 1px
-- Should remain 0 after resize -- Should remain at minimum after resize
element:resize(1600, 1200) element:resize(1600, 1200)
luaunit.assertEquals(element.textSize, 0.0) luaunit.assertEquals(element.textSize, 1) -- Protected to minimum 1px
end end
function TestTextScaling.testVerySmallTextSize() function TestTextScaling.testVerySmallTextSize()
-- Create an element with very small textSize -- Create an element with very small textSize (protected to minimum 1px)
local element = Gui.new({ local element = Gui.new({
id = "testElement", id = "testElement",
width = 100, width = 100,
@@ -171,10 +172,10 @@ function TestTextScaling.testVerySmallTextSize()
text = "Hello World", text = "Hello World",
}) })
-- Check initial state (0.1% of 600px = 0.6px) -- Check initial state (0.1% of 600px = 0.6px, protected to 1px)
luaunit.assertEquals(element.textSize, 0.6) luaunit.assertEquals(element.textSize, 1)
-- Should scale proportionally -- Should scale proportionally when above minimum
element:resize(1600, 1200) element:resize(1600, 1200)
luaunit.assertEquals(element.textSize, 1.2) -- 0.1% of 1200px = 1.2px luaunit.assertEquals(element.textSize, 1.2) -- 0.1% of 1200px = 1.2px
end end
@@ -250,7 +251,7 @@ end
function TestTextScaling.testMixedUnitsInDifferentElements() function TestTextScaling.testMixedUnitsInDifferentElements()
-- Create multiple elements with different unit types -- Create multiple elements with different unit types
local elements = { local elements = {
Gui.new({ id = "px", textSize = 20, text = "Fixed" }), Gui.new({ id = "px", textSize = 20, autoScaleText = false, text = "Fixed" }),
Gui.new({ id = "percent", textSize = "5%", text = "Percent" }), Gui.new({ id = "percent", textSize = "5%", text = "Percent" }),
Gui.new({ id = "vw", textSize = "3vw", text = "ViewWidth" }), Gui.new({ id = "vw", textSize = "3vw", text = "ViewWidth" }),
Gui.new({ id = "vh", textSize = "4vh", text = "ViewHeight" }), Gui.new({ id = "vh", textSize = "4vh", text = "ViewHeight" }),

View File

@@ -3,17 +3,27 @@
local love_helper = {} local love_helper = {}
-- Mock window state
local mockWindowWidth = 800
local mockWindowHeight = 600
-- Mock window functions -- Mock window functions
love_helper.window = {} love_helper.window = {}
function love_helper.window.getMode() function love_helper.window.getMode()
return 800, 600 -- Default resolution return mockWindowWidth, mockWindowHeight
end
function love_helper.window.setMode(width, height)
mockWindowWidth = width
mockWindowHeight = height
return true
end end
-- Mock graphics functions -- Mock graphics functions
love_helper.graphics = {} love_helper.graphics = {}
function love_helper.graphics.getDimensions() function love_helper.graphics.getDimensions()
return 800, 600 -- Default resolution - same as window.getMode return mockWindowWidth, mockWindowHeight
end end
function love_helper.graphics.newFont(size) function love_helper.graphics.newFont(size)