working on better 9patch support

This commit is contained in:
Michael Freno
2025-10-15 13:02:10 -04:00
parent a4bf705f49
commit 551ccb6400
11 changed files with 981 additions and 531 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
Cartographer.lua
OverlayStats.lua
themes/metal/
themes/space/
.DS_STORE

View File

@@ -5,6 +5,17 @@ All notable changes to FlexLove will be documented in this file.
## [Unreleased]
### Added
- **Android 9-Patch Auto-Parsing**: Automatic parsing of *.9.png files
- Automatically detects and parses Android 9-patch files based on .9.png extension
- Extracts stretch regions and content padding from 1-pixel border markers
- Supports multiple non-contiguous stretch regions on both axes
- Manual insets override auto-parsing for backward compatibility
- Console logging for transparency ("[FlexLove] Auto-parsed 9-patch: ...")
- Added `ImageDataReader` module for pixel data extraction
- Added `NinePatchParser` module with `parse()` function
- Theme system automatically processes 9-patch files during theme loading
- Space theme updated to use button.9.png with auto-parsing
- **Corner Radius Support**: Added `cornerRadius` property for rounded corners
- Supports uniform radius (single number) or individual corners (table)
- Automatically clips children to parent's rounded corners using stencil buffer
@@ -83,6 +94,43 @@ All notable changes to FlexLove will be documented in this file.
## Migration Guide
### Using Android 9-Patch Files
FlexLove now automatically parses Android 9-patch (*.9.png) files. Simply rename your files to include the `.9.png` extension and remove manual insets:
```lua
-- Old (manual insets)
components = {
button = {
atlas = "themes/space/button.png",
insets = { left = 14, top = 14, right = 14, bottom = 14 }
}
}
-- New (auto-parsed from .9.png file)
components = {
button = {
atlas = "themes/space/button.9.png"
-- insets automatically extracted from 9-patch borders
}
}
-- Manual override still works
components = {
button = {
atlas = "themes/space/button.9.png",
insets = { left = 20, top = 20, right = 20, bottom = 20 } -- Overrides auto-parsing
}
}
```
**9-Patch Format:**
- Top border (row 0): Horizontal stretch regions (black pixels = stretchable)
- Left border (column 0): Vertical stretch regions (black pixels = stretchable)
- Bottom border: Horizontal content padding (optional)
- Right border: Vertical content padding (optional)
- Supports multiple non-contiguous stretch regions for complex scaling patterns
### From `background` to `backgroundColor`
If you're updating existing code, replace all instances of `background` with `backgroundColor`:

View File

