some bugs, but getting there

This commit is contained in:
Michael Freno
2025-10-16 00:00:09 -04:00
parent e6fa67801d
commit 08c4184166
3 changed files with 146 additions and 51 deletions

View File

@@ -296,13 +296,20 @@ function NinePatchParser.parse(imagePath)
return nil, "No stretch regions found (top or left border has no black pixels)" return nil, "No stretch regions found (top or left border has no black pixels)"
end end
-- Calculate insets from stretch regions -- Calculate stretch insets from stretch regions (top/left guides)
-- Use the first stretch region's start and last stretch region's end -- Use the first stretch region's start and last stretch region's end
local firstStretchX = stretchX[1] local firstStretchX = stretchX[1]
local lastStretchX = stretchX[#stretchX] local lastStretchX = stretchX[#stretchX]
local firstStretchY = stretchY[1] local firstStretchY = stretchY[1]
local lastStretchY = stretchY[#stretchY] local lastStretchY = stretchY[#stretchY]
-- Stretch insets define the 9-slice regions
local stretchLeft = firstStretchX.start
local stretchRight = #topStretchPixels - lastStretchX["end"]
local stretchTop = firstStretchY.start
local stretchBottom = #leftStretchPixels - lastStretchY["end"]
-- Calculate content padding from content guides (bottom/right guides)
-- If content padding is defined, use it; otherwise use stretch regions -- If content padding is defined, use it; otherwise use stretch regions
local contentLeft, contentRight, contentTop, contentBottom local contentLeft, contentRight, contentTop, contentBottom
@@ -310,20 +317,26 @@ function NinePatchParser.parse(imagePath)
contentLeft = contentX[1].start contentLeft = contentX[1].start
contentRight = #topStretchPixels - contentX[#contentX]["end"] contentRight = #topStretchPixels - contentX[#contentX]["end"]
else else
contentLeft = firstStretchX.start contentLeft = stretchLeft
contentRight = #topStretchPixels - lastStretchX["end"] contentRight = stretchRight
end end
if #contentY > 0 then if #contentY > 0 then
contentTop = contentY[1].start contentTop = contentY[1].start
contentBottom = #leftStretchPixels - contentY[#contentY]["end"] contentBottom = #leftStretchPixels - contentY[#contentY]["end"]
else else
contentTop = firstStretchY.start contentTop = stretchTop
contentBottom = #leftStretchPixels - lastStretchY["end"] contentBottom = stretchBottom
end end
return { return {
insets = { insets = {
left = stretchLeft,
top = stretchTop,
right = stretchRight,
bottom = stretchBottom,
},
contentPadding = {
left = contentLeft, left = contentLeft,
top = contentTop, top = contentTop,
right = contentRight, right = contentRight,
@@ -489,11 +502,11 @@ end
---@field stretch {horizontal:table<integer, string>, vertical:table<integer, string>} ---@field stretch {horizontal:table<integer, string>, vertical:table<integer, string>}
---@field states table<string, ThemeComponent>? ---@field states table<string, ThemeComponent>?
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: multiplier for auto-sized content dimensions ---@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 scaleCorners number? -- Optional: scale multiplier for non-stretched regions (corners/edges). E.g., 2 = 2x size. Default: nil (no scaling)
---@field scalingAlgorithm "nearest"|"bilinear"? -- Optional: scaling algorithm for non-stretched regions. Default: "bilinear" ---@field scalingAlgorithm "nearest"|"bilinear"? -- Optional: scaling algorithm for non-stretched regions. Default: "bilinear"
---@field _loadedAtlas string|love.Image? -- Internal: cached loaded atlas image ---@field _loadedAtlas string|love.Image? -- Internal: cached loaded atlas image
---@field _loadedAtlasData love.ImageData? -- Internal: cached loaded atlas ImageData for pixel access ---@field _loadedAtlasData love.ImageData? -- Internal: cached loaded atlas ImageData for pixel access
---@field _ninePatchData {insets:table, stretchX:table, stretchY:table}? -- Internal: parsed 9-patch data with multiple stretch regions ---@field _ninePatchData {insets:table, contentPadding:table, stretchX:table, stretchY:table}? -- Internal: parsed 9-patch data with stretch regions and content padding
---@field _scaledRegionCache table<string, love.Image>? -- Internal: cache for scaled corner/edge images ---@field _scaledRegionCache table<string, love.Image>? -- Internal: cache for scaled corner/edge images
---@class FontFamily ---@class FontFamily
@@ -669,6 +682,35 @@ function Theme.new(definition)
self.fonts = definition.fonts or {} self.fonts = definition.fonts or {}
self.contentAutoSizingMultiplier = definition.contentAutoSizingMultiplier or nil self.contentAutoSizingMultiplier = definition.contentAutoSizingMultiplier or nil
-- Helper function to strip 1-pixel guide border from 9-patch ImageData
---@param sourceImageData love.ImageData
---@return love.ImageData -- New ImageData without guide border
local function stripNinePatchBorder(sourceImageData)
local srcWidth = sourceImageData:getWidth()
local srcHeight = sourceImageData:getHeight()
-- Content dimensions (excluding 1px border on all sides)
local contentWidth = srcWidth - 2
local contentHeight = srcHeight - 2
if contentWidth <= 0 or contentHeight <= 0 then
error(formatError("NinePatch", "Image too small to strip border"))
end
-- Create new ImageData for content only
local strippedImageData = love.image.newImageData(contentWidth, contentHeight)
-- Copy pixels from source (1,1) to (width-2, height-2)
for y = 0, contentHeight - 1 do
for x = 0, contentWidth - 1 do
local r, g, b, a = sourceImageData:getPixel(x + 1, y + 1)
strippedImageData:setPixel(x, y, r, g, b, a)
end
end
return strippedImageData
end
-- Helper function to load atlas with 9-patch support -- Helper function to load atlas with 9-patch support
local function loadAtlasWithNinePatch(comp, atlasPath, errorContext) local function loadAtlasWithNinePatch(comp, atlasPath, errorContext)
---@diagnostic disable-next-line ---@diagnostic disable-next-line
@@ -688,8 +730,16 @@ function Theme.new(definition)
local image, imageData, loaderr = safeLoadImage(resolvedPath) local image, imageData, loaderr = safeLoadImage(resolvedPath)
if image then if image then
comp._loadedAtlas = image -- Strip guide border for 9-patch images
comp._loadedAtlasData = imageData if is9Patch and imageData then
local strippedImageData = stripNinePatchBorder(imageData)
local strippedImage = love.graphics.newImage(strippedImageData)
comp._loadedAtlas = strippedImage
comp._loadedAtlasData = strippedImageData
else
comp._loadedAtlas = image
comp._loadedAtlasData = imageData
end
else else
print("[FlexLove] Warning: Failed to load atlas " .. errorContext .. ": " .. tostring(loaderr)) print("[FlexLove] Warning: Failed to load atlas " .. errorContext .. ": " .. tostring(loaderr))
end end
@@ -708,24 +758,20 @@ function Theme.new(definition)
local right = comp.insets.right or 0 local right = comp.insets.right or 0
local bottom = comp.insets.bottom or 0 local bottom = comp.insets.bottom or 0
local is9Patch = comp._ninePatchData ~= nil -- No offsets needed - guide border has been stripped for 9-patch images
local offsetX = is9Patch and 1 or 0 local centerWidth = imgWidth - left - right
local offsetY = is9Patch and 1 or 0 local centerHeight = imgHeight - top - bottom
local borderSize = is9Patch and 2 or 0
local centerWidth = imgWidth - left - right - borderSize
local centerHeight = imgHeight - top - bottom - borderSize
comp.regions = { comp.regions = {
topLeft = { x = offsetX, y = offsetY, w = left, h = top }, topLeft = { x = 0, y = 0, w = left, h = top },
topCenter = { x = left + offsetX, y = offsetY, w = centerWidth, h = top }, topCenter = { x = left, y = 0, w = centerWidth, h = top },
topRight = { x = left + centerWidth + offsetX, y = offsetY, w = right, h = top }, topRight = { x = left + centerWidth, y = 0, w = right, h = top },
middleLeft = { x = offsetX, y = top + offsetY, w = left, h = centerHeight }, middleLeft = { x = 0, y = top, w = left, h = centerHeight },
middleCenter = { x = left + offsetX, y = top + offsetY, w = centerWidth, h = centerHeight }, middleCenter = { x = left, y = top, w = centerWidth, h = centerHeight },
middleRight = { x = left + centerWidth + offsetX, y = top + offsetY, w = right, h = centerHeight }, middleRight = { x = left + centerWidth, y = top, w = right, h = centerHeight },
bottomLeft = { x = offsetX, y = top + centerHeight + offsetY, w = left, h = bottom }, bottomLeft = { x = 0, y = top + centerHeight, w = left, h = bottom },
bottomCenter = { x = left + offsetX, y = top + centerHeight + offsetY, w = centerWidth, h = bottom }, bottomCenter = { x = left, y = top + centerHeight, w = centerWidth, h = bottom },
bottomRight = { x = left + centerWidth + offsetX, y = top + centerHeight + offsetY, w = right, h = bottom }, bottomRight = { x = left + centerWidth, y = top + centerHeight, w = right, h = bottom },
} }
end end
@@ -1025,7 +1071,7 @@ end
local NineSlice = {} local NineSlice = {}
--- Draw a 9-patch component using Android-style rendering --- Draw a 9-patch component using Android-style rendering
--- Corners are never scaled (1:1 pixels), edges stretch in one dimension only --- Corners are scaled by scaleCorners multiplier, edges stretch in one dimension only
---@param component ThemeComponent ---@param component ThemeComponent
---@param atlas love.Image ---@param atlas love.Image
---@param x number -- X position (top-left corner) ---@param x number -- X position (top-left corner)
@@ -1033,7 +1079,7 @@ local NineSlice = {}
---@param width number -- Total width (border-box) ---@param width number -- Total width (border-box)
---@param height number -- Total height (border-box) ---@param height number -- Total height (border-box)
---@param opacity number? ---@param opacity number?
---@param elementScaleCorners boolean? -- Element-level override for scaleCorners ---@param elementScaleCorners number? -- Element-level override for scaleCorners (scale multiplier)
---@param elementScalingAlgorithm "nearest"|"bilinear"? -- Element-level override for scalingAlgorithm ---@param elementScalingAlgorithm "nearest"|"bilinear"? -- Element-level override for scalingAlgorithm
function NineSlice.draw(component, atlas, x, y, width, height, opacity, elementScaleCorners, elementScalingAlgorithm) function NineSlice.draw(component, atlas, x, y, width, height, opacity, elementScaleCorners, elementScalingAlgorithm)
if not component or not atlas then if not component or not atlas then
@@ -1072,11 +1118,11 @@ function NineSlice.draw(component, atlas, x, y, width, height, opacity, elementS
return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight) return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight)
end end
-- Check if corner scaling is enabled -- Get corner scale multiplier
-- Priority: element-level override > component setting > default (false) -- Priority: element-level override > component setting > default (nil = no scaling)
local scaleCorners = elementScaleCorners local scaleCorners = elementScaleCorners
if scaleCorners == nil then if scaleCorners == nil then
scaleCorners = component.scaleCorners or false scaleCorners = component.scaleCorners
end end
-- Priority: element-level override > component setting > default ("bilinear") -- Priority: element-level override > component setting > default ("bilinear")
@@ -1085,16 +1131,14 @@ function NineSlice.draw(component, atlas, x, y, width, height, opacity, elementS
scalingAlgorithm = component.scalingAlgorithm or "bilinear" scalingAlgorithm = component.scalingAlgorithm or "bilinear"
end end
if scaleCorners and Gui and Gui.scaleFactors then if scaleCorners and type(scaleCorners) == "number" and scaleCorners > 0 then
-- Initialize cache if needed -- Initialize cache if needed
if not component._scaledRegionCache then if not component._scaledRegionCache then
component._scaledRegionCache = {} component._scaledRegionCache = {}
end end
-- Get current scale factors -- Use the numeric scale multiplier directly
local scaleFactorX = Gui.scaleFactors.x or 1 local scaleFactor = scaleCorners
local scaleFactorY = Gui.scaleFactors.y or 1
local scaleFactor = math.max(scaleFactorX, scaleFactorY)
-- Helper to get or create scaled region -- Helper to get or create scaled region
local function getScaledRegion(regionName, region, targetWidth, targetHeight) local function getScaledRegion(regionName, region, targetWidth, targetHeight)
@@ -2192,7 +2236,7 @@ Public API methods to access internal state:
---@field active boolean? -- Whether the element is active/focused (for inputs, default: false) ---@field active boolean? -- Whether the element is active/focused (for inputs, default: false)
---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false) ---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false)
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions ---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions
---@field scaleCorners boolean? -- Whether to scale 9-slice corners/edges with window size (overrides theme setting) ---@field scaleCorners number? -- Scale multiplier for 9-slice corners/edges. E.g., 2 = 2x size (overrides theme setting)
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-slice corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting) ---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-slice corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting)
local Element = {} local Element = {}
Element.__index = Element Element.__index = Element
@@ -2247,7 +2291,7 @@ Element.__index = Element
---@field active boolean? -- Whether the element is active/focused (for inputs, default: false) ---@field active boolean? -- Whether the element is active/focused (for inputs, default: false)
---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false) ---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false)
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions (default: sourced from theme) ---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions (default: sourced from theme)
---@field scaleCorners boolean? -- Whether to scale 9-slice corners/edges with window size (overrides theme setting) ---@field scaleCorners number? -- Scale multiplier for 9-slice corners/edges. E.g., 2 = 2x size (overrides theme setting)
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-slice corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting) ---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-slice corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting)
local ElementProps = {} local ElementProps = {}
@@ -2306,16 +2350,15 @@ function Element.new(props)
-- Fall back to theme default -- Fall back to theme default
self.contentAutoSizingMultiplier = themeToUse.contentAutoSizingMultiplier self.contentAutoSizingMultiplier = themeToUse.contentAutoSizingMultiplier
else else
self.contentAutoSizingMultiplier = nil self.contentAutoSizingMultiplier = { 1, 1 }
end end
elseif themeToUse.contentAutoSizingMultiplier then elseif themeToUse.contentAutoSizingMultiplier then
-- No themeComponent, use theme default
self.contentAutoSizingMultiplier = themeToUse.contentAutoSizingMultiplier self.contentAutoSizingMultiplier = themeToUse.contentAutoSizingMultiplier
else else
self.contentAutoSizingMultiplier = nil self.contentAutoSizingMultiplier = { 1, 1 }
end end
else else
self.contentAutoSizingMultiplier = nil self.contentAutoSizingMultiplier = { 1, 1 }
end end
end end
@@ -2616,6 +2659,37 @@ function Element.new(props)
-- Re-resolve padding based on final border-box dimensions (important for percentage padding) -- Re-resolve padding based on final border-box dimensions (important for percentage padding)
self.padding = Units.resolveSpacing(props.padding, self._borderBoxWidth, self._borderBoxHeight) self.padding = Units.resolveSpacing(props.padding, self._borderBoxWidth, self._borderBoxHeight)
-- Apply 9-patch content padding if using a themeComponent with 9-patch data
-- This overrides the padding to match the content area defined by the 9-patch guides
if self.themeComponent then
local themeToUse = self.theme and themes[self.theme] or Theme.getActive()
if themeToUse and themeToUse.components[self.themeComponent] then
local component = themeToUse.components[self.themeComponent]
if component._ninePatchData and component._ninePatchData.contentPadding then
local contentPadding = component._ninePatchData.contentPadding
-- Only override if no explicit padding was provided
if
not props.padding
or (
not props.padding.top
and not props.padding.right
and not props.padding.bottom
and not props.padding.left
and not props.padding.horizontal
and not props.padding.vertical
)
then
self.padding = {
left = contentPadding.left,
top = contentPadding.top,
right = contentPadding.right,
bottom = contentPadding.bottom,
}
end
end
end
end
-- Calculate final content dimensions by subtracting padding from border-box -- Calculate final content dimensions by subtracting padding from border-box
self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right) self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right)
self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom) self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom)

