diff --git a/.gitignore b/.gitignore index d21b2e0..5fe651a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ Cartographer.lua OverlayStats.lua +.DS_STORE diff --git a/FlexLove.lua b/FlexLove.lua index 0882d81..381e271 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -48,6 +48,274 @@ function Color.fromHex(hexWithTag) end end +-- ==================== +-- Theme System +-- ==================== + +---@class ThemeRegion +---@field x number -- X position in atlas +---@field y number -- Y position in atlas +---@field w number -- Width in atlas +---@field h number -- Height in atlas + +---@class ThemeComponent +---@field atlas string|love.Image? -- Optional: component-specific atlas (overrides theme atlas) +---@field regions {topLeft:ThemeRegion, topCenter:ThemeRegion, topRight:ThemeRegion, middleLeft:ThemeRegion, middleCenter:ThemeRegion, middleRight:ThemeRegion, bottomLeft:ThemeRegion, bottomCenter:ThemeRegion, bottomRight:ThemeRegion} +---@field stretch {horizontal:table, vertical:table} +---@field states table? +---@field _loadedAtlas love.Image? -- Internal: cached loaded atlas image + +---@class ThemeDefinition +---@field name string +---@field atlas string|love.Image? -- Optional: global atlas (can be overridden per component) +---@field components table +---@field colors table? + +---@class Theme +---@field name string +---@field atlas love.Image? -- Optional: global atlas +---@field components table +---@field colors table +local Theme = {} +Theme.__index = Theme + +-- Global theme registry +local themes = {} +local activeTheme = nil + +--- Create a new theme instance +---@param definition ThemeDefinition +---@return Theme +function Theme.new(definition) + local self = setmetatable({}, Theme) + self.name = definition.name + + -- Load global atlas if it's a string path + if definition.atlas then + if type(definition.atlas) == "string" then + self.atlas = love.graphics.newImage(definition.atlas) + else + self.atlas = definition.atlas + end + end + + self.components = definition.components or {} + self.colors = definition.colors or {} + + -- Load component-specific atlases + for componentName, component in pairs(self.components) do + if component.atlas then + if type(component.atlas) == "string" then + component._loadedAtlas = love.graphics.newImage(component.atlas) + else + component._loadedAtlas = component.atlas + end + end + + -- Also load atlases for component states + if component.states then + for stateName, stateComponent in pairs(component.states) do + if stateComponent.atlas then + if type(stateComponent.atlas) == "string" then + stateComponent._loadedAtlas = love.graphics.newImage(stateComponent.atlas) + else + stateComponent._loadedAtlas = stateComponent.atlas + end + end + end + end + end + + return self +end + +--- Load a theme from a Lua file +---@param path string -- Path to theme definition file +---@return Theme +function Theme.load(path) + -- Check if it's a built-in theme + local builtInPath = "themes/" .. path .. ".lua" + local definition + + -- Try to load as built-in first + local success, result = pcall(function() + return love.filesystem.load(builtInPath)() + end) + + if success then + definition = result + else + -- Try to load as custom path + success, result = pcall(function() + return love.filesystem.load(path)() + end) + + if success then + definition = result + else + error("Failed to load theme from: " .. path .. "\nError: " .. tostring(result)) + end + end + + local theme = Theme.new(definition) + themes[theme.name] = theme + + return theme +end + +--- Set the active theme +---@param themeOrName Theme|string +function Theme.setActive(themeOrName) + if type(themeOrName) == "string" then + -- Try to load if not already loaded + if not themes[themeOrName] then + Theme.load(themeOrName) + end + activeTheme = themes[themeOrName] + else + activeTheme = themeOrName + end + + if not activeTheme then + error("Failed to set active theme: " .. tostring(themeOrName)) + end +end + +--- Get the active theme +---@return Theme? +function Theme.getActive() + return activeTheme +end + +--- Get a component from the active theme +---@param componentName string +---@param state string? +---@return ThemeComponent? +function Theme.getComponent(componentName, state) + if not activeTheme then + return nil + end + + local component = activeTheme.components[componentName] + if not component then + return nil + end + + -- Check for state-specific override + if state and component.states and component.states[state] then + return component.states[state] + end + + return component +end + +-- ==================== +-- NineSlice Renderer +-- ==================== + +local NineSlice = {} + +--- Draw a 9-slice component +---@param component ThemeComponent +---@param atlas love.Image +---@param x number +---@param y number +---@param width number +---@param height number +---@param opacity number? +function NineSlice.draw(component, atlas, x, y, width, height, opacity) + if not component or not atlas then + return + end + + opacity = opacity or 1 + love.graphics.setColor(1, 1, 1, opacity) + + local regions = component.regions + + -- Calculate dimensions + local cornerWidth = regions.topLeft.w + local cornerHeight = regions.topLeft.h + local rightCornerWidth = regions.topRight.w + local rightCornerHeight = regions.topRight.h + local bottomLeftHeight = regions.bottomLeft.h + local bottomRightHeight = regions.bottomRight.h + + -- Center dimensions (stretchable area) + local centerWidth = width - cornerWidth - rightCornerWidth + local centerHeight = height - cornerHeight - bottomLeftHeight + + -- 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 + love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y) + + -- Top-right corner + love.graphics.draw(atlas, makeQuad(regions.topRight), x + width - rightCornerWidth, y) + + -- Bottom-left corner + love.graphics.draw(atlas, makeQuad(regions.bottomLeft), x, y + height - bottomLeftHeight) + + -- Bottom-right corner + love.graphics.draw(atlas, makeQuad(regions.bottomRight), x + width - rightCornerWidth, y + height - bottomRightHeight) + + -- Top edge (stretched) + if centerWidth > 0 then + local scaleX = centerWidth / regions.topCenter.w + love.graphics.draw(atlas, makeQuad(regions.topCenter), x + cornerWidth, y, 0, scaleX, 1) + end + + -- Bottom edge (stretched) + if centerWidth > 0 then + local scaleX = centerWidth / regions.bottomCenter.w + love.graphics.draw( + atlas, + makeQuad(regions.bottomCenter), + x + cornerWidth, + y + height - bottomLeftHeight, + 0, + scaleX, + 1 + ) + end + + -- Left edge (stretched) + if centerHeight > 0 then + local scaleY = centerHeight / regions.middleLeft.h + love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + cornerHeight, 0, 1, scaleY) + end + + -- Right edge (stretched) + if centerHeight > 0 then + local scaleY = centerHeight / regions.middleRight.h + love.graphics.draw( + atlas, + makeQuad(regions.middleRight), + x + width - rightCornerWidth, + y + cornerHeight, + 0, + 1, + scaleY + ) + end + + -- Center (stretched both ways) + if centerWidth > 0 and centerHeight > 0 then + local scaleX = centerWidth / regions.middleCenter.w + local scaleY = centerHeight / regions.middleCenter.h + love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + cornerWidth, y + cornerHeight, 0, scaleX, scaleY) + end + + -- Reset color + love.graphics.setColor(1, 1, 1, 1) +end + local enums = { ---@enum TextAlign TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" }, @@ -521,7 +789,7 @@ local function getModifiers() shift = love.keyboard.isDown("lshift", "rshift"), ctrl = love.keyboard.isDown("lctrl", "rctrl"), alt = love.keyboard.isDown("lalt", "ralt"), - cmd = love.keyboard.isDown("lgui", "rgui") -- Mac Command key + cmd = love.keyboard.isDown("lgui", "rgui"), -- Mac Command key } end @@ -746,6 +1014,10 @@ end ---@field gridColumns number? -- Number of columns in the grid ---@field columnGap number|string? -- Gap between grid columns ---@field rowGap number|string? -- Gap between grid rows +---@field theme string|{component:string, state:string?}? -- Theme component to use for rendering +---@field _themeState string? -- Current theme state (normal, hover, pressed, active, disabled) +---@field disabled boolean? -- Whether the element is disabled (default: false) +---@field active boolean? -- Whether the element is active/focused (for inputs, default: false) local Element = {} Element.__index = Element @@ -789,6 +1061,9 @@ Element.__index = Element ---@field gridColumns number? -- Number of columns in the grid (default: 1) ---@field columnGap number|string? -- Gap between grid columns ---@field rowGap number|string? -- Gap between grid rows +---@field theme string|{component:string, state:string?}? -- Theme component to use for rendering +---@field disabled boolean? -- Whether the element is disabled (default: false) +---@field active boolean? -- Whether the element is active/focused (for inputs, default: false) local ElementProps = {} ---@param props ElementProps @@ -806,6 +1081,14 @@ function Element.new(props) self._clickCount = 0 self._touchPressed = {} + -- Initialize theme + self.theme = props.theme + self._themeState = "normal" + + -- Initialize state properties + self.disabled = props.disabled or false + self.active = props.active or false + -- Set parent first so it's available for size calculations self.parent = props.parent @@ -1771,45 +2054,87 @@ function Element:draw() end end - -- Apply opacity to all drawing operations - -- (x, y) represents border box, so draw background from (x, y) - local backgroundWithOpacity = - Color.new(drawBackground.r, drawBackground.g, drawBackground.b, drawBackground.a * self.opacity) - love.graphics.setColor(backgroundWithOpacity:toRGBA()) - love.graphics.rectangle( - "fill", - self.x, - self.y, - self.width + self.padding.left + self.padding.right, - self.height + self.padding.top + self.padding.bottom - ) - -- Draw borders based on border property - local borderColorWithOpacity = - Color.new(self.borderColor.r, self.borderColor.g, self.borderColor.b, self.borderColor.a * self.opacity) - love.graphics.setColor(borderColorWithOpacity:toRGBA()) - if self.border.top then - love.graphics.line(self.x, self.y, self.x + self.width + self.padding.left + self.padding.right, self.y) + -- Check if element has a theme + local hasTheme = false + if self.theme then + local componentName, state + + if type(self.theme) == "string" then + componentName = self.theme + state = self._themeState + else + componentName = self.theme.component + state = self.theme.state or self._themeState + end + + local component = Theme.getComponent(componentName, state) + if component then + local activeTheme = Theme.getActive() + if activeTheme then + -- Use component-specific atlas if available, otherwise use theme atlas + local atlasToUse = component._loadedAtlas or activeTheme.atlas + + if atlasToUse then + NineSlice.draw( + component, + atlasToUse, + self.x, + self.y, + self.width + self.padding.left + self.padding.right, + self.height + self.padding.top + self.padding.bottom, + self.opacity + ) + hasTheme = true + end + end + end end - if self.border.bottom then - love.graphics.line( + + -- Draw background if no theme is used + if not hasTheme then + -- Apply opacity to all drawing operations + -- (x, y) represents border box, so draw background from (x, y) + local backgroundWithOpacity = + Color.new(drawBackground.r, drawBackground.g, drawBackground.b, drawBackground.a * self.opacity) + love.graphics.setColor(backgroundWithOpacity:toRGBA()) + love.graphics.rectangle( + "fill", self.x, - self.y + self.height + self.padding.top + self.padding.bottom, - self.x + self.width + self.padding.left + self.padding.right, - self.y + self.height + self.padding.top + self.padding.bottom - ) - end - if self.border.left then - love.graphics.line(self.x, self.y, self.x, self.y + self.height + self.padding.top + self.padding.bottom) - end - if self.border.right then - love.graphics.line( - self.x + self.width + self.padding.left + self.padding.right, self.y, - self.x + self.width + self.padding.left + self.padding.right, - self.y + self.height + self.padding.top + self.padding.bottom + self.width + self.padding.left + self.padding.right, + self.height + self.padding.top + self.padding.bottom ) end + -- Draw borders based on border property (skip if using theme) + if not hasTheme then + local borderColorWithOpacity = + Color.new(self.borderColor.r, self.borderColor.g, self.borderColor.b, self.borderColor.a * self.opacity) + love.graphics.setColor(borderColorWithOpacity:toRGBA()) + if self.border.top then + love.graphics.line(self.x, self.y, self.x + self.width + self.padding.left + self.padding.right, self.y) + end + if self.border.bottom then + love.graphics.line( + self.x, + self.y + self.height + self.padding.top + self.padding.bottom, + self.x + self.width + self.padding.left + self.padding.right, + self.y + self.height + self.padding.top + self.padding.bottom + ) + end + if self.border.left then + love.graphics.line(self.x, self.y, self.x, self.y + self.height + self.padding.top + self.padding.bottom) + end + if self.border.right then + love.graphics.line( + self.x + self.width + self.padding.left + self.padding.right, + self.y, + self.x + self.width + self.padding.left + self.padding.right, + self.y + self.height + self.padding.top + self.padding.bottom + ) + end + end + -- Draw element text if present if self.text then local textColorWithOpacity = @@ -1911,7 +2236,7 @@ function Element:update(dt) end -- Handle click detection for element with enhanced event system - if self.callback then + if self.callback or self.theme then local mx, my = love.mouse.getPosition() -- Clickable area is the border box (x, y already includes padding) local bx = self.x @@ -1919,104 +2244,139 @@ function Element:update(dt) local bw = self.width + self.padding.left + self.padding.right local bh = self.height + self.padding.top + self.padding.bottom local isHovering = mx >= bx and mx <= bx + bw and my >= by and my <= by + bh - - -- Check all three mouse buttons - local buttons = {1, 2, 3} -- left, right, middle - - for _, button in ipairs(buttons) do - if isHovering then - if love.mouse.isDown(button) then - -- Button is pressed down - if not self._pressed[button] then - -- Just pressed - fire press event + + -- Update theme state based on interaction + if self.theme then + -- Disabled state takes priority + if self.disabled then + self._themeState = "disabled" + -- Active state (for inputs when focused/typing) + elseif self.active then + self._themeState = "active" + elseif isHovering then + -- Check if any button is pressed + local anyPressed = false + for _, pressed in pairs(self._pressed) do + if pressed then + anyPressed = true + break + end + end + + if anyPressed then + self._themeState = "pressed" + else + self._themeState = "hover" + end + else + self._themeState = "normal" + end + end + + -- Only process button events if callback exists and element is not disabled + if self.callback and not self.disabled then + -- Check all three mouse buttons + local buttons = { 1, 2, 3 } -- left, right, middle + + for _, button in ipairs(buttons) do + if isHovering then + if love.mouse.isDown(button) then + -- Button is pressed down + if not self._pressed[button] then + -- Just pressed - fire press event + local modifiers = getModifiers() + local pressEvent = InputEvent.new({ + type = "press", + button = button, + x = mx, + y = my, + modifiers = modifiers, + clickCount = 1, + }) + self.callback(self, pressEvent) + self._pressed[button] = true + end + elseif self._pressed[button] then + -- Button was just released - fire click event + local currentTime = love.timer.getTime() local modifiers = getModifiers() - local pressEvent = InputEvent.new({ - type = "press", + + -- Determine click count (double-click detection) + local clickCount = 1 + local doubleClickThreshold = 0.3 -- 300ms for double-click + + if + self._lastClickTime + and self._lastClickButton == button + and (currentTime - self._lastClickTime) < doubleClickThreshold + then + clickCount = self._clickCount + 1 + else + clickCount = 1 + end + + self._clickCount = clickCount + self._lastClickTime = currentTime + self._lastClickButton = button + + -- Determine event type based on button + local eventType = "click" + if button == 2 then + eventType = "rightclick" + elseif button == 3 then + eventType = "middleclick" + end + + local clickEvent = InputEvent.new({ + type = eventType, button = button, x = mx, y = my, modifiers = modifiers, - clickCount = 1, + clickCount = clickCount, }) - self.callback(self, pressEvent) - self._pressed[button] = true + + self.callback(self, clickEvent) + self._pressed[button] = false + + -- Fire release event + local releaseEvent = InputEvent.new({ + type = "release", + button = button, + x = mx, + y = my, + modifiers = modifiers, + clickCount = clickCount, + }) + self.callback(self, releaseEvent) end - elseif self._pressed[button] then - -- Button was just released - fire click event - local currentTime = love.timer.getTime() - local modifiers = getModifiers() - - -- Determine click count (double-click detection) - local clickCount = 1 - local doubleClickThreshold = 0.3 -- 300ms for double-click - - if self._lastClickTime - and self._lastClickButton == button - and (currentTime - self._lastClickTime) < doubleClickThreshold then - clickCount = self._clickCount + 1 - else - clickCount = 1 - end - - self._clickCount = clickCount - self._lastClickTime = currentTime - self._lastClickButton = button - - -- Determine event type based on button - local eventType = "click" - if button == 2 then - eventType = "rightclick" - elseif button == 3 then - eventType = "middleclick" - end - - local clickEvent = InputEvent.new({ - type = eventType, - button = button, - x = mx, - y = my, - modifiers = modifiers, - clickCount = clickCount, - }) - - self.callback(self, clickEvent) + else + -- Mouse left the element - reset pressed state self._pressed[button] = false - - -- Fire release event - local releaseEvent = InputEvent.new({ - type = "release", - button = button, - x = mx, - y = my, - modifiers = modifiers, - clickCount = clickCount, - }) - self.callback(self, releaseEvent) end - else - -- Mouse left the element - reset pressed state - self._pressed[button] = false end - end + end -- end if self.callback -- Handle touch events (maintain backward compatibility) - local touches = love.touch.getTouches() - for _, id in ipairs(touches) do - local tx, ty = love.touch.getPosition(id) - if tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh then - self._touchPressed[id] = true - elseif self._touchPressed[id] then - -- Create touch event (treat as left click) - local touchEvent = InputEvent.new({ - type = "click", - button = 1, - x = tx, - y = ty, - modifiers = getModifiers(), - clickCount = 1, - }) - self.callback(self, touchEvent) - self._touchPressed[id] = false + if self.callback then + local touches = love.touch.getTouches() + for _, id in ipairs(touches) do + local tx, ty = love.touch.getPosition(id) + if tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh then + self._touchPressed[id] = true + elseif self._touchPressed[id] then + -- Create touch event (treat as left click) + local touchEvent = InputEvent.new({ + type = "click", + button = 1, + x = tx, + y = ty, + modifiers = getModifiers(), + clickCount = 1, + }) + self.callback(self, touchEvent) + self._touchPressed[id] = false + end end end end @@ -2308,4 +2668,5 @@ end Gui.new = Element.new Gui.Element = Element Gui.Animation = Animation -return { GUI = Gui, Color = Color, enums = enums } +Gui.Theme = Theme +return { GUI = Gui, Color = Color, Theme = Theme, enums = enums } diff --git a/examples/ThemeSystemDemo.lua b/examples/ThemeSystemDemo.lua new file mode 100644 index 0000000..ed4fbe8 --- /dev/null +++ b/examples/ThemeSystemDemo.lua @@ -0,0 +1,216 @@ +local FlexLove = require("FlexLove") +local Gui = FlexLove.GUI +local Theme = FlexLove.Theme +local Color = FlexLove.Color + +---@class ThemeDemo +---@field window Element +---@field statusText Element +local ThemeDemo = {} +ThemeDemo.__index = ThemeDemo + +function ThemeDemo.init() + local self = setmetatable({}, ThemeDemo) + + -- Try to load and set the default theme + -- Note: This will fail if the atlas image doesn't exist yet + -- For now, we'll demonstrate the API without actually loading a theme + local themeLoaded = false + local themeError = nil + + pcall(function() + Theme.load("default") + Theme.setActive("default") + themeLoaded = true + end) + + -- Create main demo window (without theme for now) + self.window = Gui.new({ + x = 50, + y = 50, + width = 700, + height = 550, + background = Color.new(0.15, 0.15, 0.2, 0.95), + border = { top = true, bottom = true, left = true, right = true }, + borderColor = Color.new(0.8, 0.8, 0.8, 1), + positioning = "flex", + flexDirection = "vertical", + gap = 20, + padding = { top = 20, right = 20, bottom = 20, left = 20 }, + }) + + -- Title + local title = Gui.new({ + parent = self.window, + height = 40, + text = "Theme System Demo", + textSize = 20, + textAlign = "center", + textColor = Color.new(1, 1, 1, 1), + background = Color.new(0.2, 0.2, 0.3, 1), + }) + + -- Status message + self.statusText = Gui.new({ + parent = self.window, + height = 60, + text = themeLoaded and "✓ Theme loaded successfully!\nTheme system is ready to use." + or "⚠ Theme not loaded (atlas image missing)\nShowing API demonstration without actual theme rendering.", + textSize = 14, + textAlign = "center", + textColor = themeLoaded and Color.new(0.3, 0.9, 0.3, 1) or Color.new(0.9, 0.7, 0.3, 1), + background = Color.new(0.1, 0.1, 0.15, 0.8), + padding = { top = 10, right = 10, bottom = 10, left = 10 }, + }) + + -- Info section + local infoSection = Gui.new({ + parent = self.window, + height = 350, + positioning = "flex", + flexDirection = "vertical", + gap = 15, + background = Color.new(0.1, 0.1, 0.15, 0.5), + padding = { top = 15, right = 15, bottom = 15, left = 15 }, + }) + + -- Example 1: Basic themed button + local example1 = Gui.new({ + parent = infoSection, + height = 80, + positioning = "flex", + flexDirection = "vertical", + gap = 10, + background = Color.new(0.12, 0.12, 0.17, 1), + padding = { top = 10, right = 10, bottom = 10, left = 10 }, + }) + + Gui.new({ + parent = example1, + height = 20, + text = "Example 1: Basic Themed Button", + textSize = 14, + textColor = Color.new(0.8, 0.9, 1, 1), + background = Color.new(0, 0, 0, 0), + }) + + -- This button would use theme if loaded + local themedButton = Gui.new({ + parent = example1, + width = 150, + height = 40, + text = "Themed Button", + textAlign = "center", + textColor = Color.new(1, 1, 1, 1), + background = Color.new(0.2, 0.6, 0.9, 0.8), + -- theme = "button", -- Uncomment when theme atlas exists + callback = function(element, event) + if event.type == "click" then + print("Themed button clicked!") + end + end, + }) + + -- Example 2: Button with states + local example2 = Gui.new({ + parent = infoSection, + height = 100, + positioning = "flex", + flexDirection = "vertical", + gap = 10, + background = Color.new(0.12, 0.12, 0.17, 1), + padding = { top = 10, right = 10, bottom = 10, left = 10 }, + }) + + Gui.new({ + parent = example2, + height = 20, + text = "Example 2: Button with Hover/Pressed States", + textSize = 14, + textColor = Color.new(0.8, 0.9, 1, 1), + background = Color.new(0, 0, 0, 0), + }) + + Gui.new({ + parent = example2, + height = 15, + text = "Hover over or click the button to see state changes (when theme is loaded)", + textSize = 11, + textColor = Color.new(0.6, 0.7, 0.8, 1), + background = Color.new(0, 0, 0, 0), + }) + + local stateButton = Gui.new({ + parent = example2, + width = 200, + height = 40, + text = "Interactive Button", + textAlign = "center", + textColor = Color.new(1, 1, 1, 1), + background = Color.new(0.3, 0.7, 0.4, 0.8), + -- theme = "button", -- Will automatically handle hover/pressed states + callback = function(element, event) + if event.type == "click" then + print("State button clicked! State was:", element._themeState) + end + end, + }) + + -- Example 3: Themed panel + local example3 = Gui.new({ + parent = infoSection, + height = 120, + positioning = "flex", + flexDirection = "vertical", + gap = 10, + background = Color.new(0.12, 0.12, 0.17, 1), + padding = { top = 10, right = 10, bottom = 10, left = 10 }, + }) + + Gui.new({ + parent = example3, + height = 20, + text = "Example 3: Themed Panel/Container", + textSize = 14, + textColor = Color.new(0.8, 0.9, 1, 1), + background = Color.new(0, 0, 0, 0), + }) + + local themedPanel = Gui.new({ + parent = example3, + width = 300, + height = 80, + background = Color.new(0.25, 0.25, 0.35, 0.9), + -- theme = "panel", -- Would use panel theme component + padding = { top = 10, right = 10, bottom = 10, left = 10 }, + }) + + Gui.new({ + parent = themedPanel, + text = "This is a themed panel container.\nIt would have a 9-slice border when theme is loaded.", + textSize = 12, + textColor = Color.new(0.9, 0.9, 1, 1), + textAlign = "center", + background = Color.new(0, 0, 0, 0), + }) + + -- Code example section + local codeSection = Gui.new({ + parent = self.window, + height = 40, + background = Color.new(0.08, 0.08, 0.12, 1), + padding = { top = 10, right = 10, bottom = 10, left = 10 }, + }) + + Gui.new({ + parent = codeSection, + text = 'Usage: element = Gui.new({ theme = "button", ... })', + textSize = 12, + textColor = Color.new(0.5, 0.9, 0.5, 1), + background = Color.new(0, 0, 0, 0), + }) + + return self +end + +return ThemeDemo.init() diff --git a/themes/README.md b/themes/README.md new file mode 100644 index 0000000..6aa7676 --- /dev/null +++ b/themes/README.md @@ -0,0 +1,238 @@ +# FlexLove Theme System + +## Overview + +FlexLove supports a flexible 9-slice/9-patch theming system that allows you to create scalable UI components using texture atlases. + +## Image Organization Options + +You have **three ways** to organize your theme images: + +### Option 1: Separate Images Per Component (Recommended for Beginners) + +Each component gets its own image file: + +``` +themes/ + panel.png (24x24 pixels - 9-slice for panels) + button_normal.png (24x24 pixels - 9-slice for buttons) + button_hover.png (24x24 pixels - hover state) + button_pressed.png (24x24 pixels - pressed state) + input.png (24x24 pixels - 9-slice for inputs) +``` + +**Theme definition:** +```lua +return { + name = "My Theme", + components = { + panel = { + atlas = "themes/panel.png", + regions = { ... } + }, + button = { + atlas = "themes/button_normal.png", + regions = { ... }, + states = { + hover = { + atlas = "themes/button_hover.png", + regions = { ... } + }, + pressed = { + atlas = "themes/button_pressed.png", + regions = { ... } + } + } + } + } +} +``` + +### Option 2: Single Atlas (Recommended for Performance) + +All components in one texture atlas: + +``` +themes/ + default_atlas.png (96x48 pixels containing all components) +``` + +**Theme definition:** +```lua +return { + name = "My Theme", + atlas = "themes/default_atlas.png", -- Global atlas + components = { + panel = { + regions = { + topLeft = {x=0, y=0, w=8, h=8}, + -- ... regions reference positions in atlas + } + }, + button = { + regions = { + topLeft = {x=24, y=0, w=8, h=8}, -- Different position in same atlas + -- ... + } + } + } +} +``` + +### Option 3: Hybrid (Best of Both Worlds) + +Mix global atlas with component-specific images: + +```lua +return { + name = "My Theme", + atlas = "themes/global_atlas.png", -- Fallback atlas + components = { + panel = { + -- Uses global atlas + regions = {x=0, y=0, ...} + }, + button = { + atlas = "themes/button.png", -- Override with specific image + regions = {x=0, y=0, ...} + } + } +} +``` + +## 9-Slice Structure + +Each component image is divided into 9 regions: + +``` +┌─────┬──────────┬─────┐ +│ TL │ TC │ TR │ (Top: fixed height) +├─────┼──────────┼─────┤ +│ ML │ MC │ MR │ (Middle: stretches) +├─────┼──────────┼─────┤ +│ BL │ BC │ BR │ (Bottom: fixed height) +└─────┴──────────┴─────┘ + Fixed Stretch Fixed +``` + +- **Corners** (TL, TR, BL, BR): Fixed size, never stretched +- **Edges** (TC, BC, ML, MR): Stretched in one direction +- **Center** (MC): Stretched in both directions + +## Creating Theme Images + +### Minimum Image Size + +For a 9-slice image, you need at least **24x24 pixels**: +- 8px for each corner +- 8px for stretchable middle section + +### Image Requirements + +1. **Format**: PNG with transparency +2. **Color Mode**: RGBA +3. **Border Style**: Draw borders in the corner/edge regions +4. **Center**: Can be solid color or transparent + +## Example: Creating a Button Image + +For a button with rounded corners and a border: + +``` +button_normal.png (24x24 pixels) + +Pixel layout: +┌────────┬────────────┬────────┐ +│ ●●●●●● │ ██████████ │ ●●●●●● │ 8px +│ ●●●●●● │ ██████████ │ ●●●●●● │ +├────────┼────────────┼────────┤ +│ ██████ │ ░░░░░░░░░░ │ ██████ │ 8px (stretch) +│ ██████ │ ░░░░░░░░░░ │ ██████ │ +├────────┼────────────┼────────┤ +│ ●●●●●● │ ██████████ │ ●●●●●● │ 8px +│ ●●●●●● │ ██████████ │ ●●●●●● │ +└────────┴────────────┴────────┘ + 8px 8px(stretch) 8px + +Legend: +● = Corner (fixed) +█ = Border edge (stretched) +░ = Fill/background (stretched both ways) +``` + +## Usage in Code + +```lua +local FlexLove = require("FlexLove") +local Theme = FlexLove.Theme +local Gui = FlexLove.GUI + +-- Load theme +Theme.load("my_theme") +Theme.setActive("my_theme") + +-- Create themed button +local button = Gui.new({ + width = 150, + height = 40, + text = "Click Me", + theme = "button", -- Uses button component from active theme + callback = function(element, event) + print("Clicked!") + end +}) + +-- Create themed panel +local panel = Gui.new({ + width = 300, + height = 200, + theme = "panel" +}) +``` + +## Component States + +Buttons automatically handle three states: +- **normal**: Default appearance +- **hover**: When mouse is over the button +- **pressed**: When button is being clicked + +Define state-specific images in your theme: + +```lua +button = { + atlas = "themes/button_normal.png", + regions = { ... }, + states = { + hover = { + atlas = "themes/button_hover.png", + regions = { ... } + }, + pressed = { + atlas = "themes/button_pressed.png", + regions = { ... } + } + } +} +``` + +## Tips + +1. **Start Simple**: Begin with one component (button) before creating a full theme +2. **Test Scaling**: Make sure your 9-slice regions stretch properly at different sizes +3. **Consistent Style**: Keep corner sizes consistent across components +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 + +## Tools for Creating Atlases + +- **TexturePacker**: Professional sprite sheet tool +- **Aseprite**: Pixel art editor with export options +- **Shoebox**: Free sprite sheet packer +- **GIMP/Photoshop**: Manual layout with guides + +## See Also + +- `default.lua` - Example theme with single atlas +- `separate_images_example.lua` - Example with separate images per component +- `ThemeSystemDemo.lua` - Interactive demo of theme system diff --git a/themes/space.lua b/themes/space.lua new file mode 100644 index 0000000..3745683 --- /dev/null +++ b/themes/space.lua @@ -0,0 +1,45 @@ +return { + name = "Space Theme", + components = { + panel = { + atlas = "themes/space/panel-compressed.png", + regions = {}, + }, + button = { + atlas = "themes/space/interactive-compressed.png", + regions = {}, + states = { + hover = { + atlas = "themes/interactive_hover-compressed.png", + regions = { ... }, + }, + pressed = { + atlas = "themes/interactive_pressed-compressed.png", + regions = { ... }, + }, + disabled = { + atlas = "themes/interactive_disabled-compressed.png", + regions = { ... }, + }, + }, + }, + input = { + atlas = "themes/space/interactive-compressed.png", + regions = {}, + states = { + hover = { + atlas = "themes/interactive_hover-compressed.png", + regions = { ... }, + }, + pressed = { + atlas = "themes/interactive_pressed-compressed.png", + regions = { ... }, + }, + disabled = { + atlas = "themes/interactive_disabled-compressed.png", + regions = { ... }, + }, + }, + }, + }, +}