1377 lines
50 KiB
Lua
1377 lines
50 KiB
Lua
--[[
|
|
gui_extensions.lua
|
|
------------------
|
|
Drop-in enhancements for the gui library. Patches bugs, fills gaps,
|
|
and adds new widgets without modifying any original source files.
|
|
|
|
USAGE
|
|
require("gui_extensions") -- after requiring "gui"
|
|
|
|
CONTENTS
|
|
BUG FIXES
|
|
1. color arithmetic blue-channel bug
|
|
2. gui:isOnScreen() stub
|
|
|
|
NEW WIDGETS
|
|
3. Checkbox
|
|
4. RadioGroup
|
|
5. Slider (horizontal or vertical)
|
|
6. ProgressBar
|
|
7. Tooltip
|
|
8. TextArea (multiline input)
|
|
|
|
LAYOUT CONTAINERS
|
|
9. ListFrame (auto-stacking vertical/horizontal list)
|
|
10. GridFrame (fixed-column grid)
|
|
|
|
SCROLL FRAME AUTO-SIZING
|
|
11. Auto-measure patch for newScrollFrame content
|
|
|
|
EASING LIBRARY
|
|
12. transition.easings (ease-in/out, cubic, bounce, elastic, back)
|
|
|
|
FOCUS MANAGEMENT
|
|
13. gui.focus.* (set/get focus, OnFocusGained, OnFocusLost per element)
|
|
|
|
Z-INDEX / LAYER SYSTEM
|
|
14. gui:setLayer(n) / gui:getLayer()
|
|
]]
|
|
|
|
local gui = require("gui")
|
|
local color = require("gui.core.color")
|
|
local transition = require("gui.elements.transitions")
|
|
local multi, thread = require("multi"):init()
|
|
|
|
local ext = {}
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 1. BUG FIX: color arithmetic metamethods (blue channel was always c[2])
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
-- We reach into the metatable that color.new sets on every color table and
|
|
-- replace the broken metamethods with correct ones. All existing color objects
|
|
-- share this metatable so the fix is retroactive.
|
|
do
|
|
local probe = color.new(1, 0, 0) -- make a throwaway color to get the MT
|
|
local mt = getmetatable(probe)
|
|
if mt then
|
|
mt.__add = function(a, b) return color.new(a[1]+b[1], a[2]+b[2], a[3]+b[3]) end
|
|
mt.__sub = function(a, b) return color.new(a[1]-b[1], a[2]-b[2], a[3]-b[3]) end
|
|
mt.__mul = function(a, b) return color.new(a[1]*b[1], a[2]*b[2], a[3]*b[3]) end
|
|
mt.__div = function(a, b) return color.new(a[1]/b[1], a[2]/b[2], a[3]/b[3]) end
|
|
mt.__mod = function(a, b) return color.new(a[1]%b[1], a[2]%b[2], a[3]%b[3]) end
|
|
mt.__pow = function(a, b) return color.new(a[1]^b[1], a[2]^b[2], a[3]^b[3]) end
|
|
mt.__unm = function(a) return color.new(-a[1], -a[2], -a[3]) end
|
|
mt.__eq = function(a, b) return a[1]==b[1] and a[2]==b[2] and a[3]==b[3] end
|
|
mt.__lt = function(a, b) return a[1]< b[1] and a[2]< b[2] and a[3]< b[3] end
|
|
mt.__le = function(a, b) return a[1]<=b[1] and a[2]<=b[2] and a[3]<=b[3] end
|
|
end
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 2. BUG FIX: gui:isOnScreen() was an empty stub
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function gui:isOnScreen()
|
|
return not self:isOffScreen()
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 3. WIDGET: Checkbox
|
|
--
|
|
-- local cb = parent:newCheckbox(label, x, y, size, sx, sy, checked)
|
|
--
|
|
-- Properties cb.checked (boolean)
|
|
-- Events cb.OnChanged(function(self, checked) end)
|
|
-- Methods cb:setValue(bool) cb:toggle()
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function gui:newCheckbox(label, x, y, size, sx, sy, checked)
|
|
size = size or 20
|
|
|
|
local container = self:newFrame(x, y, 0, size, sx, sy, 0, 0)
|
|
container.drawBorder = false
|
|
container.color = {0, 0, 0, 0}
|
|
container.visibility = 0
|
|
|
|
-- The clickable box
|
|
local box = container:newTextButton("", 0, 0, size, size)
|
|
box:setRoundness(3, 3)
|
|
box.color = color.new("#dddddd")
|
|
|
|
-- The checkmark label (drawn inside the box)
|
|
local check = box:newTextLabel("✓", 0, 0, size, size)
|
|
check.align = gui.ALIGN_CENTER
|
|
check.textColor = color.new("#ffffff")
|
|
check.visibility = 0
|
|
check.ignore = true
|
|
check:setFont(math.floor(size * 0.7))
|
|
|
|
-- The text label to the right of the box
|
|
local lbl = container:newTextLabel(label or "", size + 6, 0, 0, size, 0, 0, 1)
|
|
lbl.drawBorder = false
|
|
lbl.color = {0, 0, 0, 0}
|
|
lbl.visibility = 0
|
|
lbl.textColor = color.new("#222222")
|
|
lbl.align = gui.ALIGN_LEFT
|
|
lbl.ignore = true
|
|
|
|
container.OnChanged = multi:newConnection()
|
|
container.checked = checked or false
|
|
|
|
local normalColor = color.new("#dddddd")
|
|
local checkedColor = color.new("#4a90d9")
|
|
|
|
local function refresh()
|
|
if container.checked then
|
|
box.color = checkedColor
|
|
check.visibility = 1
|
|
else
|
|
box.color = normalColor
|
|
check.visibility = 0
|
|
end
|
|
end
|
|
|
|
function container:setValue(v)
|
|
self.checked = v
|
|
refresh()
|
|
self.OnChanged:Fire(self, self.checked)
|
|
end
|
|
|
|
function container:toggle()
|
|
self:setValue(not self.checked)
|
|
end
|
|
|
|
box.OnReleased(function()
|
|
container:toggle()
|
|
end)
|
|
|
|
refresh()
|
|
return container
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 4. WIDGET: RadioGroup
|
|
--
|
|
-- local rg = parent:newRadioGroup(options, x, y, w, itemHeight, sx, sy)
|
|
-- -- options: {"Option A", "Option B", ...}
|
|
--
|
|
-- Properties rg.selected (1-based index of the selected option, or nil)
|
|
-- Events rg.OnChanged(function(self, index, label) end)
|
|
-- Methods rg:select(index)
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function gui:newRadioGroup(options, x, y, w, itemHeight, sx, sy)
|
|
itemHeight = itemHeight or 28
|
|
local totalH = #options * itemHeight
|
|
|
|
local container = self:newFrame(x, y, w or 0, totalH, sx, sy, w and 0 or 1)
|
|
container.drawBorder = false
|
|
container.color = {0, 0, 0, 0}
|
|
container.visibility = 0
|
|
|
|
container.OnChanged = multi:newConnection()
|
|
container.selected = nil
|
|
|
|
local dotSize = math.floor(itemHeight * 0.55)
|
|
local dotRadius = math.floor(dotSize / 2)
|
|
local normalBorder = color.new("#aaaaaa")
|
|
local selectedBorder = color.new("#4a90d9")
|
|
local dotColor = color.new("#4a90d9")
|
|
|
|
local buttons = {}
|
|
|
|
for i, optLabel in ipairs(options) do
|
|
local row = container:newFrame(0, (i-1)*itemHeight, 0, itemHeight, 0, 0, 1)
|
|
row.drawBorder = false
|
|
row.color = {0, 0, 0, 0}
|
|
row.visibility = 0
|
|
|
|
-- Outer ring
|
|
local ring = row:newTextButton("", 0, math.floor((itemHeight - dotSize)/2), dotSize, dotSize)
|
|
ring:makeCircle(0, math.floor((itemHeight - dotSize)/2), dotRadius)
|
|
ring.color = {1, 1, 1}
|
|
ring.borderColor = normalBorder
|
|
|
|
-- Inner filled dot (hidden when unselected)
|
|
local innerR = math.floor(dotRadius * 0.5)
|
|
local innerOff = dotRadius - innerR
|
|
local dot = ring:newFrame(innerOff, innerOff, innerR*2, innerR*2)
|
|
dot:makeCircle(innerOff, innerOff, innerR)
|
|
dot.color = dotColor
|
|
dot.drawBorder = false
|
|
dot.visibility = 0
|
|
dot.ignore = true
|
|
|
|
local lbl = row:newTextLabel(optLabel, dotSize + 8, 0, 0, itemHeight, 0, 0, 1)
|
|
lbl.drawBorder = false
|
|
lbl.color = {0, 0, 0, 0}
|
|
lbl.visibility = 0
|
|
lbl.textColor = color.new("#222222")
|
|
lbl.align = gui.ALIGN_LEFT
|
|
lbl.ignore = true
|
|
|
|
buttons[i] = {ring = ring, dot = dot, label = optLabel}
|
|
|
|
local idx = i
|
|
ring.OnReleased(function()
|
|
container:select(idx)
|
|
end)
|
|
end
|
|
|
|
function container:select(index)
|
|
-- deselect previous
|
|
if self.selected and buttons[self.selected] then
|
|
local prev = buttons[self.selected]
|
|
prev.ring.borderColor = normalBorder
|
|
prev.dot.visibility = 0
|
|
end
|
|
self.selected = index
|
|
if buttons[index] then
|
|
local cur = buttons[index]
|
|
cur.ring.borderColor = selectedBorder
|
|
cur.dot.visibility = 1
|
|
end
|
|
self.OnChanged:Fire(self, index, options[index])
|
|
end
|
|
|
|
return container
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 5. WIDGET: Slider
|
|
--
|
|
-- local sl = parent:newSlider(min, max, value, x, y, w, h, sx, sy, vertical)
|
|
--
|
|
-- Properties sl.value (current value, clamped to [min,max])
|
|
-- Events sl.OnChanged(function(self, value) end)
|
|
-- Methods sl:setValue(n) sl:setRange(min, max)
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function gui:newSlider(min, max, value, x, y, w, h, sx, sy, vertical)
|
|
min = min or 0
|
|
max = max or 100
|
|
value = value or min
|
|
|
|
local container = self:newFrame(x, y, w or 0, h or (vertical and 0 or 20), sx, sy, w and 0 or 1, h and 0 or 0)
|
|
container.drawBorder = false
|
|
container.color = {0, 0, 0, 0}
|
|
container.visibility = 0
|
|
|
|
-- Track
|
|
local track = container:newFrame(0, 0, 0, 0, 0, 0, 1, 1)
|
|
track.drawBorder = false
|
|
track.color = color.new("#cccccc")
|
|
if vertical then
|
|
track:setDualDim(math.floor((w or 20)/2)-3, 0, 6, 0, 0, 0, 0, 1)
|
|
else
|
|
track:setDualDim(0, math.floor((h or 20)/2)-3, 0, 6, 0, 0, 1, 0)
|
|
end
|
|
track:setRoundness(3, 3)
|
|
|
|
-- Fill (colored part up to the thumb)
|
|
local fill = track:newFrame(0, 0, 0, 0, 0, 0, 0, 1)
|
|
fill.drawBorder = false
|
|
fill.color = color.new("#4a90d9")
|
|
fill:setRoundness(3, 3)
|
|
if vertical then
|
|
fill:setDualDim(0, 0, 0, 0, 0, 0, 1, 0) -- will be sized by setValue
|
|
end
|
|
|
|
-- Thumb
|
|
local thumbSize = vertical and (w or 20) or (h or 20)
|
|
local thumb = container:newFrame(0, 0, thumbSize, thumbSize)
|
|
thumb:makeCircle(0, 0, math.floor(thumbSize/2))
|
|
thumb.color = color.new("#ffffff")
|
|
thumb.borderColor = color.new("#4a90d9")
|
|
thumb:enableDragging(gui.MOUSE_PRIMARY)
|
|
thumb:respectHierarchy(true)
|
|
|
|
container.OnChanged = multi:newConnection()
|
|
container.value = value
|
|
container._min = min
|
|
container._max = max
|
|
|
|
local function normalize(v)
|
|
return (v - container._min) / (container._max - container._min)
|
|
end
|
|
|
|
local function positionThumb(v)
|
|
local t = normalize(v)
|
|
local ax, ay, aw, ah = container:getAbsolutes()
|
|
if vertical then
|
|
local travel = ah - thumbSize
|
|
local py = travel * (1 - t) -- 0 = top = max
|
|
thumb:rawSetDualDim(math.floor((aw - thumbSize)/2), math.floor(py))
|
|
fill:setDualDim(0, math.floor(py + thumbSize/2), 0, 0, 0, 0, 1, 1 - t)
|
|
else
|
|
local travel = aw - thumbSize
|
|
local px = travel * t
|
|
thumb:rawSetDualDim(math.floor(px), math.floor((ah - thumbSize)/2))
|
|
fill:setDualDim(0, 0, 0, 0, 0, 0, t, 1)
|
|
end
|
|
end
|
|
|
|
function container:setValue(v)
|
|
v = math.max(self._min, math.min(self._max, v))
|
|
self.value = v
|
|
positionThumb(v)
|
|
self.OnChanged:Fire(self, v)
|
|
end
|
|
|
|
function container:setRange(newMin, newMax)
|
|
self._min = newMin
|
|
self._max = newMax
|
|
self:setValue(math.max(newMin, math.min(newMax, self.value)))
|
|
end
|
|
|
|
-- Drag handling
|
|
thumb.OnDragging(function(self, dx, dy)
|
|
local ax, ay, aw, ah = container:getAbsolutes()
|
|
local t
|
|
if vertical then
|
|
local travel = ah - thumbSize
|
|
if travel <= 0 then return end
|
|
local _, ty = thumb:getAbsolutes()
|
|
local newY = ty - ay + dy
|
|
t = 1 - math.max(0, math.min(1, newY / travel))
|
|
else
|
|
local travel = aw - thumbSize
|
|
if travel <= 0 then return end
|
|
local tx, _ = thumb:getAbsolutes()
|
|
local newX = tx - ax + dx
|
|
t = math.max(0, math.min(1, newX / travel))
|
|
end
|
|
local newVal = container._min + t * (container._max - container._min)
|
|
container:setValue(newVal)
|
|
end)
|
|
|
|
-- Click on track to jump
|
|
track.OnReleased(function(self, mx, my)
|
|
local ax, ay, aw, ah = container:getAbsolutes()
|
|
local t
|
|
if vertical then
|
|
t = 1 - math.max(0, math.min(1, (my - ay) / ah))
|
|
else
|
|
t = math.max(0, math.min(1, (mx - ax) / aw))
|
|
end
|
|
local newVal = container._min + t * (container._max - container._min)
|
|
container:setValue(newVal)
|
|
end)
|
|
|
|
-- Initial position (after first layout pass)
|
|
container.OnSizeChanged(function() positionThumb(container.value) end)
|
|
container:OnUpdate(function()
|
|
-- only run once to set initial position
|
|
positionThumb(container.value)
|
|
-- replace with no-op after first run
|
|
container.OnUpdate = function() end
|
|
end)
|
|
|
|
return container
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 6. WIDGET: ProgressBar
|
|
--
|
|
-- local pb = parent:newProgressBar(min, max, value, x, y, w, h, sx, sy)
|
|
--
|
|
-- Properties pb.value
|
|
-- Methods pb:setValue(n) pb:setRange(min, max)
|
|
-- pb:setColors(bg, fill)
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function gui:newProgressBar(min, max, value, x, y, w, h, sx, sy)
|
|
min = min or 0
|
|
max = max or 100
|
|
value = value or min
|
|
|
|
local bar = self:newFrame(x, y, w or 0, h or 20, sx, sy, w and 0 or 1)
|
|
bar.color = color.new("#cccccc")
|
|
bar:setRoundness(4, 4)
|
|
|
|
local fill = bar:newFrame(0, 0, 0, 0, 0, 0, 0, 1)
|
|
fill.drawBorder = false
|
|
fill.color = color.new("#4a90d9")
|
|
fill:setRoundness(4, 4)
|
|
|
|
bar._min = min
|
|
bar._max = max
|
|
bar.value = value
|
|
|
|
local function refresh()
|
|
local t = (bar.value - bar._min) / (bar._max - bar._min)
|
|
t = math.max(0, math.min(1, t))
|
|
fill:setDualDim(0, 0, 0, 0, 0, 0, t, 1)
|
|
end
|
|
|
|
function bar:setValue(v)
|
|
self.value = math.max(self._min, math.min(self._max, v))
|
|
refresh()
|
|
end
|
|
|
|
function bar:setRange(newMin, newMax)
|
|
self._min = newMin
|
|
self._max = newMax
|
|
self:setValue(self.value)
|
|
end
|
|
|
|
function bar:setColors(bg, fg)
|
|
if bg then self.color = color.new(bg) end
|
|
if fg then fill.color = color.new(fg) end
|
|
end
|
|
|
|
refresh()
|
|
return bar
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 7. WIDGET: Tooltip
|
|
--
|
|
-- local tt = gui:newTooltip(text, delay)
|
|
-- tt:attach(someElement) -- show tooltip when hovering someElement
|
|
--
|
|
-- Or attach inline:
|
|
-- gui:attachTooltip(element, text, delay)
|
|
--
|
|
-- The tooltip lives at the root and always renders on top.
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
do
|
|
-- Shared tooltip panel (one global instance)
|
|
local _panel, _label
|
|
local _visible = false
|
|
local _timer = nil
|
|
|
|
local function ensurePanel()
|
|
if _panel then return end
|
|
_panel = gui:newFrame(0, 0, 0, 0)
|
|
_panel.color = color.new("#333333")
|
|
_panel.visibility = 0
|
|
_panel.drawBorder = false
|
|
_panel:setRoundness(4, 4)
|
|
_panel:topStack()
|
|
|
|
_label = _panel:newTextLabel("", 6, 4, 0, 0, 0, 0, 1, 1)
|
|
_label.textColor = color.new("#ffffff")
|
|
_label.drawBorder = false
|
|
_label.color = {0, 0, 0, 0}
|
|
_label.visibility = 0
|
|
_label.ignore = true
|
|
_label:setFont(13)
|
|
end
|
|
|
|
local function showAt(text, x, y)
|
|
ensurePanel()
|
|
_label.text = text
|
|
local fw = _label.font:getWidth(text) + 12
|
|
local fh = _label.font:getHeight() + 8
|
|
local sw, sh = love.graphics.getDimensions()
|
|
-- keep tooltip on screen
|
|
local px = math.min(x + 14, sw - fw - 4)
|
|
local py = math.min(y + 14, sh - fh - 4)
|
|
_panel:rawSetDualDim(px, py, fw, fh)
|
|
_panel.visibility = 0.92
|
|
_panel:topStack()
|
|
_visible = true
|
|
end
|
|
|
|
local function hide()
|
|
if _panel then _panel.visibility = 0 end
|
|
_visible = false
|
|
end
|
|
|
|
function gui:attachTooltip(element, text, delay)
|
|
delay = delay or 0.6
|
|
ensurePanel()
|
|
|
|
local hoverTimer = nil
|
|
local timerDone = false
|
|
|
|
element.OnEnter(function(self, mx, my)
|
|
timerDone = false
|
|
hoverTimer = 0
|
|
end)
|
|
|
|
element.OnMoved(function(self, mx, my)
|
|
if not timerDone then
|
|
-- show once timer expires; track mouse position
|
|
if hoverTimer and hoverTimer >= delay then
|
|
showAt(text, mx, my)
|
|
timerDone = true
|
|
end
|
|
end
|
|
end)
|
|
|
|
element.OnExit(function()
|
|
hoverTimer = nil
|
|
timerDone = false
|
|
hide()
|
|
end)
|
|
|
|
-- We need a per-frame tick to advance the hover timer.
|
|
-- OnUpdate is on the element itself so it cleans up when the element dies.
|
|
local elapsed = 0
|
|
element:OnUpdate(function(self, dt)
|
|
if hoverTimer ~= nil and not timerDone then
|
|
hoverTimer = hoverTimer + dt
|
|
if hoverTimer >= delay then
|
|
local mx, my = love.mouse.getPosition()
|
|
showAt(text, mx, my)
|
|
timerDone = true
|
|
end
|
|
end
|
|
end)
|
|
|
|
element.OnDestroy(function() hide() end)
|
|
end
|
|
|
|
-- Standalone tooltip object (attach to multiple targets)
|
|
function gui:newTooltip(text, delay)
|
|
local tt = { text = text, delay = delay or 0.6 }
|
|
function tt:attach(element)
|
|
gui:attachTooltip(element, self.text, self.delay)
|
|
end
|
|
function tt:setText(t)
|
|
self.text = t
|
|
end
|
|
return tt
|
|
end
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 8. WIDGET: TextArea (multiline input)
|
|
--
|
|
-- local ta = parent:newTextArea(text, x, y, w, h, sx, sy, sw, sh)
|
|
--
|
|
-- Properties ta.text (string, may contain "\n")
|
|
-- ta.readOnly (boolean, default false)
|
|
-- Events ta.OnChanged(function(self, text) end)
|
|
-- Methods ta:getText() ta:setText(s) ta:appendLine(s)
|
|
-- ta:scrollToBottom()
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function gui:newTextArea(initialText, x, y, w, h, sx, sy, sw, sh)
|
|
-- Outer viewport (clips content)
|
|
local viewport = self:newFrame(x, y, w or 0, h or 0, sx, sy, sw, sh)
|
|
viewport.clipDescendants = true
|
|
viewport.color = color.new("#f9f9f9")
|
|
viewport:setRoundness(3, 3)
|
|
|
|
-- Inner content frame (scrolled by offsetting its y)
|
|
local content = viewport:newFrame(2, 2, -4, -4, 0, 0, 1, 0)
|
|
content.drawBorder = false
|
|
content.color = {0, 0, 0, 0}
|
|
content.visibility = 0
|
|
|
|
-- Cursor line rendering happens via a separate frame
|
|
local cursorBar = viewport:newFrame(0, 0, 1, 0)
|
|
cursorBar.color = color.new("#222222")
|
|
cursorBar.drawBorder = false
|
|
cursorBar.ignore = true
|
|
cursorBar.visibility = 0
|
|
|
|
local lines = {}
|
|
local lineObjs = {} -- TextLabel per line
|
|
local LINE_H = 18
|
|
local scrollY = 0
|
|
local cursorLine = 1
|
|
local cursorCol = 0
|
|
local blinkOn = true
|
|
local blinkTimer = 0
|
|
local BLINK_RATE = 0.5
|
|
local focused = false
|
|
|
|
viewport.OnChanged = multi:newConnection()
|
|
viewport.readOnly = false
|
|
|
|
-- Split a string into lines
|
|
local function splitLines(s)
|
|
local result = {}
|
|
local pos = 1
|
|
while true do
|
|
local nl = s:find("\n", pos, true)
|
|
if nl then
|
|
result[#result + 1] = s:sub(pos, nl - 1)
|
|
pos = nl + 1
|
|
else
|
|
result[#result + 1] = s:sub(pos)
|
|
break
|
|
end
|
|
end
|
|
return result
|
|
end
|
|
|
|
-- Join lines back to a single string
|
|
local function joinLines()
|
|
return table.concat(lines, "\n")
|
|
end
|
|
|
|
-- Rebuild all line label objects
|
|
local function rebuildLabels()
|
|
for _, obj in ipairs(lineObjs) do
|
|
obj:destroy()
|
|
end
|
|
lineObjs = {}
|
|
|
|
for i, lineText in ipairs(lines) do
|
|
local lbl = content:newTextLabel(lineText, 0, (i-1)*LINE_H, 0, LINE_H, 0, 0, 1)
|
|
lbl.drawBorder = false
|
|
lbl.color = {0, 0, 0, 0}
|
|
lbl.visibility = 0
|
|
lbl.textColor = color.new("#222222")
|
|
lbl.align = gui.ALIGN_LEFT
|
|
lbl.ignore = true
|
|
lbl:setFont(13)
|
|
lineObjs[i] = lbl
|
|
end
|
|
|
|
-- Resize content frame to fit all lines
|
|
local totalH = #lines * LINE_H + 4
|
|
content:setDualDim(nil, nil, nil, totalH)
|
|
end
|
|
|
|
-- Apply vertical scroll so the cursor stays visible
|
|
local function applyScroll()
|
|
local _, _, _, vh = viewport:getAbsolutes()
|
|
local contentH = #lines * LINE_H + 4
|
|
local maxScroll = math.max(0, contentH - vh)
|
|
scrollY = math.max(0, math.min(scrollY, maxScroll))
|
|
content:rawSetDualDim(2, 2 - scrollY)
|
|
end
|
|
|
|
local function ensureCursorVisible()
|
|
local _, _, _, vh = viewport:getAbsolutes()
|
|
local cursorY = (cursorLine - 1) * LINE_H
|
|
if cursorY < scrollY then
|
|
scrollY = cursorY
|
|
elseif cursorY + LINE_H > scrollY + vh then
|
|
scrollY = cursorY + LINE_H - vh
|
|
end
|
|
applyScroll()
|
|
end
|
|
|
|
-- Update the cursor bar position
|
|
local function updateCursor()
|
|
if not focused then
|
|
cursorBar.visibility = 0
|
|
return
|
|
end
|
|
local ax, ay = viewport:getAbsolutes()
|
|
local lineText = lines[cursorLine] or ""
|
|
local font = love.graphics.newFont(13)
|
|
local cx = 2 + font:getWidth(lineText:sub(1, cursorCol))
|
|
local cy = 2 + (cursorLine - 1) * LINE_H - scrollY
|
|
cursorBar:rawSetDualDim(cx, cy, 1, LINE_H)
|
|
cursorBar.visibility = blinkOn and 1 or 0
|
|
end
|
|
|
|
local function setText(s)
|
|
lines = splitLines(s or "")
|
|
if #lines == 0 then lines = {""} end
|
|
rebuildLabels()
|
|
cursorLine = math.min(cursorLine, #lines)
|
|
cursorCol = math.min(cursorCol, #lines[cursorLine])
|
|
applyScroll()
|
|
updateCursor()
|
|
end
|
|
|
|
function viewport:getText()
|
|
return joinLines()
|
|
end
|
|
|
|
function viewport:setText(s)
|
|
setText(s)
|
|
self.OnChanged:Fire(self, joinLines())
|
|
end
|
|
|
|
function viewport:appendLine(s)
|
|
lines[#lines + 1] = s
|
|
rebuildLabels()
|
|
applyScroll()
|
|
end
|
|
|
|
function viewport:scrollToBottom()
|
|
scrollY = math.huge
|
|
applyScroll()
|
|
end
|
|
|
|
-- Insert text at cursor
|
|
local function insertText(s)
|
|
if viewport.readOnly then return end
|
|
local line = lines[cursorLine] or ""
|
|
-- Handle newlines in inserted text
|
|
if s == "\n" then
|
|
local before = line:sub(1, cursorCol)
|
|
local after = line:sub(cursorCol + 1)
|
|
lines[cursorLine] = before
|
|
table.insert(lines, cursorLine + 1, after)
|
|
cursorLine = cursorLine + 1
|
|
cursorCol = 0
|
|
else
|
|
lines[cursorLine] = line:sub(1, cursorCol) .. s .. line:sub(cursorCol + 1)
|
|
cursorCol = cursorCol + #s
|
|
end
|
|
rebuildLabels()
|
|
ensureCursorVisible()
|
|
updateCursor()
|
|
viewport.OnChanged:Fire(viewport, joinLines())
|
|
end
|
|
|
|
local function deleteBack()
|
|
if viewport.readOnly then return end
|
|
if cursorCol > 0 then
|
|
local line = lines[cursorLine]
|
|
lines[cursorLine] = line:sub(1, cursorCol - 1) .. line:sub(cursorCol + 1)
|
|
cursorCol = cursorCol - 1
|
|
elseif cursorLine > 1 then
|
|
-- merge with previous line
|
|
local prevLine = lines[cursorLine - 1]
|
|
cursorCol = #prevLine
|
|
lines[cursorLine - 1] = prevLine .. lines[cursorLine]
|
|
table.remove(lines, cursorLine)
|
|
cursorLine = cursorLine - 1
|
|
end
|
|
rebuildLabels()
|
|
ensureCursorVisible()
|
|
updateCursor()
|
|
viewport.OnChanged:Fire(viewport, joinLines())
|
|
end
|
|
|
|
local function deleteForward()
|
|
if viewport.readOnly then return end
|
|
local line = lines[cursorLine]
|
|
if cursorCol < #line then
|
|
lines[cursorLine] = line:sub(1, cursorCol) .. line:sub(cursorCol + 2)
|
|
elseif cursorLine < #lines then
|
|
lines[cursorLine] = line .. lines[cursorLine + 1]
|
|
table.remove(lines, cursorLine + 1)
|
|
end
|
|
rebuildLabels()
|
|
updateCursor()
|
|
viewport.OnChanged:Fire(viewport, joinLines())
|
|
end
|
|
|
|
-- Mouse click to position cursor
|
|
viewport.OnPressed(function(self, mx, my)
|
|
focused = true
|
|
local _, vy = viewport:getAbsolutes()
|
|
local relY = my - vy + scrollY - 2
|
|
cursorLine = math.max(1, math.min(#lines, math.floor(relY / LINE_H) + 1))
|
|
local lineText = lines[cursorLine] or ""
|
|
local font = love.graphics.newFont(13)
|
|
local _, vx = viewport:getAbsolutes()
|
|
local relX = mx - vx - 2
|
|
-- binary-search for cursor column
|
|
local col = 0
|
|
for i = 1, #lineText do
|
|
local w = font:getWidth(lineText:sub(1, i))
|
|
if w > relX then break end
|
|
col = i
|
|
end
|
|
cursorCol = col
|
|
updateCursor()
|
|
end)
|
|
|
|
viewport.OnPressedOuter(function()
|
|
focused = false
|
|
updateCursor()
|
|
end)
|
|
|
|
-- Keyboard input (only when focused)
|
|
gui.Events.OnTextInputed(function(t)
|
|
if not focused then return end
|
|
insertText(t)
|
|
end)
|
|
|
|
gui.Events.OnKeyPressed(function(key)
|
|
if not focused then return end
|
|
if key == "return" or key == "kpenter" then
|
|
insertText("\n")
|
|
elseif key == "backspace" then
|
|
deleteBack()
|
|
elseif key == "delete" then
|
|
deleteForward()
|
|
elseif key == "up" then
|
|
cursorLine = math.max(1, cursorLine - 1)
|
|
cursorCol = math.min(cursorCol, #(lines[cursorLine] or ""))
|
|
ensureCursorVisible(); updateCursor()
|
|
elseif key == "down" then
|
|
cursorLine = math.min(#lines, cursorLine + 1)
|
|
cursorCol = math.min(cursorCol, #(lines[cursorLine] or ""))
|
|
ensureCursorVisible(); updateCursor()
|
|
elseif key == "left" then
|
|
if cursorCol > 0 then
|
|
cursorCol = cursorCol - 1
|
|
elseif cursorLine > 1 then
|
|
cursorLine = cursorLine - 1
|
|
cursorCol = #lines[cursorLine]
|
|
end
|
|
ensureCursorVisible(); updateCursor()
|
|
elseif key == "right" then
|
|
local lineLen = #(lines[cursorLine] or "")
|
|
if cursorCol < lineLen then
|
|
cursorCol = cursorCol + 1
|
|
elseif cursorLine < #lines then
|
|
cursorLine = cursorLine + 1
|
|
cursorCol = 0
|
|
end
|
|
ensureCursorVisible(); updateCursor()
|
|
elseif key == "home" then
|
|
cursorCol = 0; updateCursor()
|
|
elseif key == "end" then
|
|
cursorCol = #(lines[cursorLine] or ""); updateCursor()
|
|
end
|
|
end)
|
|
|
|
-- Scroll wheel
|
|
viewport.OnWheelMoved(function(_, dy)
|
|
scrollY = scrollY - dy * 30
|
|
applyScroll()
|
|
updateCursor()
|
|
end)
|
|
|
|
-- Cursor blink
|
|
viewport:OnUpdate(function(self, dt)
|
|
blinkTimer = blinkTimer + dt
|
|
if blinkTimer >= BLINK_RATE then
|
|
blinkTimer = 0
|
|
blinkOn = not blinkOn
|
|
if focused then
|
|
cursorBar.visibility = blinkOn and 1 or 0
|
|
end
|
|
end
|
|
end)
|
|
|
|
setText(initialText or "")
|
|
return viewport
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 9. LAYOUT: ListFrame (auto-stacking container)
|
|
--
|
|
-- local list = parent:newListFrame(x, y, w, h, sx, sy, sw, sh, opts)
|
|
-- opts = { direction = "vertical"|"horizontal", gap = 4, padding = 6 }
|
|
--
|
|
-- The list watches OnSizeChanged on each child and re-stacks automatically.
|
|
-- Methods list:reflow() list:setGap(n) list:setPadding(n)
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function gui:newListFrame(x, y, w, h, sx, sy, sw, sh, opts)
|
|
opts = opts or {}
|
|
local direction = opts.direction or "vertical"
|
|
local gap = opts.gap or 4
|
|
local padding = opts.padding or 0
|
|
|
|
local frame = self:newFrame(x, y, w or 0, h or 0, sx, sy, sw, sh)
|
|
frame.drawBorder = false
|
|
frame.color = {0, 0, 0, 0}
|
|
frame.visibility = 0
|
|
|
|
-- Track insertion order separately from frame.children (which can be
|
|
-- reordered by topStack/bottomStack).
|
|
local managed = {}
|
|
|
|
local function reflow()
|
|
local cursor = padding
|
|
for _, child in ipairs(managed) do
|
|
if child.visible ~= false then
|
|
local _, _, cw, ch = child:getAbsolutes()
|
|
if direction == "vertical" then
|
|
child:rawSetDualDim(padding, cursor)
|
|
cursor = cursor + ch + gap
|
|
else
|
|
child:rawSetDualDim(cursor, padding)
|
|
cursor = cursor + cw + gap
|
|
end
|
|
end
|
|
end
|
|
-- Auto-size the frame to fit its content if no fractional scale given
|
|
if (sw or 0) == 0 and direction == "vertical" then
|
|
-- don't auto-resize width; height grows to fit
|
|
frame:rawSetDualDim(nil, nil, nil, cursor + padding - gap)
|
|
elseif (sh or 0) == 0 and direction == "horizontal" then
|
|
frame:rawSetDualDim(nil, nil, cursor + padding - gap)
|
|
end
|
|
end
|
|
|
|
frame.reflow = reflow
|
|
|
|
function frame:setGap(n)
|
|
gap = n
|
|
reflow()
|
|
end
|
|
|
|
function frame:setPadding(n)
|
|
padding = n
|
|
reflow()
|
|
end
|
|
|
|
-- Override newFrame etc. on this frame so children auto-register
|
|
local function wrapChild(child)
|
|
managed[#managed + 1] = child
|
|
child.OnSizeChanged(reflow)
|
|
child.OnDestroy(function()
|
|
for i, v in ipairs(managed) do
|
|
if v == child then table.remove(managed, i); break end
|
|
end
|
|
reflow()
|
|
end)
|
|
reflow()
|
|
return child
|
|
end
|
|
|
|
-- Intercept common constructors so anything added to this list is tracked.
|
|
-- We do this by wrapping the frame's methods after creation.
|
|
local _newFrame = frame.newFrame
|
|
local _newTextButton = frame.newTextButton
|
|
local _newTextLabel = frame.newTextLabel
|
|
local _newTextBox = frame.newTextBox
|
|
local _newImageLabel = frame.newImageLabel
|
|
local _newImageButton = frame.newImageButton
|
|
local _newProgressBar = frame.newProgressBar
|
|
local _newCheckbox = frame.newCheckbox
|
|
local _newSlider = frame.newSlider
|
|
|
|
function frame:newFrame(...) return wrapChild(_newFrame(self, ...)) end
|
|
function frame:newTextButton(...) return wrapChild(_newTextButton(self, ...)) end
|
|
function frame:newTextLabel(...) return wrapChild(_newTextLabel(self, ...)) end
|
|
function frame:newTextBox(...) return wrapChild(_newTextBox(self, ...)) end
|
|
function frame:newImageLabel(...) return wrapChild(_newImageLabel(self, ...)) end
|
|
function frame:newImageButton(...) return wrapChild(_newImageButton(self, ...)) end
|
|
function frame:newProgressBar(...) return wrapChild(_newProgressBar(self, ...)) end
|
|
function frame:newCheckbox(...) return wrapChild(_newCheckbox(self, ...)) end
|
|
function frame:newSlider(...) return wrapChild(_newSlider(self, ...)) end
|
|
|
|
-- Allow manually registering an externally created child
|
|
function frame:register(child)
|
|
return wrapChild(child)
|
|
end
|
|
|
|
reflow()
|
|
return frame
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 10. LAYOUT: GridFrame (fixed-column auto grid)
|
|
--
|
|
-- local grid = parent:newGridFrame(cols, cellW, cellH, x, y, sx, sy, gap, padding)
|
|
--
|
|
-- Methods grid:reflow() grid:setColumns(n)
|
|
-- Children are placed left-to-right, wrapping at `cols` columns.
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function gui:newGridFrame(cols, cellW, cellH, x, y, sx, sy, gap, padding)
|
|
cols = cols or 3
|
|
cellW = cellW or 100
|
|
cellH = cellH or 100
|
|
gap = gap or 4
|
|
padding = padding or 0
|
|
|
|
local totalW = cols * cellW + (cols - 1) * gap + padding * 2
|
|
local frame = self:newFrame(x or 0, y or 0, totalW, 0, sx, sy)
|
|
frame.drawBorder = false
|
|
frame.color = {0, 0, 0, 0}
|
|
frame.visibility = 0
|
|
|
|
local managed = {}
|
|
|
|
local function reflow()
|
|
local row, col = 0, 0
|
|
for _, child in ipairs(managed) do
|
|
local px = padding + col * (cellW + gap)
|
|
local py = padding + row * (cellH + gap)
|
|
child:rawSetDualDim(px, py, cellW, cellH)
|
|
col = col + 1
|
|
if col >= cols then
|
|
col = 0
|
|
row = row + 1
|
|
end
|
|
end
|
|
local rows = math.ceil(#managed / cols)
|
|
frame:rawSetDualDim(nil, nil, totalW, rows * (cellH + gap) - gap + padding * 2)
|
|
end
|
|
|
|
frame.reflow = reflow
|
|
|
|
function frame:setColumns(n)
|
|
cols = n
|
|
totalW = cols * cellW + (cols - 1) * gap + padding * 2
|
|
self:rawSetDualDim(nil, nil, totalW)
|
|
reflow()
|
|
end
|
|
|
|
local function wrapChild(child)
|
|
managed[#managed + 1] = child
|
|
child.OnDestroy(function()
|
|
for i, v in ipairs(managed) do
|
|
if v == child then table.remove(managed, i); break end
|
|
end
|
|
reflow()
|
|
end)
|
|
reflow()
|
|
return child
|
|
end
|
|
|
|
local _newFrame = frame.newFrame
|
|
local _newTextButton = frame.newTextButton
|
|
local _newTextLabel = frame.newTextLabel
|
|
local _newImageLabel = frame.newImageLabel
|
|
local _newImageButton = frame.newImageButton
|
|
|
|
function frame:newFrame(...) return wrapChild(_newFrame(self, ...)) end
|
|
function frame:newTextButton(...) return wrapChild(_newTextButton(self, ...)) end
|
|
function frame:newTextLabel(...) return wrapChild(_newTextLabel(self, ...)) end
|
|
function frame:newImageLabel(...) return wrapChild(_newImageLabel(self, ...)) end
|
|
function frame:newImageButton(...) return wrapChild(_newImageButton(self, ...)) end
|
|
|
|
function frame:register(child) return wrapChild(child) end
|
|
|
|
return frame
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 11. SCROLL FRAME AUTO-SIZING PATCH
|
|
--
|
|
-- Patches gui:newScrollFrame so the content frame automatically measures its
|
|
-- children and grows to fit them. No manual setContentSize() needed.
|
|
--
|
|
-- The original newScrollFrame is preserved as gui:newScrollFrameRaw if you
|
|
-- need the unpatched version.
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
do
|
|
-- Store the original (defined in addons/system.lua after require)
|
|
-- We wrap it after the module is loaded. Using a post-load hook via
|
|
-- the next iteration of the updater is not practical here, so we patch
|
|
-- directly. If addons.system hasn't been required yet this is a no-op and
|
|
-- the patch is applied lazily on first call.
|
|
local _original
|
|
|
|
local function wrap()
|
|
if _original then return end
|
|
_original = gui.newScrollFrame
|
|
if not _original then return end -- system addon not loaded yet
|
|
|
|
gui.newScrollFrameRaw = _original
|
|
|
|
gui.newScrollFrame = function(self, x, y, w, h, sx, sy, sw, sh)
|
|
local content = _original(self, x, y, w, h, sx, sy, sw, sh)
|
|
|
|
-- After each child is created under content, measure and resize.
|
|
local function measure()
|
|
local maxH = 0
|
|
local maxW = 0
|
|
for _, child in ipairs(content:getChildren()) do
|
|
local cx, cy, cw, ch = child:getAbsolutes()
|
|
local _, contentY = content:getAbsolutes()
|
|
local relBottom = (cy - contentY) + ch
|
|
local relRight = (cx - (select(1, content:getAbsolutes()))) + cw
|
|
if relBottom > maxH then maxH = relBottom end
|
|
if relRight > maxW then maxW = relRight end
|
|
end
|
|
-- Only grow, don't shrink (prevents thrash while items are being added)
|
|
local _, _, curW, curH = content:getAbsolutes()
|
|
if maxH > curH then content:setDualDim(nil, nil, nil, maxH + 4) end
|
|
end
|
|
|
|
content.OnCreated(function(child)
|
|
child.OnSizeChanged(measure)
|
|
child.OnDestroy(measure)
|
|
measure()
|
|
end)
|
|
|
|
return content
|
|
end
|
|
end
|
|
|
|
-- Attempt to patch immediately; if the addon isn't loaded yet this becomes
|
|
-- a one-shot deferred patch triggered on first call.
|
|
wrap()
|
|
|
|
-- Deferred fallback: wrap on first call if not already done
|
|
local _deferred = gui.newScrollFrame
|
|
if _deferred == _original or _original == nil then
|
|
gui.newScrollFrame = function(self, ...)
|
|
wrap() -- try again now that more modules may be loaded
|
|
return gui.newScrollFrame(self, ...)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 12. EASING LIBRARY
|
|
--
|
|
-- Extended transition easings to complement transition.glide.
|
|
--
|
|
-- USAGE (same pattern as glide):
|
|
--
|
|
-- local ease = transition.easings
|
|
--
|
|
-- local t = ease.easeInOut(0, 100, 0.4)()
|
|
-- t.OnStep(function(v) panel:setDualDim(v) end)
|
|
-- t.OnStop(function() print("done") end)
|
|
--
|
|
-- Available:
|
|
-- ease.easeIn(start, stop, time) -- accelerates from start
|
|
-- ease.easeOut(start, stop, time) -- decelerates into stop
|
|
-- ease.easeInOut(start, stop, time) -- smooth S-curve
|
|
-- ease.easeInCubic(...) -- more aggressive acceleration
|
|
-- ease.easeOutCubic(...) -- more aggressive deceleration
|
|
-- ease.bounce(start, stop, time) -- bounces at the end
|
|
-- ease.elastic(start, stop, time) -- overshoots then settles
|
|
-- ease.back(start, stop, time) -- slight pull-back before moving
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
do
|
|
local easings = {}
|
|
transition.easings = easings
|
|
|
|
-- Helper: build a transition from an easing function f(t) where t is 0→1
|
|
local function makeEasing(f)
|
|
return transition:newTransition(function(t, start, stop, time)
|
|
local steps = t.fps * time
|
|
local piece = time / steps
|
|
local range = stop - start
|
|
t.running = true
|
|
for i = 0, steps do
|
|
if not t.kill then
|
|
thread.sleep(piece)
|
|
local progress = i / steps
|
|
thread.pushStatus(start + f(progress) * range)
|
|
end
|
|
end
|
|
t.running = false
|
|
t.kill = false
|
|
end)
|
|
end
|
|
|
|
easings.easeIn = makeEasing(function(t)
|
|
return t * t
|
|
end)
|
|
|
|
easings.easeOut = makeEasing(function(t)
|
|
return t * (2 - t)
|
|
end)
|
|
|
|
easings.easeInOut = makeEasing(function(t)
|
|
return t < 0.5 and (2 * t * t) or (-1 + (4 - 2*t) * t)
|
|
end)
|
|
|
|
easings.easeInCubic = makeEasing(function(t)
|
|
return t * t * t
|
|
end)
|
|
|
|
easings.easeOutCubic = makeEasing(function(t)
|
|
local u = t - 1
|
|
return u * u * u + 1
|
|
end)
|
|
|
|
easings.bounce = makeEasing(function(t)
|
|
if t < 1/2.75 then
|
|
return 7.5625 * t * t
|
|
elseif t < 2/2.75 then
|
|
t = t - 1.5/2.75
|
|
return 7.5625 * t * t + 0.75
|
|
elseif t < 2.5/2.75 then
|
|
t = t - 2.25/2.75
|
|
return 7.5625 * t * t + 0.9375
|
|
else
|
|
t = t - 2.625/2.75
|
|
return 7.5625 * t * t + 0.984375
|
|
end
|
|
end)
|
|
|
|
easings.elastic = makeEasing(function(t)
|
|
if t == 0 or t == 1 then return t end
|
|
local p = 0.3
|
|
local s = p / 4
|
|
local u = t - 1
|
|
return -(math.pow(2, 10 * u) * math.sin((u - s) * (2 * math.pi) / p))
|
|
end)
|
|
|
|
easings.back = makeEasing(function(t)
|
|
local s = 1.70158
|
|
return t * t * ((s + 1) * t - s)
|
|
end)
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 13. FOCUS MANAGEMENT
|
|
--
|
|
-- gui.focus.set(element) -- programmatically set focus
|
|
-- gui.focus.get() -- returns currently focused element (or nil)
|
|
-- gui.focus.clear() -- clear focus (no element focused)
|
|
-- gui.focus.setTabOrder(list) -- set a list of elements for Tab navigation
|
|
-- gui.focus.tabNext() -- focus the next element in the tab order
|
|
-- gui.focus.tabPrev() -- focus the previous element
|
|
--
|
|
-- Per-element events (added to every element at creation via OnCreated):
|
|
-- element.OnFocusGained(function(self) end)
|
|
-- element.OnFocusLost(function(self) end)
|
|
--
|
|
-- NOTE: gui.focus tracks focus independently of the internal object_focus.
|
|
-- It is the public, programmable layer on top.
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
do
|
|
gui.focus = {}
|
|
|
|
local _current = nil
|
|
local _tabOrder = {}
|
|
|
|
-- Attach focus events to every new element
|
|
gui.Events.OnCreated(function(element)
|
|
if not element.OnFocusGained then
|
|
element.OnFocusGained = multi:newConnection()
|
|
end
|
|
if not element.OnFocusLost then
|
|
element.OnFocusLost = multi:newConnection()
|
|
end
|
|
end)
|
|
|
|
function gui.focus.set(element)
|
|
if _current == element then return end
|
|
if _current and _current.OnFocusLost then
|
|
_current.OnFocusLost:Fire(_current)
|
|
end
|
|
_current = element
|
|
if element and element.OnFocusGained then
|
|
element.OnFocusGained:Fire(element)
|
|
end
|
|
end
|
|
|
|
function gui.focus.get()
|
|
return _current
|
|
end
|
|
|
|
function gui.focus.clear()
|
|
gui.focus.set(nil)
|
|
end
|
|
|
|
function gui.focus.setTabOrder(list)
|
|
_tabOrder = list
|
|
end
|
|
|
|
function gui.focus.tabNext()
|
|
if #_tabOrder == 0 then return end
|
|
local idx = 1
|
|
for i, v in ipairs(_tabOrder) do
|
|
if v == _current then idx = i + 1; break end
|
|
end
|
|
if idx > #_tabOrder then idx = 1 end
|
|
gui.focus.set(_tabOrder[idx])
|
|
end
|
|
|
|
function gui.focus.tabPrev()
|
|
if #_tabOrder == 0 then return end
|
|
local idx = #_tabOrder
|
|
for i, v in ipairs(_tabOrder) do
|
|
if v == _current then idx = i - 1; break end
|
|
end
|
|
if idx < 1 then idx = #_tabOrder end
|
|
gui.focus.set(_tabOrder[idx])
|
|
end
|
|
|
|
-- Wire Tab / Shift+Tab globally
|
|
gui.Events.OnKeyPressed(function(key)
|
|
if key == "tab" then
|
|
if love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") then
|
|
gui.focus.tabPrev()
|
|
else
|
|
gui.focus.tabNext()
|
|
end
|
|
end
|
|
end)
|
|
|
|
-- Keep gui.focus in sync with mouse clicks on interactive elements.
|
|
-- We listen to the global ObjectFocusChanged which fires whenever any
|
|
-- element is pressed.
|
|
gui.Events.OnObjectFocusChanged(function(prev, next)
|
|
gui.focus.set(next)
|
|
end)
|
|
|
|
-- Elements that are destroyed should be removed from the tab order
|
|
-- and should lose focus if they currently hold it.
|
|
gui.Events.OnCreated(function(element)
|
|
element.OnDestroy(function(self)
|
|
if _current == self then gui.focus.clear() end
|
|
for i, v in ipairs(_tabOrder) do
|
|
if v == self then table.remove(_tabOrder, i); break end
|
|
end
|
|
end)
|
|
end)
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 14. Z-INDEX / LAYER SYSTEM
|
|
--
|
|
-- obj:setLayer(n) -- set a numeric z-index (higher = drawn on top)
|
|
-- obj:getLayer() -- returns current layer (default 0)
|
|
--
|
|
-- The draw order within each parent is sorted by layer every frame.
|
|
-- Elements with the same layer retain their insertion order (stable sort).
|
|
-- This replaces the need to call topStack() in OnUpdate for persistent
|
|
-- ordering requirements.
|
|
--
|
|
-- NOTE: Sorting the children table every frame is O(n log n) in the number
|
|
-- of siblings. For large flat hierarchies, assign layers once and call
|
|
-- gui.layers.pause() to skip re-sorting when nothing has changed.
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
do
|
|
gui.layers = {}
|
|
local _dirty = false -- set to true when any layer changes
|
|
|
|
function gui:setLayer(n)
|
|
self.__layer = n
|
|
_dirty = true
|
|
end
|
|
|
|
function gui:getLayer()
|
|
return self.__layer or 0
|
|
end
|
|
|
|
-- Stable sort: preserve relative order for equal layers
|
|
local function stableSort(t, lt)
|
|
-- insertion sort (stable and fast for small-to-medium sibling counts)
|
|
for i = 2, #t do
|
|
local key = t[i]
|
|
local j = i - 1
|
|
while j >= 1 and lt(key, t[j]) do
|
|
t[j + 1] = t[j]
|
|
j = j - 1
|
|
end
|
|
t[j + 1] = key
|
|
end
|
|
end
|
|
|
|
local function sortChildren(parent)
|
|
stableSort(parent.children, function(a, b)
|
|
return (a.__layer or 0) < (b.__layer or 0)
|
|
end)
|
|
for _, child in ipairs(parent.children) do
|
|
if #child.children > 0 then
|
|
sortChildren(child)
|
|
end
|
|
end
|
|
end
|
|
|
|
function gui.layers.pause() _dirty = false end
|
|
function gui.layers.resume() _dirty = true end
|
|
|
|
-- Re-sort the tree when layers change. We piggy-back on OnUpdate so this
|
|
-- runs before the draw loop.
|
|
gui:OnUpdate(function(_, dt)
|
|
if not _dirty then return end
|
|
_dirty = false
|
|
sortChildren(gui)
|
|
end)
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- Return the extension table for optional introspection
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
return ext
|