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
--- 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)
---@param spacingProps table?
---@param parentWidth number
@@ -233,14 +246,50 @@ end
--- Top level GUI manager
---@class Gui
---@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 draw fun(): nil
---@field update fun(dt:number): 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()
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
win:resize(newWidth, newHeight)
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)
local widthProp = props.width
if widthProp then
@@ -581,7 +633,8 @@ function Element.new(props)
local parentWidth = self.parent and self.parent.width or viewportWidth
self.width = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth)
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" }
end
else
@@ -599,7 +652,8 @@ function Element.new(props)
local parentHeight = self.parent and self.parent.height or viewportHeight
self.height = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight)
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" }
end
else
@@ -664,9 +718,24 @@ function Element.new(props)
self.textSize = Units.resolve(value, unit, viewportWidth, viewportHeight, nil)
end
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" }
end
end
else
-- No textSize specified - use auto-scaling default
if self.autoScaleText then
@@ -674,18 +743,26 @@ function Element.new(props)
self.textSize = (1.5 / 100) * viewportHeight
self.units.textSize = { value = 1.5, unit = "vh" }
else
-- Fixed 12px when auto-scaling is disabled
self.textSize = 12
-- Fixed 12px when auto-scaling is disabled (with base scaling if set)
self.textSize = Gui.baseScale and (12 * scaleY) or 12
self.units.textSize = { value = nil, unit = "px" }
end
end
-- Apply min/max constraints
if self.minTextSize and self.textSize < self.minTextSize then
self.textSize = self.minTextSize
-- Apply min/max constraints (also scaled)
local minSize = self.minTextSize and (Gui.baseScale and (self.minTextSize * scaleY) or 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
if self.maxTextSize and self.textSize > self.maxTextSize then
self.textSize = self.maxTextSize
if maxSize and self.textSize > maxSize then
self.textSize = maxSize
end
-- Protect against too-small text sizes (minimum 1px)
if self.textSize < 1 then
self.textSize = 1 -- Minimum 1px
end
-- Store original spacing values for proper resize handling
@@ -730,7 +807,8 @@ function Element.new(props)
self.units.x = { value = value, unit = unit }
self.x = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth)
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" }
end
else
@@ -745,7 +823,8 @@ function Element.new(props)
self.units.y = { value = value, unit = unit }
self.y = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight)
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" }
end
else
@@ -798,7 +877,8 @@ function Element.new(props)
local parentWidth = self.parent.width
self.x = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth)
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" }
end
else
@@ -814,7 +894,8 @@ function Element.new(props)
local parentHeight = self.parent.height
self.y = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight)
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" }
end
else
@@ -836,7 +917,9 @@ function Element.new(props)
local offsetX = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth)
self.x = baseX + offsetX
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" }
end
else
@@ -852,7 +935,9 @@ function Element.new(props)
local offsetY = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight)
self.y = baseY + offsetY
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" }
end
else
@@ -1248,7 +1333,11 @@ function Element:layoutChildren()
child.y = self.y + self.padding.top + currentCrossPos + child.padding.top
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.padding.top
child.y = self.y
+ self.padding.top
+ currentCrossPos
+ ((lineHeight - childTotalHeight) / 2)
+ child.padding.top
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.padding.top
@@ -1283,7 +1372,11 @@ function Element:layoutChildren()
child.x = self.x + self.padding.left + currentCrossPos + child.padding.left
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.padding.left
child.x = self.x
+ self.padding.left
+ currentCrossPos
+ ((lineHeight - childTotalWidth) / 2)
+ child.padding.left
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.padding.left
@@ -1523,11 +1616,17 @@ end
---@param newViewportWidth number
---@param newViewportHeight number
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)
if self.units.width.unit ~= "px" and self.units.width.unit ~= "auto" then
local parentWidth = self.parent and self.parent.width or newViewportWidth
self.width =
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
-- 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
self.height =
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
-- 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)
self.x = baseX + offsetX
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
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
@@ -1559,10 +1665,14 @@ function Element:recalculateUnits(newViewportWidth, newViewportHeight)
Units.resolve(self.units.y.value, self.units.y.unit, newViewportWidth, newViewportHeight, parentHeight)
self.y = baseY + offsetY
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
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
@@ -1587,13 +1697,34 @@ function Element:recalculateUnits(newViewportWidth, newViewportHeight)
self.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, nil)
end
-- Apply min/max constraints
if self.minTextSize and self.textSize < self.minTextSize then
self.textSize = self.minTextSize
-- Apply min/max constraints (with base scaling)
local minSize = self.minTextSize and (Gui.baseScale and (self.minTextSize * scaleY) or 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
if self.maxTextSize and self.textSize > self.maxTextSize then
self.textSize = self.maxTextSize
if maxSize and self.textSize > maxSize then
self.textSize = maxSize
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
-- Recalculate gap if using viewport or percentage units

View File

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

View File

@@ -3,17 +3,27 @@
local love_helper = {}
-- Mock window state
local mockWindowWidth = 800
local mockWindowHeight = 600
-- Mock window functions
love_helper.window = {}
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
-- Mock graphics functions
love_helper.graphics = {}
function love_helper.graphics.getDimensions()
return 800, 600 -- Default resolution - same as window.getMode
return mockWindowWidth, mockWindowHeight
end
function love_helper.graphics.newFont(size)