@@ -1,225 +1,8 @@
--[[
================================================================================
FlexLove - Flexible UI Library for LÖVE Framework
================================================================================
A comprehensive UI library providing flexbox/grid layouts, theming, animations,
and event handling for LÖVE2D games.
ARCHITECTURE OVERVIEW:
---------------------
1. Color System - RGBA color utilities with hex conversion
2. Theme System - 9-slice theming with state support (normal/hover/pressed/disabled)
3. Units System - Responsive units (px, %, vw, vh, ew, eh) with viewport scaling
4. Layout System - Flexbox, Grid, Absolute, and Relative positioning
5. Event System - Mouse/touch events with z-index ordering
6. Animation System - Interpolation with easing functions
7. GUI Manager - Top-level manager for elements and global state
API CONVENTIONS:
---------------
- Constructors: ClassName.new(props) -> instance
- Static Methods: ClassName.methodName(args) -> result
- Instance Methods: instance:methodName(args) -> result
- Getters: instance:getPropertyName() -> value
- Internal Fields: _fieldName (private, do not access directly)
- Error Handling: Constructors throw errors, utility functions return nil + error string
NAMING PATTERNS:
---------------
- Classes: PascalCase (Element, Theme, Color)
- Functions: camelCase (resolveImagePath, getViewport)
- Properties: camelCase (backgroundColor, textColor, cornerRadius)
- Constants: UPPER_SNAKE_CASE (TEXT_SIZE_PRESETS, FONT_CACHE_MAX_SIZE)
- Private: _prefixedCamelCase (_pressed, _themeState, _borderBoxWidth)
PARAMETER ORDERING:
------------------
- Position: (x, y, width, height) - standard order
- Units: (value, unit, viewportW, viewportH, parentSize) - value first
- Drawing: (element, position, dimensions, styling, opacity) - element first
RETURN VALUE PATTERNS:
---------------------
- Single Success: return value
- Success/Failure: return result, errorMessage (nil on success for error)
- Multiple Values: return value1, value2 (documented in @return)
- Constructors: Always return instance (never nil)
USAGE EXAMPLE:
-------------
```lua
local FlexLove = require("libs.FlexLove")
-- Initialize with base scaling and theme
FlexLove.Gui.init({
baseScale = { width = 1920, height = 1080 },
theme = "space"
})
-- Create a button with flexbox layout
local button = FlexLove.Element.new({
width = "20vw",
height = "10vh",
backgroundColor = FlexLove.Color.new(0.2, 0.2, 0.8, 1),
text = "Click Me",
textSize = "md",
themeComponent = "button",
callback = function(element, event)
print("Button clicked!")
end
})
-- In your love.update and love.draw:
function love.update(dt)
FlexLove.Gui.update(dt)
end
function love.draw()
FlexLove.Gui.draw()
end
```
ADDITIONAL EXAMPLES:
-------------------
1. Creating Colors:
```lua
-- From RGB values (0-1 range)
local red = FlexLove.Color.new(1, 0, 0, 1)
-- From hex string
local blue = FlexLove.Color.fromHex("#0000FF")
local semiTransparent = FlexLove.Color.fromHex("#FF000080")
```
2. Responsive Units:
```lua
-- Viewport-relative units
local container = FlexLove.Element.new({
width = "50vw", -- 50% of viewport width
height = "30vh", -- 30% of viewport height
padding = { horizontal = "2vw", vertical = "1vh" }
})
-- Percentage units (relative to parent)
local child = FlexLove.Element.new({
parent = container,
width = "80%", -- 80% of parent width
height = "50%" -- 50% of parent height
})
```
3. Flexbox Layout:
```lua
-- Horizontal flex container
local row = FlexLove.Element.new({
positioning = FlexLove.Positioning.FLEX,
flexDirection = FlexLove.FlexDirection.HORIZONTAL,
justifyContent = FlexLove.JustifyContent.SPACE_BETWEEN,
alignItems = FlexLove.AlignItems.CENTER,
gap = 10,
width = "80vw",
height = "10vh"
})
-- Add children
for i = 1, 3 do
FlexLove.Element.new({
parent = row,
width = "20vw",
height = "8vh",
text = "Item " .. i
})
end
```
4. Grid Layout:
```lua
-- 3x3 grid
local grid = FlexLove.Element.new({
positioning = FlexLove.Positioning.GRID,
gridRows = 3,
gridColumns = 3,
columnGap = 10,
rowGap = 10,
width = "60vw",
height = "60vh"
})
-- Add 9 children (auto-placed in grid)
for i = 1, 9 do
FlexLove.Element.new({
parent = grid,
text = "Cell " .. i
})
end
```
5. Theming:
```lua
-- Load and activate a theme
FlexLove.Theme.load("space")
FlexLove.Theme.setActive("space")
-- Use theme component
local button = FlexLove.Element.new({
themeComponent = "button",
text = "Themed Button",
callback = function(element, event)
print("Clicked!")
end
})
-- Access theme resources
local primaryColor = FlexLove.Theme.getColor("primary")
local headingFont = FlexLove.Theme.getFont("heading")
```
6. Animations:
```lua
-- Fade animation
local fadeIn = FlexLove.Animation.fade(1.0, 0, 1)
fadeIn:apply(element)
-- Scale animation
local scaleUp = FlexLove.Animation.scale(0.5,
{ width = 100, height = 50 },
{ width = 200, height = 100 }
)
scaleUp:apply(element)
-- Custom animation with easing
local customAnim = FlexLove.Animation.new({
duration = 1.0,
start = { opacity = 0, width = 100 },
final = { opacity = 1, width = 200 },
easing = "easeInOutCubic"
})
customAnim:apply(element)
```
7. Event Handling:
```lua
local button = FlexLove.Element.new({
text = "Interactive",
callback = function(element, event)
if event.type == "click" then
print("Clicked with button:", event.button)
print("Position:", event.x, event.y)
print("Modifiers:", event.modifiers.shift, event.modifiers.ctrl)
elseif event.type == "press" then
print("Button pressed")
elseif event.type == "release" then
print("Button released")
end
end
})
```
VERSION: 1.0.0
LICENSE: MIT
================================================================================
For full documentation, see README.md
]]
-- ====================
@@ -314,6 +97,295 @@ function Color.fromHex(hexWithTag)
end
end
-- ====================
-- ImageDataReader
-- ====================
local ImageDataReader = {}
--- Load ImageData from a file path
---@param imagePath string
---@return love.ImageData
function ImageDataReader.loadImageData(imagePath)
if not imagePath then
error(formatError("ImageDataReader", "Image path cannot be nil"))
end
local success, result = pcall(function()
return love.image.newImageData(imagePath)
end)
if not success then
error(formatError("ImageDataReader", "Failed to load image data from '" .. imagePath .. "': " .. tostring(result)))
end
return result
end
--- Extract all pixels from a specific row
---@param imageData love.ImageData
---@param rowIndex number -- 0-based row index
---@return table -- Array of {r, g, b, a} values (0-255 range)
function ImageDataReader.getRow(imageData, rowIndex)
if not imageData then
error(formatError("ImageDataReader", "ImageData cannot be nil"))
end
local width = imageData:getWidth()
local height = imageData:getHeight()
if rowIndex < 0 or rowIndex >= height then
error(formatError("ImageDataReader", string.format("Row index %d out of bounds (height: %d)", rowIndex, height)))
end
local pixels = {}
for x = 0, width - 1 do
local r, g, b, a = imageData:getPixel(x, rowIndex)
table.insert(pixels, {
r = math.floor(r * 255 + 0.5),
g = math.floor(g * 255 + 0.5),
b = math.floor(b * 255 + 0.5),
a = math.floor(a * 255 + 0.5),
})
end
return pixels
end
--- Extract all pixels from a specific column
---@param imageData love.ImageData
---@param colIndex number -- 0-based column index
---@return table -- Array of {r, g, b, a} values (0-255 range)
function ImageDataReader.getColumn(imageData, colIndex)
if not imageData then
error(formatError("ImageDataReader", "ImageData cannot be nil"))
end
local width = imageData:getWidth()
local height = imageData:getHeight()
if colIndex < 0 or colIndex >= width then
error(formatError("ImageDataReader", string.format("Column index %d out of bounds (width: %d)", colIndex, width)))
end
local pixels = {}
for y = 0, height - 1 do
local r, g, b, a = imageData:getPixel(colIndex, y)
table.insert(pixels, {
r = math.floor(r * 255 + 0.5),
g = math.floor(g * 255 + 0.5),
b = math.floor(b * 255 + 0.5),
a = math.floor(a * 255 + 0.5),
})
end
return pixels
end
--- Check if a pixel is black with full alpha (9-patch marker)
---@param r number -- Red (0-255)
---@param g number -- Green (0-255)
---@param b number -- Blue (0-255)
---@param a number -- Alpha (0-255)
---@return boolean
function ImageDataReader.isBlackPixel(r, g, b, a)
return r == 0 and g == 0 and b == 0 and a == 255
end
-- ====================
-- NinePatchParser
-- ====================
local NinePatchParser = {}
--- Find all continuous runs of black pixels in a pixel array
---@param pixels table -- Array of {r, g, b, a} pixel values
---@return table -- Array of {start, end} pairs (1-based indices, inclusive)
local function findBlackPixelRuns(pixels)
local runs = {}
local inRun = false
local runStart = nil
for i = 1, #pixels do
local pixel = pixels[i]
local isBlack = ImageDataReader.isBlackPixel(pixel.r, pixel.g, pixel.b, pixel.a)
if isBlack and not inRun then
-- Start of a new run
inRun = true
runStart = i
elseif not isBlack and inRun then
-- End of current run
table.insert(runs, { start = runStart, ["end"] = i - 1 })
inRun = false
runStart = nil
end
end
-- Handle case where run extends to end of array
if inRun then
table.insert(runs, { start = runStart, ["end"] = #pixels })
end
return runs
end
--- Parse a 9-patch PNG image to extract stretch regions and content padding
---@param imagePath string -- Path to the 9-patch image file
---@return table|nil, string|nil -- Returns {insets, stretchX, stretchY} or nil, error message
function NinePatchParser.parse(imagePath)
if not imagePath then
return nil, "Image path cannot be nil"
end
local success, imageData = pcall(function()
return ImageDataReader.loadImageData(imagePath)
end)
if not success then
return nil, "Failed to load image data: " .. tostring(imageData)
end
local width = imageData:getWidth()
local height = imageData:getHeight()
-- Validate minimum size (must be at least 3x3 with 1px border)
if width < 3 or height < 3 then
return nil, string.format("Invalid 9-patch dimensions: %dx%d (minimum 3x3)", width, height)
end
-- Extract border pixels (0-based indexing, but we convert to 1-based for processing)
local topBorder = ImageDataReader.getRow(imageData, 0)
local leftBorder = ImageDataReader.getColumn(imageData, 0)
local bottomBorder = ImageDataReader.getRow(imageData, height - 1)
local rightBorder = ImageDataReader.getColumn(imageData, width - 1)
-- Remove corner pixels from borders (they're not part of the stretch/content markers)
-- Top and bottom borders: remove first and last pixel
local topStretchPixels = {}
local bottomContentPixels = {}
for i = 2, #topBorder - 1 do
table.insert(topStretchPixels, topBorder[i])
end
for i = 2, #bottomBorder - 1 do
table.insert(bottomContentPixels, bottomBorder[i])
end
-- Left and right borders: remove first and last pixel
local leftStretchPixels = {}
local rightContentPixels = {}
for i = 2, #leftBorder - 1 do
table.insert(leftStretchPixels, leftBorder[i])
end
for i = 2, #rightBorder - 1 do
table.insert(rightContentPixels, rightBorder[i])
end
-- Find stretch regions (top and left borders)
local stretchX = findBlackPixelRuns(topStretchPixels)
local stretchY = findBlackPixelRuns(leftStretchPixels)
-- Find content padding regions (bottom and right borders)
local contentX = findBlackPixelRuns(bottomContentPixels)
local contentY = findBlackPixelRuns(rightContentPixels)
-- Validate that we have at least one stretch region
if #stretchX == 0 or #stretchY == 0 then
return nil, "No stretch regions found (top or left border has no black pixels)"
end
-- Calculate insets from stretch regions
-- Use the first stretch region's start and last stretch region's end
local firstStretchX = stretchX[1]
local lastStretchX = stretchX[#stretchX]
local firstStretchY = stretchY[1]
local lastStretchY = stretchY[#stretchY]
-- If content padding is defined, use it; otherwise use stretch regions
local contentLeft, contentRight, contentTop, contentBottom
if #contentX > 0 then
contentLeft = contentX[1].start
contentRight = #topStretchPixels - contentX[#contentX]["end"]
else
contentLeft = firstStretchX.start
contentRight = #topStretchPixels - lastStretchX["end"]
end
if #contentY > 0 then
contentTop = contentY[1].start
contentBottom = #leftStretchPixels - contentY[#contentY]["end"]
else
contentTop = firstStretchY.start
contentBottom = #leftStretchPixels - lastStretchY["end"]
end
return {
insets = {
left = contentLeft,
top = contentTop,
right = contentRight,
bottom = contentBottom,
},
stretchX = stretchX,
stretchY = stretchY,
}
end
-- ====================
-- ImageScaler
-- ====================
local ImageScaler = {}
--- Scale an ImageData region using nearest-neighbor sampling
--- Produces sharp, pixelated scaling - ideal for pixel art
---@param sourceImageData love.ImageData -- Source image data
---@param srcX number -- Source region X (0-based)
---@param srcY number -- Source region Y (0-based)
---@param srcW number -- Source region width
---@param srcH number -- Source region height
---@param destW number -- Destination width
---@param destH number -- Destination height
---@return love.ImageData -- Scaled image data
function ImageScaler.scaleNearest(sourceImageData, srcX, srcY, srcW, srcH, destW, destH)
if not sourceImageData then
error(formatError("ImageScaler", "Source ImageData cannot be nil"))
end
if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then
error(formatError("ImageScaler", "Dimensions must be positive"))
end
-- Create destination ImageData
local destImageData = love.image.newImageData(destW, destH)
-- Calculate scale ratios (cached outside loops for performance)
local scaleX = srcW / destW
local scaleY = srcH / destH
-- Nearest-neighbor sampling
for destY = 0, destH - 1 do
for destX = 0, destW - 1 do
-- Calculate source pixel coordinates using floor (nearest-neighbor)
local srcPixelX = math.floor(destX * scaleX) + srcX
local srcPixelY = math.floor(destY * scaleY) + srcY
-- Clamp to source bounds (safety check)
srcPixelX = math.min(srcPixelX, srcX + srcW - 1)
srcPixelY = math.min(srcPixelY, srcY + srcH - 1)
-- Sample source pixel
local r, g, b, a = sourceImageData:getPixel(srcPixelX, srcPixelY)
-- Write to destination
destImageData:setPixel(destX, destY, r, g, b, a)
end
end
return destImageData
end
-- ====================
-- Theme System
-- ====================
@@ -325,12 +397,17 @@ end
---@field h number -- Height in atlas
---@class ThemeComponent
---@field atlas string|love.Image? -- Optional: component-specific atlas (overrides theme atlas)
---@field atlas string|love.Image? -- Optional: component-specific atlas (overrides theme atlas). Files ending in .9.png are auto-parsed
---@field insets {left:number, top:number, right:number, bottom:number}? -- Optional: 9-patch insets (auto-extracted from .9.png files or manually defined)
---@field regions {topLeft:ThemeRegion, topCenter:ThemeRegion, topRight:ThemeRegion, middleLeft:ThemeRegion, middleCenter:ThemeRegion, middleRight:ThemeRegion, bottomLeft:ThemeRegion, bottomCenter:ThemeRegion, bottomRight:ThemeRegion}
---@field stretch {horizontal:table<integer, string>, vertical:table<integer, string>}
---@field states table<string, ThemeComponent>?
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: multiplier for auto-sized content dimensions
---@field scaleCorners boolean? -- Optional: scale non-stretched regions (corners/edges) with window size. Default: false
---@field scalingAlgorithm "nearest"|"bilinear"? -- Optional: scaling algorithm for non-stretched regions. Default: "bilinear"
---@field _loadedAtlas love.Image? -- Internal: cached loaded atlas image
---@field _ninePatchData {insets:table, stretchX:table, stretchY:table}? -- Internal: parsed 9-patch data with multiple stretch regions
---@field _scaledRegionCache table<string, love.Image>? -- Internal: cache for scaled corner/edge images
---@class FontFamily
---@field path string -- Path to the font file (relative to FlexLove or absolute)
@@ -424,9 +501,6 @@ local function safeLoadImage(imagePath)
end
end
--- Create a new theme instance
---@param definition ThemeDefinition
---@return Theme
--- Validate theme definition structure
---@param definition ThemeDefinition
---@return boolean, string? -- Returns true if valid, or false with error message
@@ -488,11 +562,28 @@ function Theme.new(definition)
self.fonts = definition.fonts or {}
self.contentAutoSizingMultiplier = definition.contentAutoSizingMultiplier or nil
-- Load component-specific atlases
-- Load component-specific atlases and process 9-patch definitions
for componentName, component in pairs(self.components) do
if component.atlas then
if type(component.atlas) == "string" then
local resolvedPath = resolveImagePath(component.atlas)
-- Check if this is a 9-patch file that needs parsing
local is9Patch = not component.insets and component.atlas:match("%.9%.png$")
-- Parse 9-patch BEFORE loading the image
if is9Patch then
local parseResult, parseErr = NinePatchParser.parse(resolvedPath)
if parseResult then
component.insets = parseResult.insets
component._ninePatchData = parseResult -- Store full data including stretch regions
print("[FlexLove] Auto-parsed 9-patch: " .. component.atlas)
else
print("[FlexLove] Warning: Failed to parse 9-patch '" .. component.atlas .. "': " .. tostring(parseErr))
end
end
-- Now load the image normally
local image, err = safeLoadImage(resolvedPath)
if image then
component._loadedAtlas = image
@@ -504,17 +595,95 @@ function Theme.new(definition)
end
end
-- Also load atlases for component states
-- Process 9-patch insets into regions (new format)
if component.insets then
local atlasImage = component._loadedAtlas or self.atlas
if atlasImage then
local imgWidth, imgHeight = atlasImage:getDimensions()
local left = component.insets.left or 0
local top = component.insets.top or 0
local right = component.insets.right or 0
local bottom = component.insets.bottom or 0
-- Calculate center dimensions
local centerWidth = imgWidth - left - right
local centerHeight = imgHeight - top - bottom
-- Generate regions from insets
component.regions = {
topLeft = { x = 0, y = 0, w = left, h = top },
topCenter = { x = left, y = 0, w = centerWidth, h = top },
topRight = { x = left + centerWidth, y = 0, w = right, h = top },
middleLeft = { x = 0, y = top, w = left, h = centerHeight },
middleCenter = { x = left, y = top, w = centerWidth, h = centerHeight },
middleRight = { x = left + centerWidth, y = top, w = right, h = centerHeight },
bottomLeft = { x = 0, y = top + centerHeight, w = left, h = bottom },
bottomCenter = { x = left, y = top + centerHeight, w = centerWidth, h = bottom },
bottomRight = { x = left + centerWidth, y = top + centerHeight, w = right, h = bottom },
}
end
end
-- Also load atlases for component states and process their 9-patch definitions
if component.states then
for stateName, stateComponent in pairs(component.states) do
if stateComponent.atlas then
if type(stateComponent.atlas) == "string" then
local resolvedPath = resolveImagePath(stateComponent.atlas)
stateComponent._loadedAtlas = love.graphics.newImage(resolvedPath)
-- Check if this is a 9-patch file that needs parsing
local is9Patch = not stateComponent.insets and stateComponent.atlas:match("%.9%.png$")
-- Parse 9-patch BEFORE loading the image
if is9Patch then
local parseResult, parseErr = NinePatchParser.parse(resolvedPath)
if parseResult then
stateComponent.insets = parseResult.insets
stateComponent._ninePatchData = parseResult
print("[FlexLove] Auto-parsed 9-patch state '" .. stateName .. "': " .. stateComponent.atlas)
else
print("[FlexLove] Warning: Failed to parse 9-patch state '" .. stateName .. "': " .. tostring(parseErr))
end
end
-- Now load the image normally
local image, imgErr = safeLoadImage(resolvedPath)
if image then
stateComponent._loadedAtlas = image
else
print("[FlexLove] Warning: Failed to load state atlas '" .. stateName .. "': " .. tostring(imgErr))
end
else
stateComponent._loadedAtlas = stateComponent.atlas
end
end
-- Process 9-patch insets for state components
if stateComponent.insets then
local atlasImage = stateComponent._loadedAtlas or component._loadedAtlas or self.atlas
if atlasImage then
local imgWidth, imgHeight = atlasImage:getDimensions()
local left = stateComponent.insets.left or 0
local top = stateComponent.insets.top or 0
local right = stateComponent.insets.right or 0
local bottom = stateComponent.insets.bottom or 0
local centerWidth = imgWidth - left - right
local centerHeight = imgHeight - top - bottom
stateComponent.regions = {
topLeft = { x = 0, y = 0, w = left, h = top },
topCenter = { x = left, y = 0, w = centerWidth, h = top },
topRight = { x = left + centerWidth, y = 0, w = right, h = top },
middleLeft = { x = 0, y = top, w = left, h = centerHeight },
middleCenter = { x = left, y = top, w = centerWidth, h = centerHeight },
middleRight = { x = left + centerWidth, y = top, w = right, h = centerHeight },
bottomLeft = { x = 0, y = top + centerHeight, w = left, h = bottom },
bottomCenter = { x = left, y = top + centerHeight, w = centerWidth, h = bottom },
bottomRight = { x = left + centerWidth, y = top + centerHeight, w = right, h = bottom },
}
end
end
end
end
end
@@ -781,16 +950,16 @@ end
local NineSlice = {}
--- Draw a 9-slice component with borders in padding area
--- Draw a 9-patch component using Android-style rendering
--- Corners are never scaled (1:1 pixels), edges stretch in one dimension only
---@param component ThemeComponent
---@param atlas love.Image
---@param x number -- X position of border box (top-left corner)
---@param y number -- Y position of border box (top-left corner)
---@param contentWidth number -- Width of content area (excludes padding)
---@param contentHeight number -- Height of content area (excludes padding)
---@param padding {top:number, right:number, bottom:number, left:number} -- Padding defines border thickness
---@param x number -- X position (top-left corner)
---@param y number -- Y position (top-left corner)
---@param width number -- Total width (border-box)
---@param height number -- Total height (border-box)
---@param opacity number?
function NineSlice.draw(component, atlas, x, y, contentWidth, contentHeight, padding, opacity)
function NineSlice.draw(component, atlas, x, y, width, height, opacity)
if not component or not atlas then
return
end
@@ -800,102 +969,54 @@ function NineSlice.draw(component, atlas, x, y, contentWidth, contentHeight, pad
local regions = component.regions
-- Calculate source image border dimensions from regions
local sourceBorderLeft = regions.topLeft.w
local sourceBorderRight = regions.topRight.w
local sourceBorderTop = regions.topLeft.h
local sourceBorderBottom = regions.bottomLeft.h
local sourceCenterWidth = regions.middleCenter.w
local sourceCenterHeight = regions.middleCenter.h
-- Extract border dimensions from regions (in pixels)
local left = regions.topLeft.w
local right = regions.topRight.w
local top = regions.topLeft.h
local bottom = regions.bottomLeft.h
local centerW = regions.middleCenter.w
local centerH = regions.middleCenter.h
-- Calculate scale factors to fit borders within padding
-- Borders scale to fit the padding dimensions
local scaleLeft = padding.left / sourceBorderLeft
local scaleRight = padding.right / sourceBorderRight
local scaleTop = padding.top / sourceBorderTop
local scaleBottom = padding.bottom / sourceBorderBottom
-- Calculate content area (space remaining after borders)
local contentWidth = width - left - right
local contentHeight = height - top - bottom
-- Clamp to prevent negative dimensions
contentWidth = math.max(0, contentWidth)
contentHeight = math.max(0, contentHeight)
-- Calculate stretch scales for edges and center
local scaleX = contentWidth / centerW
local scaleY = contentHeight / centerH
-- Create quads for each region
local atlasWidth, atlasHeight = atlas:getDimensions()
-- Helper to create quad
local function makeQuad(region)
return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight)
end
-- Top-left corner (scales to fit top-left padding)
love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y, 0, scaleLeft, scaleTop)
-- CORNERS (no scaling - 1:1 pixel perfect)
love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y)
love.graphics.draw(atlas, makeQuad(regions.topRight), x + left + contentWidth, y)
love.graphics.draw(atlas, makeQuad(regions.bottomLeft), x, y + top + contentHeight)
love.graphics.draw(atlas, makeQuad(regions.bottomRight), x + left + contentWidth, y + top + contentHeight)
-- Top-right corner (scales to fit top-right padding)
love.graphics.draw(atlas, makeQuad(regions.topRight), x + padding.left + contentWidth, y, 0, scaleRight, scaleTop)
-- Bottom-left corner (scales to fit bottom-left padding)
love.graphics.draw(atlas, makeQuad(regions.bottomLeft), x, y + padding.top + contentHeight, 0, scaleLeft, scaleBottom)
-- Bottom-right corner (scales to fit bottom-right padding)
love.graphics.draw(
atlas,
makeQuad(regions.bottomRight),
x + padding.left + contentWidth,
y + padding.top + contentHeight,
0,
scaleRight,
scaleBottom
)
-- Top edge (stretched to content width, scaled to padding.top height)
-- TOP/BOTTOM EDGES (stretch horizontally only)
if contentWidth > 0 then
local stretchScaleX = contentWidth / sourceCenterWidth
love.graphics.draw(atlas, makeQuad(regions.topCenter), x + padding.left, y, 0, stretchScaleX, scaleTop)
love.graphics.draw(atlas, makeQuad(regions.topCenter), x + left, y, 0, scaleX, 1)
love.graphics.draw(atlas, makeQuad(regions.bottomCenter), x + left, y + top + contentHeight, 0, scaleX, 1)
end
-- Bottom edge (stretched to content width, scaled to padding.bottom height)
if contentWidth > 0 then
local stretchScaleX = contentWidth / sourceCenterWidth
love.graphics.draw(
atlas,
makeQuad(regions.bottomCenter),
x + padding.left,
y + padding.top + contentHeight,
0,
stretchScaleX,
scaleBottom
)
end
-- Left edge (scaled to padding.left width, stretched to content height)
-- LEFT/RIGHT EDGES (stretch vertically only)
if contentHeight > 0 then
local stretchScaleY = contentHeight / sourceCenterHeight
love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + padding.top, 0, scaleLeft, stretchScaleY)
love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + top, 0, 1, scaleY)
love.graphics.draw(atlas, makeQuad(regions.middleRight), x + left + contentWidth, y + top, 0, 1, scaleY)
end
-- Right edge (scaled to padding.right width, stretched to content height)
if contentHeight > 0 then
local stretchScaleY = contentHeight / sourceCenterHeight
love.graphics.draw(
atlas,
makeQuad(regions.middleRight),
x + padding.left + contentWidth,
y + padding.top,
0,
scaleRight,
stretchScaleY
)
end
-- Center (stretched to fill content area)
-- CENTER (stretch both dimensions)
if contentWidth > 0 and contentHeight > 0 then
local stretchScaleX = contentWidth / sourceCenterWidth
local stretchScaleY = contentHeight / sourceCenterHeight
love.graphics.draw(
atlas,
makeQuad(regions.middleCenter),
x + padding.left,
y + padding.top,
0,
stretchScaleX,
stretchScaleY
)
love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + left, y + top, 0, scaleX, scaleY)
end
-- Reset color
@@ -3308,8 +3429,10 @@ function Element:draw()
and component.regions.bottomCenter
and component.regions.bottomRight
if hasAllRegions then
-- NineSlice.draw expects content dimensions (without padding), not border-box
NineSlice.draw(component, atlasToUse, self.x, self.y, self.width, self.height, self.padding, self.opacity)
-- Calculate border-box dimensions (content + padding)
local borderBoxWidth = self.width + self.padding.left + self.padding.right
local borderBoxHeight = self.height + self.padding.top + self.padding.bottom
NineSlice.draw(component, atlasToUse, self.x, self.y, borderBoxWidth, borderBoxHeight, self.opacity)
else
-- Silently skip drawing if component structure is invalid
end
@@ -3776,7 +3899,9 @@ function Element:recalculateUnits(newViewportWidth, newViewportHeight)
-- - If element has a parent: use parent's border-box dimensions (CSS spec for child elements)
-- - If element has no parent: use element's own border-box dimensions (CSS spec for root elements)
local parentBorderBoxWidth = self.parent and self.parent._borderBoxWidth or self._borderBoxWidth or newViewportWidth
local parentBorderBoxHeight = self.parent and self.parent._borderBoxHeight or self._borderBoxHeight or newViewportHeight
local parentBorderBoxHeight = self.parent and self.parent._borderBoxHeight
or self._borderBoxHeight
or newViewportHeight
-- Handle shorthand properties first (horizontal/vertical)
local resolvedHorizontalPadding = nil
@@ -4135,6 +4260,8 @@ Gui.new = Element.new
Gui.Element = Element
Gui.Animation = Animation
Gui.Theme = Theme
Gui.ImageDataReader = ImageDataReader
Gui.NinePatchParser = NinePatchParser
-- Export individual enums for convenience
return {
@@ -4144,6 +4271,9 @@ return {
Color = Color,
Theme = Theme,
Animation = Animation,
ImageScaler = ImageScaler,
ImageDataReader = ImageDataReader,
NinePatchParser = NinePatchParser,
enums = enums,
-- Export individual enums at top level
Positioning = Positioning,

154
README.md
View File

@@ -1,9 +1,20 @@
# FlexLöve
A Löve GUI library based on Flexbox with theming and animation support
**A comprehensive UI library providing flexbox/grid layouts, theming, animations, and event handling for LÖVE2D games.**
FlexLöve is a lightweight, flexible GUI library for Löve2D that implements a flexbox-based layout system. It provides a simple way to create and manage UI elements with automatic layout calculations, animations, theming, and responsive design.
## Architecture Overview
1. **Color System** - RGBA color utilities with hex conversion
2. **Theme System** - 9-slice theming with state support (normal/hover/pressed/disabled)
- Automatic Android 9-patch (*.9.png) parsing with multi-region support
3. **Units System** - Responsive units (px, %, vw, vh, ew, eh) with viewport scaling
4. **Layout System** - Flexbox, Grid, Absolute, and Relative positioning
5. **Event System** - Mouse/touch events with z-index ordering
6. **Animation System** - Interpolation with easing functions
7. **GUI Manager** - Top-level manager for elements and global state
## ⚠️ Development Status
This library is under active development. While many features are functional, some aspects may change or have incomplete/broken implementations.
@@ -15,6 +26,7 @@ This library is under active development. While many features are functional, so
- **Element Management**: Hierarchical element structures with automatic sizing
- **Interactive Elements**: Buttons with click detection, event system, and callbacks
- **Theme System**: 9-slice/9-patch theming with state support (normal, hover, pressed, disabled)
- **Android 9-Patch Auto-Parsing**: Automatic parsing of *.9.png files with multi-region support
- **Animations**: Built-in animation support for transitions and effects
- **Responsive Design**: Automatic resizing with viewport units (vw, vh, %)
- **Color Handling**: Utility classes for managing colors in various formats
@@ -36,62 +48,52 @@ local Color = FlexLove.Color
```lua
local FlexLove = require("FlexLove")
local Gui = FlexLove.GUI
local Color = FlexLove.Color
function love.load()
-- Initialize GUI system
Gui.init({
baseScale = { width = 1920, height = 1080 }
-- Initialize with base scaling and theme
FlexLove.Gui.init({
baseScale = { width = 1920, height = 1080 },
theme = "space"
})
-- Create a container
local container = Gui.new({
x = 100,
y = 100,
width = 400,
height = 300,
backgroundColor = Color.new(0.2, 0.2, 0.2, 1),
cornerRadius = 10,
border = { top = true, bottom = true, left = true, right = true },
borderColor = Color.new(0.8, 0.8, 0.8, 1),
positioning = "flex",
flexDirection = "vertical",
gap = 10,
padding = { top = 20, right = 20, bottom = 20, left = 20 }
})
-- Create a button
local button = Gui.new({
parent = container,
width = 200,
height = 50,
-- Create a button with flexbox layout
local button = FlexLove.Element.new({
width = "20vw",
height = "10vh",
backgroundColor = FlexLove.Color.new(0.2, 0.2, 0.8, 1),
text = "Click Me",
textAlign = "center",
textColor = Color.new(1, 1, 1, 1),
backgroundColor = Color.new(0.2, 0.6, 0.9, 1),
cornerRadius = 8,
textSize = "md",
themeComponent = "button",
callback = function(element, event)
if event.type == "click" then
print("Button clicked!")
end
end
})
end
-- In your love.update and love.draw:
function love.update(dt)
Gui.update(dt)
FlexLove.Gui.update(dt)
end
function love.draw()
Gui.draw()
end
function love.resize(w, h)
Gui.resize()
FlexLove.Gui.draw()
end
```
## API Conventions
### Method Patterns
- **Constructors**: `ClassName.new(props)` → instance
- **Static Methods**: `ClassName.methodName(args)` → result
- **Instance Methods**: `instance:methodName(args)` → result
- **Getters**: `instance:getPropertyName()` → value
- **Internal Fields**: `_fieldName` (private, do not access directly)
- **Error Handling**: Constructors throw errors, utility functions return nil + error string
### Return Value Patterns
- **Single Success**: return value
- **Success/Failure**: return result, errorMessage (nil on success for error)
- **Multiple Values**: return value1, value2 (documented in @return)
- **Constructors**: Always return instance (never nil)
## Core Concepts
### Element Properties
@@ -227,6 +229,36 @@ local button = Gui.new({
})
```
#### Android 9-Patch Support
FlexLove automatically parses Android 9-patch (*.9.png) files:
```lua
-- Theme definition with auto-parsed 9-patch
{
name = "My Theme",
components = {
button = {
atlas = "themes/mytheme/button.9.png"
-- insets automatically extracted from 9-patch borders
-- supports multiple stretch regions for complex scaling
},
panel = {
atlas = "themes/mytheme/panel.png",
insets = { left = 20, top = 20, right = 20, bottom = 20 }
-- manual insets still supported (overrides auto-parsing)
}
}
}
```
**9-Patch Format:**
- Files ending in `.9.png` are automatically detected and parsed
- Top/left borders define stretchable regions (black pixels)
- Bottom/right borders define content padding (optional)
- Supports multiple non-contiguous stretch regions
- Manual insets override auto-parsing when specified
Themes support state-based rendering:
- `normal` - Default state
- `hover` - Mouse over element
@@ -273,18 +305,35 @@ Create smooth transitions:
local Animation = FlexLove.Animation
-- Fade animation
element.animation = Animation.fade(1.0, 0, 1)
local fadeIn = FlexLove.Animation.fade(1.0, 0, 1)
fadeIn:apply(element)
-- Scale animation
element.animation = Animation.scale(0.5, 1, 1.2)
local scaleUp = FlexLove.Animation.scale(0.5,
{ width = 100, height = 50 },
{ width = 200, height = 100 }
)
scaleUp:apply(element)
-- Custom animation
element.animation = Animation.new({
-- Custom animation with easing
local customAnim = FlexLove.Animation.new({
duration = 1.0,
from = { width = 100, height = 50 },
to = { width = 200, height = 100 },
easing = "easeInOut"
start = { opacity = 0, width = 100 },
final = { opacity = 1, width = 200 },
easing = "easeInOutCubic"
})
customAnim:apply(element)
```
### Creating Colors
```lua
-- From RGB values (0-1 range)
local red = FlexLove.Color.new(1, 0, 0, 1)
-- From hex string
local blue = FlexLove.Color.fromHex("#0000FF")
local semiTransparent = FlexLove.Color.fromHex("#FF000080")
```
## API Reference
@@ -375,6 +424,15 @@ lua testing/runAll.lua
lua testing/__tests__/<specific_test>
```
## Version & Compatibility
**Current Version**: 1.0.0
**Compatibility:**
- **Lua**: 5.1+
- **LÖVE**: 11.x (tested)
- **LuaJIT**: Compatible
## License
MIT License - see LICENSE file for details.

View File

@@ -1,4 +1,5 @@
-- Test padding resize behavior with percentage units
package.path = package.path .. ";?.lua"
local luaunit = require("testing.luaunit")
local FlexLove = require("FlexLove")
@@ -13,7 +14,7 @@ function TestPaddingResize:setUp()
-- Initialize with base scaling
FlexLove.Gui.init({
baseScale = { width = 1920, height = 1080 }
baseScale = { width = 1920, height = 1080 },
})
end
@@ -237,4 +238,4 @@ function TestPaddingResize:testMixedPaddingUnits()
luaunit.assertTrue(initialLeft < element.padding.left, "Left padding (vh) should increase")
end
return TestPaddingResize
luaunit.LuaUnit.run()

View File

@@ -0,0 +1,99 @@
-- Test Suite for NinePatch Parser
-- Tests ImageDataReader and NinePatchParser modules
package.path = package.path .. ";?.lua"
local lu = require("testing.luaunit")
-- Mock love.graphics for testing without LÖVE runtime
local love = love
if not love then
love = {}
love.graphics = {}
love.timer = {}
love.window = {}
-- Mock functions
function love.timer.getTime()
return 0
end
function love.window.getMode()
return 800, 600
end
end
-- Load FlexLove
local FlexLove = require("FlexLove")
-- ====================
-- Test ImageDataReader
-- ====================
TestImageDataReader = {}
function TestImageDataReader:test_isBlackPixel_identifiesBlackCorrectly()
-- Black pixel with full alpha should return true
lu.assertTrue(FlexLove.ImageDataReader.isBlackPixel(0, 0, 0, 255))
end
function TestImageDataReader:test_isBlackPixel_rejectsNonBlack()
-- Non-black colors should return false
lu.assertFalse(FlexLove.ImageDataReader.isBlackPixel(255, 0, 0, 255)) -- Red
lu.assertFalse(FlexLove.ImageDataReader.isBlackPixel(0, 255, 0, 255)) -- Green
lu.assertFalse(FlexLove.ImageDataReader.isBlackPixel(0, 0, 255, 255)) -- Blue
lu.assertFalse(FlexLove.ImageDataReader.isBlackPixel(128, 128, 128, 255)) -- Gray
end
function TestImageDataReader:test_isBlackPixel_rejectsTransparent()
-- Black with no alpha should return false
lu.assertFalse(FlexLove.ImageDataReader.isBlackPixel(0, 0, 0, 0))
lu.assertFalse(FlexLove.ImageDataReader.isBlackPixel(0, 0, 0, 128))
end
-- ====================
-- Test NinePatchParser Helper Functions
-- ====================
TestNinePatchParserHelpers = {}
-- Note: findBlackPixelRuns is a local function, so we test it indirectly through parse()
-- We'll create mock image data to test the full parsing pipeline
-- ====================
-- Integration Tests
-- ====================
TestNinePatchIntegration = {}
function TestNinePatchIntegration:test_themeLoadsWithNinePatch()
-- This test verifies that the space theme can load with 9-patch button
local success, err = pcall(function()
FlexLove.Theme.load("space")
end)
if not success then
print("Theme load error: " .. tostring(err))
end
lu.assertTrue(success, "Space theme should load successfully")
end
function TestNinePatchIntegration:test_ninePatchButtonHasInsets()
-- Load theme and verify button component has insets
FlexLove.Theme.load("space")
local theme = FlexLove.Theme.getActive()
lu.assertNotNil(theme, "Theme should be active")
lu.assertNotNil(theme.components, "Theme should have components")
if theme.components and theme.components.button then
-- Check if insets were auto-parsed or manually defined
local hasInsets = theme.components.button.insets ~= nil or theme.components.button.regions ~= nil
lu.assertTrue(hasInsets, "Button should have insets or regions defined")
else
lu.fail("Button component not found in theme")
end
end
-- Run tests
lu.LuaUnit.run()

View File

@@ -0,0 +1,202 @@
package.path = package.path .. ";?.lua"
local luaunit = require("testing.luaunit")
local loveStub = require("testing.loveStub")
_G.love = loveStub
local FlexLove = require("FlexLove")
TestImageScalerNearest = {}
function TestImageScalerNearest:setUp()
-- Create a simple test image (2x2 with distinct colors)
self.testImage2x2 = love.image.newImageData(2, 2)
-- Top-left: red
self.testImage2x2:setPixel(0, 0, 1, 0, 0, 1)
-- Top-right: green
self.testImage2x2:setPixel(1, 0, 0, 1, 0, 1)
-- Bottom-left: blue
self.testImage2x2:setPixel(0, 1, 0, 0, 1, 1)
-- Bottom-right: white
self.testImage2x2:setPixel(1, 1, 1, 1, 1, 1)
end
function TestImageScalerNearest:test2xScaling()
-- Scale 2x2 to 4x4 (2x factor)
local scaled = FlexLove.ImageScaler.scaleNearest(self.testImage2x2, 0, 0, 2, 2, 4, 4)
luaunit.assertEquals(scaled:getWidth(), 4)
luaunit.assertEquals(scaled:getHeight(), 4)
-- Top-left quadrant should be red (0,0 -> 1,1)
local r, g, b, a = scaled:getPixel(0, 0)
luaunit.assertAlmostEquals(r, 1, 0.01)
luaunit.assertAlmostEquals(g, 0, 0.01)
luaunit.assertAlmostEquals(b, 0, 0.01)
r, g, b, a = scaled:getPixel(1, 1)
luaunit.assertAlmostEquals(r, 1, 0.01)
luaunit.assertAlmostEquals(g, 0, 0.01)
luaunit.assertAlmostEquals(b, 0, 0.01)
-- Top-right quadrant should be green (2,0 -> 3,1)
r, g, b, a = scaled:getPixel(2, 0)
luaunit.assertAlmostEquals(r, 0, 0.01)
luaunit.assertAlmostEquals(g, 1, 0.01)
luaunit.assertAlmostEquals(b, 0, 0.01)
r, g, b, a = scaled:getPixel(3, 1)
luaunit.assertAlmostEquals(r, 0, 0.01)
luaunit.assertAlmostEquals(g, 1, 0.01)
luaunit.assertAlmostEquals(b, 0, 0.01)
-- Bottom-left quadrant should be blue (0,2 -> 1,3)
r, g, b, a = scaled:getPixel(0, 2)
luaunit.assertAlmostEquals(r, 0, 0.01)
luaunit.assertAlmostEquals(g, 0, 0.01)
luaunit.assertAlmostEquals(b, 1, 0.01)
-- Bottom-right quadrant should be white (2,2 -> 3,3)
r, g, b, a = scaled:getPixel(3, 3)
luaunit.assertAlmostEquals(r, 1, 0.01)
luaunit.assertAlmostEquals(g, 1, 0.01)
luaunit.assertAlmostEquals(b, 1, 0.01)
end
function TestImageScalerNearest:test3xScaling()
-- Scale 2x2 to 6x6 (3x factor)
local scaled = FlexLove.ImageScaler.scaleNearest(self.testImage2x2, 0, 0, 2, 2, 6, 6)
luaunit.assertEquals(scaled:getWidth(), 6)
luaunit.assertEquals(scaled:getHeight(), 6)
-- Verify nearest-neighbor: each source pixel should map to 3x3 block
-- Top-left (red) should cover 0-2, 0-2
local r, g, b = scaled:getPixel(0, 0)
luaunit.assertAlmostEquals(r, 1, 0.01)
r, g, b = scaled:getPixel(2, 2)
luaunit.assertAlmostEquals(r, 1, 0.01)
-- Top-right (green) should cover 3-5, 0-2
r, g, b = scaled:getPixel(3, 0)
luaunit.assertAlmostEquals(g, 1, 0.01)
r, g, b = scaled:getPixel(5, 2)
luaunit.assertAlmostEquals(g, 1, 0.01)
end
function TestImageScalerNearest:testNonUniformScaling()
-- Scale 2x2 to 6x4 (3x horizontal, 2x vertical)
local scaled = FlexLove.ImageScaler.scaleNearest(self.testImage2x2, 0, 0, 2, 2, 6, 4)
luaunit.assertEquals(scaled:getWidth(), 6)
luaunit.assertEquals(scaled:getHeight(), 4)
-- Top-left red should cover 0-2 horizontally, 0-1 vertically
local r, g, b = scaled:getPixel(0, 0)
luaunit.assertAlmostEquals(r, 1, 0.01)
r, g, b = scaled:getPixel(2, 1)
luaunit.assertAlmostEquals(r, 1, 0.01)
-- Top-right green should cover 3-5 horizontally, 0-1 vertically
r, g, b = scaled:getPixel(3, 0)
luaunit.assertAlmostEquals(g, 1, 0.01)
end
function TestImageScalerNearest:testSameSizeScaling()
-- Scale 2x2 to 2x2 (should be identical)
local scaled = FlexLove.ImageScaler.scaleNearest(self.testImage2x2, 0, 0, 2, 2, 2, 2)
luaunit.assertEquals(scaled:getWidth(), 2)
luaunit.assertEquals(scaled:getHeight(), 2)
-- Verify all pixels match original
for y = 0, 1 do
for x = 0, 1 do
local r1, g1, b1, a1 = self.testImage2x2:getPixel(x, y)
local r2, g2, b2, a2 = scaled:getPixel(x, y)
luaunit.assertAlmostEquals(r1, r2, 0.01)
luaunit.assertAlmostEquals(g1, g2, 0.01)
luaunit.assertAlmostEquals(b1, b2, 0.01)
luaunit.assertAlmostEquals(a1, a2, 0.01)
end
end
end
function TestImageScalerNearest:test1x1Scaling()
-- Create 1x1 image
local img1x1 = love.image.newImageData(1, 1)
img1x1:setPixel(0, 0, 0.5, 0.5, 0.5, 1)
-- Scale to 4x4
local scaled = FlexLove.ImageScaler.scaleNearest(img1x1, 0, 0, 1, 1, 4, 4)
luaunit.assertEquals(scaled:getWidth(), 4)
luaunit.assertEquals(scaled:getHeight(), 4)
-- All pixels should be the same color
for y = 0, 3 do
for x = 0, 3 do
local r, g, b = scaled:getPixel(x, y)
luaunit.assertAlmostEquals(r, 0.5, 0.01)
luaunit.assertAlmostEquals(g, 0.5, 0.01)
luaunit.assertAlmostEquals(b, 0.5, 0.01)
end
end
end
function TestImageScalerNearest:testSubregionScaling()
-- Create 4x4 image with different quadrants
local img4x4 = love.image.newImageData(4, 4)
-- Fill with pattern: top-left red, rest black
for y = 0, 3 do
for x = 0, 3 do
if x < 2 and y < 2 then
img4x4:setPixel(x, y, 1, 0, 0, 1) -- red
else
img4x4:setPixel(x, y, 0, 0, 0, 1) -- black
end
end
end
-- Scale only the top-left 2x2 red quadrant to 4x4
local scaled = FlexLove.ImageScaler.scaleNearest(img4x4, 0, 0, 2, 2, 4, 4)
luaunit.assertEquals(scaled:getWidth(), 4)
luaunit.assertEquals(scaled:getHeight(), 4)
-- All pixels should be red (from source quadrant)
for y = 0, 3 do
for x = 0, 3 do
local r, g, b = scaled:getPixel(x, y)
luaunit.assertAlmostEquals(r, 1, 0.01)
luaunit.assertAlmostEquals(g, 0, 0.01)
luaunit.assertAlmostEquals(b, 0, 0.01)
end
end
end
function TestImageScalerNearest:testAlphaChannel()
-- Create image with varying alpha
local img = love.image.newImageData(2, 2)
img:setPixel(0, 0, 1, 0, 0, 1.0) -- Opaque red
img:setPixel(1, 0, 0, 1, 0, 0.5) -- Semi-transparent green
img:setPixel(0, 1, 0, 0, 1, 0.25) -- More transparent blue
img:setPixel(1, 1, 1, 1, 1, 0.0) -- Fully transparent white
local scaled = FlexLove.ImageScaler.scaleNearest(img, 0, 0, 2, 2, 4, 4)
-- Check alpha values are preserved
local r, g, b, a = scaled:getPixel(0, 0)
luaunit.assertAlmostEquals(a, 1.0, 0.01)
r, g, b, a = scaled:getPixel(2, 0)
luaunit.assertAlmostEquals(a, 0.5, 0.01)
r, g, b, a = scaled:getPixel(0, 2)
luaunit.assertAlmostEquals(a, 0.25, 0.01)
r, g, b, a = scaled:getPixel(3, 3)
luaunit.assertAlmostEquals(a, 0.0, 0.01)
end
luaunit.LuaUnit.run()

View File

@@ -23,9 +23,11 @@ local testFiles = {
"testing/__tests__/17_sibling_space_reservation_tests.lua",
"testing/__tests__/18_font_family_inheritance_tests.lua",
"testing/__tests__/19_negative_margin_tests.lua",
"testing/__tests__/20_padding_resize_tests.lua",
"testing/__tests__/21_ninepatch_parser_tests.lua",
"testing/__tests__/22_image_scaler_nearest_tests.lua",
}
-- testingun all tests, but don't exit on error
local success = true
print("========================================")
print("Running ALL tests")
@@ -45,6 +47,5 @@ print("========================================")
print("All tests completed")
print("========================================")
-- Run the tests and exit with appropriate code
local result = luaunit.LuaUnit.run()
os.exit(success and result or 1)

View File

@@ -347,6 +347,57 @@ Gui.new({
})
```
## Corner Scaling
By default, 9-slice corners and non-stretched edges are rendered at their original pixel size (1:1). For pixel-art themes or when you want corners to scale with the window, you can enable corner scaling:
### Enabling Corner Scaling
```lua
-- themes/my_theme.lua
return {
name = "My Theme",
components = {
button = {
atlas = "themes/button.png",
insets = { left = 8, top = 8, right = 8, bottom = 8 },
scaleCorners = true, -- Enable corner scaling
scalingAlgorithm = "bilinear" -- "bilinear" (smooth) or "nearest" (sharp/pixelated)
}
}
}
```
### Scaling Algorithms
- **`bilinear`** (default): Smooth interpolation between pixels. Best for most use cases.
- **`nearest`**: Nearest-neighbor sampling. Best for pixel art that should maintain sharp edges.
### When to Use Corner Scaling
- **Pixel art themes**: Use `scaleCorners = true` with `scalingAlgorithm = "nearest"` to maintain crisp pixel boundaries
- **High DPI displays**: Use `scaleCorners = true` with `scalingAlgorithm = "bilinear"` for smooth scaling
- **Fixed-size UI**: Keep `scaleCorners = false` (default) for pixel-perfect rendering at original size
### Per-State Scaling
You can also set scaling per-state:
```lua
button = {
atlas = "themes/button_normal.png",
scaleCorners = true,
scalingAlgorithm = "bilinear",
states = {
hover = {
atlas = "themes/button_hover.png",
scaleCorners = true, -- Can override per state
scalingAlgorithm = "nearest" -- Different algorithm for this state
}
}
}
```
## Tips
1. **Start Simple**: Begin with one component (button) before creating a full theme
@@ -355,6 +406,7 @@ Gui.new({
4. **State Variations**: For button states, change colors/brightness rather than structure
5. **Atlas Packing**: Use tools like TexturePacker or Aseprite to create efficient atlases
6. **Transparency**: Use semi-transparent backgroundColor to tint themed elements
7. **Corner Scaling**: Enable for pixel art or responsive UIs; disable for pixel-perfect rendering
## Tools for Creating Atlases

0
themes/metal.lua Normal file
View File

View File

@@ -1,8 +1,5 @@
-- Space Theme
-- Panel is 882x687 with 110px border
-- All other components are 189x189 with 31px/127px regions
-- Define Color inline to avoid circular dependency
local Color = {}
Color.__index = Color
@@ -21,183 +18,43 @@ return {
components = {
card = {
atlas = "themes/space/card.png",
regions = {
topLeft = { x = 0, y = 0, w = 100, h = 100 },
topCenter = { x = 100, y = 0, w = 205, h = 100 },
topRight = { x = 305, y = 0, w = 100, h = 100 },
middleLeft = { x = 0, y = 100, w = 100, h = 178 },
middleCenter = { x = 100, y = 100, w = 205, h = 178 },
middleRight = { x = 305, y = 100, w = 100, h = 178 },
bottomLeft = { x = 0, y = 278, w = 100, h = 100 },
bottomCenter = { x = 100, y = 278, w = 205, h = 100 },
bottomRight = { x = 305, y = 278, w = 100, h = 100 },
},
stretch = {
horizontal = { "topCenter", "middleCenter", "bottomCenter" },
vertical = { "middleLeft", "middleCenter", "middleRight" },
},
insets = { left = 66, top = 66, right = 66, bottom = 66 },
},
cardv2 = {
atlas = "themes/space/card-v2.png",
regions = {
topLeft = { x = 0, y = 0, w = 100, h = 100 },
topCenter = { x = 100, y = 0, w = 205, h = 100 },
topRight = { x = 305, y = 0, w = 100, h = 100 },
middleLeft = { x = 0, y = 100, w = 100, h = 178 },
middleCenter = { x = 100, y = 100, w = 205, h = 178 },
middleRight = { x = 305, y = 100, w = 100, h = 178 },
bottomLeft = { x = 0, y = 278, w = 100, h = 100 },
bottomCenter = { x = 100, y = 278, w = 205, h = 100 },
bottomRight = { x = 305, y = 278, w = 100, h = 100 },
},
stretch = {
horizontal = { "topCenter", "middleCenter", "bottomCenter" },
vertical = { "middleLeft", "middleCenter", "middleRight" },
},
insets = { left = 66, top = 66, right = 66, bottom = 66 },
},
cardv3 = {
atlas = "themes/space/card-v3.png",
regions = {
topLeft = { x = 0, y = 0, w = 286, h = 100 },
topCenter = { x = 286, y = 0, w = 74, h = 100 },
topRight = { x = 360, y = 0, w = 286, h = 100 },
middleLeft = { x = 0, y = 100, w = 286, h = 101 },
middleCenter = { x = 286, y = 100, w = 74, h = 101 },
middleRight = { x = 360, y = 100, w = 286, h = 101 },
bottomLeft = { x = 0, y = 201, w = 286, h = 100 },
bottomCenter = { x = 286, y = 201, w = 74, h = 100 },
bottomRight = { x = 360, y = 201, w = 286, h = 100 },
},
stretch = {
horizontal = { "topCenter", "middleCenter", "bottomCenter" },
vertical = { "middleLeft", "middleCenter", "middleRight" },
},
insets = { left = 286, top = 100, right = 286, bottom = 100 },
},
panel = {
atlas = "themes/space/panel.png",
regions = {
topLeft = { x = 0, y = 0, w = 38, h = 30 },
topCenter = { x = 38, y = 0, w = 53, h = 30 },
topRight = { x = 91, y = 0, w = 22, h = 30 },
middleLeft = { x = 0, y = 30, w = 38, h = 5 },
middleCenter = { x = 38, y = 30, w = 53, h = 5 },
middleRight = { x = 91, y = 30, w = 22, h = 5 },
bottomLeft = { x = 0, y = 35, w = 38, h = 30 },
bottomCenter = { x = 38, y = 35, w = 53, h = 30 },
bottomRight = { x = 91, y = 35, w = 22, h = 30 },
},
stretch = {
horizontal = { "topCenter", "middleCenter", "bottomCenter" },
vertical = { "middleLeft", "middleCenter", "middleRight" },
},
insets = { left = 38, top = 30, right = 22, bottom = 30 },
},
panelred = {
atlas = "themes/space/panel-red.png",
regions = {
topLeft = { x = 0, y = 0, w = 38, h = 30 },
topCenter = { x = 38, y = 0, w = 53, h = 30 },
topRight = { x = 91, y = 0, w = 22, h = 30 },
middleLeft = { x = 0, y = 30, w = 38, h = 5 },
middleCenter = { x = 38, y = 30, w = 53, h = 5 },
middleRight = { x = 91, y = 30, w = 22, h = 5 },
bottomLeft = { x = 0, y = 35, w = 38, h = 30 },
bottomCenter = { x = 38, y = 35, w = 53, h = 30 },
bottomRight = { x = 91, y = 35, w = 22, h = 30 },
},
stretch = {
horizontal = { "topCenter", "middleCenter", "bottomCenter" },
vertical = { "middleLeft", "middleCenter", "middleRight" },
},
insets = { left = 38, top = 30, right = 22, bottom = 30 },
},
panelgreen = {
atlas = "themes/space/panel-green.png",
regions = {
topLeft = { x = 0, y = 0, w = 38, h = 30 },
topCenter = { x = 38, y = 0, w = 53, h = 30 },
topRight = { x = 91, y = 0, w = 22, h = 30 },
middleLeft = { x = 0, y = 30, w = 38, h = 5 },
middleCenter = { x = 38, y = 30, w = 53, h = 5 },
middleRight = { x = 91, y = 30, w = 22, h = 5 },
bottomLeft = { x = 0, y = 35, w = 38, h = 30 },
bottomCenter = { x = 38, y = 35, w = 53, h = 30 },
bottomRight = { x = 91, y = 35, w = 22, h = 30 },
},
stretch = {
horizontal = { "topCenter", "middleCenter", "bottomCenter" },
vertical = { "middleLeft", "middleCenter", "middleRight" },
},
insets = { left = 38, top = 30, right = 22, bottom = 30 },
},
button = {
atlas = "themes/space/button.png",
regions = {
topLeft = { x = 0, y = 0, w = 14, h = 14 },
topCenter = { x = 14, y = 0, w = 86, h = 14 },
topRight = { x = 100, y = 0, w = 14, h = 14 },
middleLeft = { x = 0, y = 14, w = 14, h = 10 },
middleCenter = { x = 14, y = 14, w = 86, h = 10 },
middleRight = { x = 100, y = 14, w = 14, h = 10 },
bottomLeft = { x = 0, y = 24, w = 14, h = 14 },
bottomCenter = { x = 14, y = 24, w = 86, h = 14 },
bottomRight = { x = 100, y = 24, w = 14, h = 14 },
},
stretch = {
horizontal = { "topCenter", "middleCenter", "bottomCenter" },
vertical = { "middleLeft", "middleCenter", "middleRight" },
},
insets = { left = 14, top = 14, right = 14, bottom = 14 },
states = {
hover = {
atlas = "themes/space/button-hover.png",
regions = {
topLeft = { x = 0, y = 0, w = 14, h = 14 },
topCenter = { x = 14, y = 0, w = 86, h = 14 },
topRight = { x = 100, y = 0, w = 14, h = 14 },
middleLeft = { x = 0, y = 14, w = 14, h = 10 },
middleCenter = { x = 14, y = 14, w = 86, h = 10 },
middleRight = { x = 100, y = 14, w = 14, h = 10 },
bottomLeft = { x = 0, y = 24, w = 14, h = 14 },
bottomCenter = { x = 14, y = 24, w = 86, h = 14 },
bottomRight = { x = 100, y = 24, w = 14, h = 14 },
},
stretch = {
horizontal = { "topCenter", "middleCenter", "bottomCenter" },
vertical = { "middleLeft", "middleCenter", "middleRight" },
},
insets = { left = 14, top = 14, right = 14, bottom = 14 },
},
pressed = {
atlas = "themes/space/button-pressed.png",
regions = {
topLeft = { x = 0, y = 0, w = 14, h = 14 },
topCenter = { x = 14, y = 0, w = 86, h = 14 },
topRight = { x = 100, y = 0, w = 14, h = 14 },
middleLeft = { x = 0, y = 14, w = 14, h = 10 },
middleCenter = { x = 14, y = 14, w = 86, h = 10 },
middleRight = { x = 100, y = 14, w = 14, h = 10 },
bottomLeft = { x = 0, y = 24, w = 14, h = 14 },
bottomCenter = { x = 14, y = 24, w = 86, h = 14 },
bottomRight = { x = 100, y = 24, w = 14, h = 14 },
},
stretch = {
horizontal = { "topCenter", "middleCenter", "bottomCenter" },
vertical = { "middleLeft", "middleCenter", "middleRight" },
},
insets = { left = 14, top = 14, right = 14, bottom = 14 },
},
disabled = {
atlas = "themes/space/button-disabled.png",
regions = {
topLeft = { x = 0, y = 0, w = 14, h = 14 },
topCenter = { x = 14, y = 0, w = 86, h = 14 },
topRight = { x = 100, y = 0, w = 14, h = 14 },
middleLeft = { x = 0, y = 14, w = 14, h = 10 },
middleCenter = { x = 14, y = 14, w = 86, h = 10 },
middleRight = { x = 100, y = 14, w = 14, h = 10 },
bottomLeft = { x = 0, y = 24, w = 14, h = 14 },
bottomCenter = { x = 14, y = 24, w = 86, h = 14 },
bottomRight = { x = 100, y = 24, w = 14, h = 14 },
},
stretch = {
horizontal = { "topCenter", "middleCenter", "bottomCenter" },
vertical = { "middleLeft", "middleCenter", "middleRight" },
},
insets = { left = 14, top = 14, right = 14, bottom = 14 },
},
},
},