View File

@@ -254,11 +254,25 @@ FlexLove automatically parses Android 9-patch (*.9.png) files:
**9-Patch Format:** **9-Patch Format:**
- Files ending in `.9.png` are automatically detected and parsed - Files ending in `.9.png` are automatically detected and parsed
- **Guide pixels are automatically removed** - the 1px border is stripped during loading
- Top/left borders define stretchable regions (black pixels) - Top/left borders define stretchable regions (black pixels)
- Bottom/right borders define content padding (optional) - Bottom/right borders define content padding (optional) - **automatically applied to child positioning**
- Supports multiple non-contiguous stretch regions - Supports multiple non-contiguous stretch regions
- Manual insets override auto-parsing when specified - Manual insets override auto-parsing when specified
**Scaling Corners:**
```lua
{
button = {
atlas = "themes/mytheme/button.9.png",
scaleCorners = 2 -- Scale corners by 2x (number = direct multiplier)
}
}
```
- `scaleCorners` accepts a number (e.g., 2 = 2x size, 0.5 = half size)
- Default: `nil` (no scaling, 1:1 pixel perfect)
- Corners scale uniformly while edges stretch as defined by guides
Themes support state-based rendering: Themes support state-based rendering:
- `normal` - Default state - `normal` - Default state
- `hover` - Mouse over element - `hover` - Mouse over element

