jeopardy/gui/addons/extensions_old.lua
2026-05-12 21:10:14 -07:00

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