starting theme system
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
Cartographer.lua
|
Cartographer.lua
|
||||||
OverlayStats.lua
|
OverlayStats.lua
|
||||||
|
.DS_STORE
|
||||||
|
|||||||
373
FlexLove.lua
373
FlexLove.lua
@@ -48,6 +48,274 @@ function Color.fromHex(hexWithTag)
|
|||||||
end
|
end
|
||||||
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<integer, string>, vertical:table<integer, string>}
|
||||||
|
---@field states table<string, ThemeComponent>?
|
||||||
|
---@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<string, ThemeComponent>
|
||||||
|
---@field colors table<string, Color>?
|
||||||
|
|
||||||
|
---@class Theme
|
||||||
|
---@field name string
|
||||||
|
---@field atlas love.Image? -- Optional: global atlas
|
||||||
|
---@field components table<string, ThemeComponent>
|
||||||
|
---@field colors table<string, Color>
|
||||||
|
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 = {
|
local enums = {
|
||||||
---@enum TextAlign
|
---@enum TextAlign
|
||||||
TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" },
|
TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" },
|
||||||
@@ -521,7 +789,7 @@ local function getModifiers()
|
|||||||
shift = love.keyboard.isDown("lshift", "rshift"),
|
shift = love.keyboard.isDown("lshift", "rshift"),
|
||||||
ctrl = love.keyboard.isDown("lctrl", "rctrl"),
|
ctrl = love.keyboard.isDown("lctrl", "rctrl"),
|
||||||
alt = love.keyboard.isDown("lalt", "ralt"),
|
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
|
end
|
||||||
|
|
||||||
@@ -746,6 +1014,10 @@ end
|
|||||||
---@field gridColumns number? -- Number of columns in the grid
|
---@field gridColumns number? -- Number of columns in the grid
|
||||||
---@field columnGap number|string? -- Gap between grid columns
|
---@field columnGap number|string? -- Gap between grid columns
|
||||||
---@field rowGap number|string? -- Gap between grid rows
|
---@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 = {}
|
local Element = {}
|
||||||
Element.__index = Element
|
Element.__index = Element
|
||||||
|
|
||||||
@@ -789,6 +1061,9 @@ Element.__index = Element
|
|||||||
---@field gridColumns number? -- Number of columns in the grid (default: 1)
|
---@field gridColumns number? -- Number of columns in the grid (default: 1)
|
||||||
---@field columnGap number|string? -- Gap between grid columns
|
---@field columnGap number|string? -- Gap between grid columns
|
||||||
---@field rowGap number|string? -- Gap between grid rows
|
---@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 = {}
|
local ElementProps = {}
|
||||||
|
|
||||||
---@param props ElementProps
|
---@param props ElementProps
|
||||||
@@ -806,6 +1081,14 @@ function Element.new(props)
|
|||||||
self._clickCount = 0
|
self._clickCount = 0
|
||||||
self._touchPressed = {}
|
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
|
-- Set parent first so it's available for size calculations
|
||||||
self.parent = props.parent
|
self.parent = props.parent
|
||||||
|
|
||||||
@@ -1771,6 +2054,44 @@ function Element:draw()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
|
||||||
|
-- Draw background if no theme is used
|
||||||
|
if not hasTheme then
|
||||||
-- Apply opacity to all drawing operations
|
-- Apply opacity to all drawing operations
|
||||||
-- (x, y) represents border box, so draw background from (x, y)
|
-- (x, y) represents border box, so draw background from (x, y)
|
||||||
local backgroundWithOpacity =
|
local backgroundWithOpacity =
|
||||||
@@ -1783,7 +2104,10 @@ function Element:draw()
|
|||||||
self.width + self.padding.left + self.padding.right,
|
self.width + self.padding.left + self.padding.right,
|
||||||
self.height + self.padding.top + self.padding.bottom
|
self.height + self.padding.top + self.padding.bottom
|
||||||
)
|
)
|
||||||
-- Draw borders based on border property
|
end
|
||||||
|
|
||||||
|
-- Draw borders based on border property (skip if using theme)
|
||||||
|
if not hasTheme then
|
||||||
local borderColorWithOpacity =
|
local borderColorWithOpacity =
|
||||||
Color.new(self.borderColor.r, self.borderColor.g, self.borderColor.b, self.borderColor.a * self.opacity)
|
Color.new(self.borderColor.r, self.borderColor.g, self.borderColor.b, self.borderColor.a * self.opacity)
|
||||||
love.graphics.setColor(borderColorWithOpacity:toRGBA())
|
love.graphics.setColor(borderColorWithOpacity:toRGBA())
|
||||||
@@ -1809,6 +2133,7 @@ function Element:draw()
|
|||||||
self.y + self.height + self.padding.top + self.padding.bottom
|
self.y + self.height + self.padding.top + self.padding.bottom
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Draw element text if present
|
-- Draw element text if present
|
||||||
if self.text then
|
if self.text then
|
||||||
@@ -1911,7 +2236,7 @@ function Element:update(dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Handle click detection for element with enhanced event system
|
-- 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()
|
local mx, my = love.mouse.getPosition()
|
||||||
-- Clickable area is the border box (x, y already includes padding)
|
-- Clickable area is the border box (x, y already includes padding)
|
||||||
local bx = self.x
|
local bx = self.x
|
||||||
@@ -1920,6 +2245,36 @@ function Element:update(dt)
|
|||||||
local bh = self.height + self.padding.top + self.padding.bottom
|
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
|
local isHovering = mx >= bx and mx <= bx + bw and my >= by and my <= by + bh
|
||||||
|
|
||||||
|
-- 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
|
-- Check all three mouse buttons
|
||||||
local buttons = { 1, 2, 3 } -- left, right, middle
|
local buttons = { 1, 2, 3 } -- left, right, middle
|
||||||
|
|
||||||
@@ -1950,9 +2305,11 @@ function Element:update(dt)
|
|||||||
local clickCount = 1
|
local clickCount = 1
|
||||||
local doubleClickThreshold = 0.3 -- 300ms for double-click
|
local doubleClickThreshold = 0.3 -- 300ms for double-click
|
||||||
|
|
||||||
if self._lastClickTime
|
if
|
||||||
|
self._lastClickTime
|
||||||
and self._lastClickButton == button
|
and self._lastClickButton == button
|
||||||
and (currentTime - self._lastClickTime) < doubleClickThreshold then
|
and (currentTime - self._lastClickTime) < doubleClickThreshold
|
||||||
|
then
|
||||||
clickCount = self._clickCount + 1
|
clickCount = self._clickCount + 1
|
||||||
else
|
else
|
||||||
clickCount = 1
|
clickCount = 1
|
||||||
@@ -1998,8 +2355,10 @@ function Element:update(dt)
|
|||||||
self._pressed[button] = false
|
self._pressed[button] = false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end -- end if self.callback
|
||||||
|
|
||||||
-- Handle touch events (maintain backward compatibility)
|
-- Handle touch events (maintain backward compatibility)
|
||||||
|
if self.callback then
|
||||||
local touches = love.touch.getTouches()
|
local touches = love.touch.getTouches()
|
||||||
for _, id in ipairs(touches) do
|
for _, id in ipairs(touches) do
|
||||||
local tx, ty = love.touch.getPosition(id)
|
local tx, ty = love.touch.getPosition(id)
|
||||||
@@ -2021,6 +2380,7 @@ function Element:update(dt)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
--- Recalculate units based on new viewport dimensions (for vw, vh, % units)
|
--- Recalculate units based on new viewport dimensions (for vw, vh, % units)
|
||||||
---@param newViewportWidth number
|
---@param newViewportWidth number
|
||||||
@@ -2308,4 +2668,5 @@ end
|
|||||||
Gui.new = Element.new
|
Gui.new = Element.new
|
||||||
Gui.Element = Element
|
Gui.Element = Element
|
||||||
Gui.Animation = Animation
|
Gui.Animation = Animation
|
||||||
return { GUI = Gui, Color = Color, enums = enums }
|
Gui.Theme = Theme
|
||||||
|
return { GUI = Gui, Color = Color, Theme = Theme, enums = enums }
|
||||||
|
|||||||
216
examples/ThemeSystemDemo.lua
Normal file
216
examples/ThemeSystemDemo.lua
Normal file
@@ -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()
|
||||||
238
themes/README.md
Normal file
238
themes/README.md
Normal file
@@ -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
|
||||||
45
themes/space.lua
Normal file
45
themes/space.lua
Normal file
@@ -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 = { ... },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user