View File

@@ -349,9 +349,9 @@ Gui.new({
## Corner Scaling ## 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: By default, 9-slice corners and non-stretched edges are rendered at their original pixel size (1:1). You can scale corners using a numeric multiplier:
### Enabling Corner Scaling ### Corner Scaling
```lua ```lua
-- themes/my_theme.lua -- themes/my_theme.lua
@@ -361,13 +361,20 @@ return {
button = { button = {
atlas = "themes/button.png", atlas = "themes/button.png",
insets = { left = 8, top = 8, right = 8, bottom = 8 }, insets = { left = 8, top = 8, right = 8, bottom = 8 },
scaleCorners = true, -- Enable corner scaling scaleCorners = 2, -- Scale corners by 2x (numeric multiplier)
scalingAlgorithm = "bilinear" -- "bilinear" (smooth) or "nearest" (sharp/pixelated) scalingAlgorithm = "bilinear" -- "bilinear" (smooth) or "nearest" (sharp/pixelated)
} }
} }
} }
``` ```
**`scaleCorners` values:**
- Number (e.g., `2`, `1.5`, `0.5`) - Direct scale multiplier
- `2` = double size
- `0.5` = half size
- `1` = original size
- `nil` (default) - No scaling, 1:1 pixel perfect
### Scaling Algorithms ### Scaling Algorithms
- **`bilinear`** (default): Smooth interpolation between pixels. Best for most use cases. - **`bilinear`** (default): Smooth interpolation between pixels. Best for most use cases.
@@ -375,9 +382,9 @@ return {
### When to Use Corner Scaling ### When to Use Corner Scaling
- **Pixel art themes**: Use `scaleCorners = true` with `scalingAlgorithm = "nearest"` to maintain crisp pixel boundaries - **Pixel art themes**: Use `scaleCorners = 2` with `scalingAlgorithm = "nearest"` to maintain crisp pixel boundaries
- **High DPI displays**: Use `scaleCorners = true` with `scalingAlgorithm = "bilinear"` for smooth scaling - **High DPI displays**: Use `scaleCorners = 1.5` or `2` with `scalingAlgorithm = "bilinear"` for smooth scaling
- **Fixed-size UI**: Keep `scaleCorners = false` (default) for pixel-perfect rendering at original size - **Fixed-size UI**: Keep `scaleCorners = nil` (default) for pixel-perfect rendering at original size
### Per-State Scaling ### Per-State Scaling
@@ -386,13 +393,13 @@ You can also set scaling per-state:
```lua ```lua
button = { button = {
atlas = "themes/button_normal.png", atlas = "themes/button_normal.png",
scaleCorners = true, scaleCorners = 2,
scalingAlgorithm = "bilinear", scalingAlgorithm = "bilinear",
states = { states = {
hover = { hover = {
atlas = "themes/button_hover.png", atlas = "themes/button_hover.png",
scaleCorners = true, -- Can override per state scaleCorners = 2.5, -- Can override per state
scalingAlgorithm = "nearest" -- Different algorithm for this state scalingAlgorithm = "nearest" -- Different algorithm for this state
} }
} }
} }