diff --git a/FlexLove.lua b/FlexLove.lua index 2aa5d15..d76d97f 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -296,13 +296,20 @@ function NinePatchParser.parse(imagePath) return nil, "No stretch regions found (top or left border has no black pixels)" 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 local firstStretchX = stretchX[1] local lastStretchX = stretchX[#stretchX] local firstStretchY = stretchY[1] 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 local contentLeft, contentRight, contentTop, contentBottom @@ -310,20 +317,26 @@ function NinePatchParser.parse(imagePath) contentLeft = contentX[1].start contentRight = #topStretchPixels - contentX[#contentX]["end"] else - contentLeft = firstStretchX.start - contentRight = #topStretchPixels - lastStretchX["end"] + contentLeft = stretchLeft + contentRight = stretchRight end if #contentY > 0 then contentTop = contentY[1].start contentBottom = #leftStretchPixels - contentY[#contentY]["end"] else - contentTop = firstStretchY.start - contentBottom = #leftStretchPixels - lastStretchY["end"] + contentTop = stretchTop + contentBottom = stretchBottom end return { insets = { + left = stretchLeft, + top = stretchTop, + right = stretchRight, + bottom = stretchBottom, + }, + contentPadding = { left = contentLeft, top = contentTop, right = contentRight, @@ -489,11 +502,11 @@ end ---@field stretch {horizontal:table, vertical:table} ---@field states table? ---@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 _loadedAtlas string|love.Image? -- Internal: cached loaded atlas image ---@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? -- Internal: cache for scaled corner/edge images ---@class FontFamily @@ -669,6 +682,35 @@ function Theme.new(definition) self.fonts = definition.fonts or {} 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 local function loadAtlasWithNinePatch(comp, atlasPath, errorContext) ---@diagnostic disable-next-line @@ -688,8 +730,16 @@ function Theme.new(definition) local image, imageData, loaderr = safeLoadImage(resolvedPath) if image then - comp._loadedAtlas = image - comp._loadedAtlasData = imageData + -- Strip guide border for 9-patch images + 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 print("[FlexLove] Warning: Failed to load atlas " .. errorContext .. ": " .. tostring(loaderr)) end @@ -708,24 +758,20 @@ function Theme.new(definition) local right = comp.insets.right or 0 local bottom = comp.insets.bottom or 0 - local is9Patch = comp._ninePatchData ~= nil - local offsetX = is9Patch and 1 or 0 - local offsetY = is9Patch and 1 or 0 - local borderSize = is9Patch and 2 or 0 - - local centerWidth = imgWidth - left - right - borderSize - local centerHeight = imgHeight - top - bottom - borderSize + -- No offsets needed - guide border has been stripped for 9-patch images + local centerWidth = imgWidth - left - right + local centerHeight = imgHeight - top - bottom comp.regions = { - topLeft = { x = offsetX, y = offsetY, w = left, h = top }, - topCenter = { x = left + offsetX, y = offsetY, w = centerWidth, h = top }, - topRight = { x = left + centerWidth + offsetX, y = offsetY, w = right, h = top }, - middleLeft = { x = offsetX, y = top + offsetY, w = left, h = centerHeight }, - middleCenter = { x = left + offsetX, y = top + offsetY, w = centerWidth, h = centerHeight }, - middleRight = { x = left + centerWidth + offsetX, y = top + offsetY, w = right, h = centerHeight }, - bottomLeft = { x = offsetX, y = top + centerHeight + offsetY, w = left, h = bottom }, - bottomCenter = { x = left + offsetX, y = top + centerHeight + offsetY, w = centerWidth, h = bottom }, - bottomRight = { x = left + centerWidth + offsetX, y = top + centerHeight + offsetY, w = right, h = bottom }, + 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 @@ -1025,7 +1071,7 @@ end local NineSlice = {} --- 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 atlas love.Image ---@param x number -- X position (top-left corner) @@ -1033,7 +1079,7 @@ local NineSlice = {} ---@param width number -- Total width (border-box) ---@param height number -- Total height (border-box) ---@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 function NineSlice.draw(component, atlas, x, y, width, height, opacity, elementScaleCorners, elementScalingAlgorithm) 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) end - -- Check if corner scaling is enabled - -- Priority: element-level override > component setting > default (false) + -- Get corner scale multiplier + -- Priority: element-level override > component setting > default (nil = no scaling) local scaleCorners = elementScaleCorners if scaleCorners == nil then - scaleCorners = component.scaleCorners or false + scaleCorners = component.scaleCorners end -- 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" end - if scaleCorners and Gui and Gui.scaleFactors then + if scaleCorners and type(scaleCorners) == "number" and scaleCorners > 0 then -- Initialize cache if needed if not component._scaledRegionCache then component._scaledRegionCache = {} end - -- Get current scale factors - local scaleFactorX = Gui.scaleFactors.x or 1 - local scaleFactorY = Gui.scaleFactors.y or 1 - local scaleFactor = math.max(scaleFactorX, scaleFactorY) + -- Use the numeric scale multiplier directly + local scaleFactor = scaleCorners -- Helper to get or create scaled region 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 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 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) local 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 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 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) local ElementProps = {} @@ -2306,16 +2350,15 @@ function Element.new(props) -- Fall back to theme default self.contentAutoSizingMultiplier = themeToUse.contentAutoSizingMultiplier else - self.contentAutoSizingMultiplier = nil + self.contentAutoSizingMultiplier = { 1, 1 } end elseif themeToUse.contentAutoSizingMultiplier then - -- No themeComponent, use theme default self.contentAutoSizingMultiplier = themeToUse.contentAutoSizingMultiplier else - self.contentAutoSizingMultiplier = nil + self.contentAutoSizingMultiplier = { 1, 1 } end else - self.contentAutoSizingMultiplier = nil + self.contentAutoSizingMultiplier = { 1, 1 } end end @@ -2616,6 +2659,37 @@ function Element.new(props) -- Re-resolve padding based on final border-box dimensions (important for percentage padding) 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 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) diff --git a/README.md b/README.md index c31d282..928ba1f 100644 --- a/README.md +++ b/README.md @@ -254,11 +254,25 @@ FlexLove automatically parses Android 9-patch (*.9.png) files: **9-Patch Format:** - 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) -- 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 - 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: - `normal` - Default state - `hover` - Mouse over element diff --git a/themes/README.md b/themes/README.md index f20782a..f2246a9 100644 --- a/themes/README.md +++ b/themes/README.md @@ -349,9 +349,9 @@ 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: +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 -- themes/my_theme.lua @@ -361,13 +361,20 @@ return { button = { atlas = "themes/button.png", 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) } } } ``` +**`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 - **`bilinear`** (default): Smooth interpolation between pixels. Best for most use cases. @@ -375,9 +382,9 @@ return { ### 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 +- **Pixel art themes**: Use `scaleCorners = 2` with `scalingAlgorithm = "nearest"` to maintain crisp pixel boundaries +- **High DPI displays**: Use `scaleCorners = 1.5` or `2` with `scalingAlgorithm = "bilinear"` for smooth scaling +- **Fixed-size UI**: Keep `scaleCorners = nil` (default) for pixel-perfect rendering at original size ### Per-State Scaling @@ -386,13 +393,13 @@ You can also set scaling per-state: ```lua button = { atlas = "themes/button_normal.png", - scaleCorners = true, + scaleCorners = 2, scalingAlgorithm = "bilinear", states = { hover = { atlas = "themes/button_hover.png", - scaleCorners = true, -- Can override per state - scalingAlgorithm = "nearest" -- Different algorithm for this state + scaleCorners = 2.5, -- Can override per state + scalingAlgorithm = "nearest" -- Different algorithm for this state } } }