diff --git a/anime/index.yml b/anime/index.yml index 7a917a0..db1eb6f 100644 --- a/anime/index.yml +++ b/anime/index.yml @@ -9,7 +9,7 @@ settings: categories: # the name of each category should correspond to a yaml file with the same name - name: shounen # name must be alphanumeric, cannot start with a number or contain special characters "-" and "." are ok if they are not at the beginning displayName: "Shounen" # if blank will use name - #image: "assets/14018-3193093789.gif" # if set will display image instead of name, categories are referenced by name which is required + image: "assets/14018-3193093789.gif" # if set will display image instead of name, categories are referenced by name which is required - name: openings displayName: "Openings" # if blank will use name - name: sadness diff --git a/board.lua b/board.lua index 187291c..714b004 100644 --- a/board.lua +++ b/board.lua @@ -150,7 +150,7 @@ local function buildBoard(frame, path) t.index = tier t.price = start + inc*(tier-1) t:OnReleased(boardUpdater:newFunction(function(self) - if self.text == "" then return end + if self.text == "" or GetActivePlayer() == nil then return end if dd_enabled then applyDD() -- check and run DD if conditions meet end diff --git a/gui/README.md b/gui/README.md index 149e2f3..761438c 100644 --- a/gui/README.md +++ b/gui/README.md @@ -37,4 +37,36 @@ Events: - Other Events - ~~OnUpdate~~ ✔️ - ~~OnDraw~~ ✔️ +Polish: + - ~~gui:newCheckbox()~~ + - ~~gui:isOnScreen()~~ + - ~~gui:newRadioGroup()~~ + - gui:newSlider() + - ~~gui:newProgressBar()~~ + - gui:newTooltip() + - gui:newTextArea() + - TODO: selection, hotkeys + - gui:newListFrame() + - gui:newGridFrame() +Better Transistions: + - 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 + +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 + +Z-index Enhancments: + - gui:setLayer(n) + - gui:getLayer() \ No newline at end of file diff --git a/gui/addons/extensions.lua b/gui/addons/extensions.lua new file mode 100644 index 0000000..ca1883c --- /dev/null +++ b/gui/addons/extensions.lua @@ -0,0 +1,167 @@ +local gui = require("gui") +local theme = require("gui.core.theme") +local color = require("gui.core.color") +local multi, thread = require("multi"):init() +local mediaProc = gui:newProcessor() + +local function noOf(sx,sy,sw,sh) + return nil,nil,nil,nil,sx,sy,sw,sh +end + +function gui:newVideoPlayer(source, x, y, w, h, sx, sy, sw, sh) + local window = gui:newWindow(x, y, w, h, source, true, theme:new({ + primary = "#000000", + primaryDark = "#10465c", + primaryText = "#ffffff" + })) + local video = window:newVideo(source, 0, 0, 0, 0, 0, .05, 1, .75) + local play_pause = window:newImageButton("gui/assets/play.png",0,0,0,0,.45,.82,0,.175) + local seek = window:newFrame(0,0,0,0,0,.8,1,.015) + seek.color = color.new("#3c434c") + local seeker = seek:newFrame(0,0,0,0,0,.1,0,.8) + seeker.drawBorder = false + seeker.color = color.new("#0c278a") + play_pause.square = "h" + play_pause.isPaused = true + play_pause:OnReleased(function(self) + if self.isPaused then + self:setImage("gui/assets/pause.png") + video:play() + else + self:setImage("gui/assets/play.png") + video:pause() + end + self.isPaused = not self.isPaused + end) + + local length = video:getDuration() + mediaProc:newThread(function() + while true do + thread.yield() + seeker:setDualDim(nil,nil,nil,nil,nil,nil,video:tell()/length) + end + end) + + -- print() + +end + +function gui:newCheckbox(label, x, y, size, sx, sy, checked) + local checkbox = self:newFrame(x, y, size, size, sx, sy) + checkbox.color = color.black + local border = checkbox:newVisualFrame(noOf(.1,.1,.8,.8)) + border.color = color.white + local toggle = border:newFrame(noOf(.3,.3,.4,.4)) + toggle.color = color.black + toggle.visible = false + + checkbox:OnReleased(function() + checkbox:check(not toggle.visible) + end) + + if label ~= "" then + local text = checkbox:newTextLabel(label, noOf(1.25,0,15,1)) + text:OnUpdate(function() + text:centerFont() + end) + text:setFont(size-2) + text.visibility = 0 + end + + function checkbox:check(value) + toggle.visible = value + self.OnChanged:Fire(value) + end + + function checkbox:isChecked() + return toggle.visible + end + + function checkbox:getLabel() + return label or "" + end + + checkbox.OnChanged = multi:newConnection() + + return checkbox +end + +function gui:newRadioGroup(options, x, y, sx, sy, size) + local group = {} + local rg = self:newFrame() + local selected + + rg.OnSelectionChanged = multi:newConnection() + + for i,v in ipairs(options or {}) do + table.insert(group,self:newCheckbox(tostring(v),x,y+((i-1)*size+((options.padding or 0)*(i-1))),size,sx,sy)) + end + + gui.apply({ + OnReleased=function(self) + gui.apply({check={false}},unpack(group)) + self:check(true) + if selected ~= self then + rg.OnSelectionChanged:Fire(rg, self) + end + selected = self + end, + },unpack(group)) + + function rg:getSelectedOption() + return selected + end + + return rg +end + +function gui:newProgressBar(x, y, w, h, sx, sy, sw, sh, count, value) + local value = value or 0 + local progressbar = self:newFrame(x,y,w,h,sx,sy,sw,sh) + local fillframe = progressbar:newFrame(noOf(.025, .1, .95, .8)) + local fill = fillframe:newFrame(noOf(0, 0, 1, 1)) + + fillframe.visibility = 0 + progressbar.color = color.new("#000000") + fill.color = color.new("#ffffff") + progressbar.fillframe = fillframe + progressbar.fill = fill + + function progressbar:update(value) + if value > count then value = count end + if value < 0 then value = 0 end + local percent = value/count + fill:setDualDim(noOf(nil,nil,percent)) + end + + function progressbar:add(n) + if value >= count then + return + end + value = value + n + self:update(value) + end + + function progressbar:sub(n) + if value <= 0 then + return + end + value = value - n + self:update(value) + end + + function progressbar:max() + value = count + self:update(value) + end + + function progressbar:min() + value = 0 + self:update(value) + end + + progressbar:update(value) + + -- to change colors and modify main components + return progressbar, fill, fillframe +end \ No newline at end of file diff --git a/gui/addons/extensions_old.lua b/gui/addons/extensions_old.lua new file mode 100644 index 0000000..3f1b34e --- /dev/null +++ b/gui/addons/extensions_old.lua @@ -0,0 +1,1376 @@ +--[[ + 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 diff --git a/gui/addons/init.lua b/gui/addons/init.lua index e69de29..84cae69 100644 --- a/gui/addons/init.lua +++ b/gui/addons/init.lua @@ -0,0 +1,3 @@ +-- Addons modify the gui interface directly and do not return anything. +require("gui.addons.extensions") +require("gui.addons.system") \ No newline at end of file diff --git a/gui/addons/players.lua b/gui/addons/players.lua deleted file mode 100644 index b8fe61e..0000000 --- a/gui/addons/players.lua +++ /dev/null @@ -1,43 +0,0 @@ -local gui = require("gui") -local theme = require("gui.core.theme") -local color = require("gui.core.color") -local multi, thread = require("multi"):init() -require("gui.addons.system") -local proc = gui:newProcessor() -function gui:newVideoPlayer(source, x, y, w, h, sx, sy, sw, sh) - local window = gui:newWindow(x, y, w, h, source, true, theme:new({ - primary = "#000000", - primaryDark = "#10465c", - primaryText = "#ffffff" - })) - local video = window:newVideo(source, 0, 0, 0, 0, 0, .05, 1, .75) - local play_pause = window:newImageButton("gui/assets/play.png",0,0,0,0,.45,.82,0,.175) - local seek = window:newFrame(0,0,0,0,0,.8,1,.015) - seek.color = color.new("#3c434c") - local seeker = seek:newFrame(0,0,0,0,0,.1,0,.8) - seeker.drawBorder = false - seeker.color = color.new("#0c278a") - play_pause.square = "h" - play_pause.isPaused = true - play_pause:OnReleased(function(self) - if self.isPaused then - self:setImage("gui/assets/pause.png") - video:play() - else - self:setImage("gui/assets/play.png") - video:pause() - end - self.isPaused = not self.isPaused - end) - - local length = video:getDuration() - proc:newThread(function() - while true do - thread.yield() - seeker:setDualDim(nil,nil,nil,nil,nil,nil,video:tell()/length) - end - end) - - -- print() - -end diff --git a/gui/addons/system.lua b/gui/addons/system.lua index 23450ed..862920c 100644 --- a/gui/addons/system.lua +++ b/gui/addons/system.lua @@ -155,149 +155,6 @@ local function collectTasks() return rows end --- ── window constructor (unchanged from original) ────────────────────────────── -local windowCount = 0 -function gui:newWindow(x, y, w, h, text, draggable, theme) - local process = gui:newProcessor(text or "window_"..windowCount) - windowCount = windowCount + 1 - local parent = self - local pointer = love.mouse.getCursor() - local sizewe = love.mouse.getSystemCursor("sizewe") - local sizens = love.mouse.getSystemCursor("sizens") - local sizenesw = love.mouse.getSystemCursor("sizenesw") - local sizenwse = love.mouse.getSystemCursor("sizenwse") - local theme = theme or default_theme - - local header = self:newFrame(x, y, w, 35) - header:setRoundness(10, 10, nil, "top") - local window = header:newFrame(0, 35, 0, h - 35, 0, 0, 1) - window.clipDescendants = true - local left = window:newFrame(0, -4, 4, 0, 0, 0, 0, 1):tag("left") - local right = window:newFrame(-4, -4, 4, 0, 1, 0, 0, 1):tag("right") - local bottom = window:newFrame(4, -4, -8, 4, 0, 1, 1):tag("bottom") - local bottomleft = window:newFrame(0, -4, 4, 4, 0, 1):tag("bleft") - local bottomright = window:newFrame(-4, -4, 4, 4, 1, 1):tag("bright") - gui.apply({ - visibility = 0, - I_enableDragging = {gui.MOUSE_PRIMARY}, - respectHierarchy = {false}, - OnUpdate = function(self) self:topStack() end, - OnDragging = function(self, dx, dy) - local ox, oy, ow, oh = header:getAbsolutes() - local tag = self:getTag() - if tag == "left" or tag == "bleft" then - window:size(0, dy) - header:move(dx, 0) - header:size(-dx, 0) - else - window:size(0, dy) - header:size(dx, 0) - end - local x, y, w, h = header:getAbsolutes() - if w < 200 and (tag == "left" or tag == "bleft") then - header:setDualDim(ox, nil, 200) - elseif w < 200 then - header:setDualDim(nil, nil, 200) - end - local x, y, w, h = window:getAbsolutes() - if h < 100 then window:setDualDim(nil, nil, nil, 100) end - end, - OnDragEnd = function(self) love.mouse.setCursor(pointer) end, - OnEnter = function(self) - local tag = self:getTag() - if tag == "left" or tag == "right" then - love.mouse.setCursor(sizewe) - elseif tag == "bleft" then - love.mouse.setCursor(sizenesw) - elseif tag == "bright" then - love.mouse.setCursor(sizenwse) - else - love.mouse.setCursor(sizens) - end - end, - OnExit = function(self) love.mouse.setCursor(pointer) end, - }, left, right, bottom, bottomleft, bottomright) - - local title = header:newTextLabel(text or "", 5, 0, w - 35, 35) - title.clipDescendants = true - title.visibility = 0 - title.ignore = true - title:setFont(theme.fontPrimary) - title:fitFont() - - function window:setTitle(t) title.text = t end - - local X = header:newTextButton("", -25, -25, 20, 20, 1, 1) - X:setRoundness(10, 10) - X.align = gui.ALIGN_CENTER - X.color = color.red - local darkenX = color.darken(color.red, .2) - X.OnEnter(function(self) self.color = darkenX end) - X.OnExit(function(self) self.color = color.red end) - - if draggable then - header:enableDragging(gui.MOUSE_PRIMARY) - header:OnDragging(function(self, dx, dy) self:move(dx, dy) end) - header:OnDragEnd(function(self) - local x, y, w, h = self:getAbsolutes() - local width, height = love.graphics.getDimensions() - if x <= 0 then self:setDualDim(0) end - if y <= 0 then self:setDualDim(nil, 0) end - if x + w >= width then self:setDualDim(width - w) end - if y + h >= height then self:setDualDim(nil, height - 35) end - end) - end - - window.OnClose = function() return window end % X.OnPressed - window.OnClose(function() - header:setParent(gui.virtual) - love.mouse.setCursor(pointer) - end) - function window:close() window.OnClose:Fire(self) end - function window:open() header:setParent(parent) end - - function window:setTheme(th) - theme = th - title.textColor = theme.colorPrimaryText - header.color = theme.colorPrimaryDark - window.color = theme.colorPrimary - end - function window:getTheme() return theme end - - process:newThread(function() window:setTheme(theme) end) - - window.OnSizeChanged(function() window:refresh() end) - function window:refresh() window:setTheme(theme) end - - window.process = process - window.OnCreated(function(element) - if element:hasType(gui.TYPE_BUTTON) then - element:setFont(theme.fontButton) - element.color = theme.colorButtonNormal - element.textColor = theme.colorButtonText - if not element.__registeredTheme then - element.OnEnter(function(self) self.color = theme.colorButtonHighlight end) - element.OnExit(function(self) self.color = theme.colorButtonNormal end) - end - element:fitFont() - element.__registeredTheme = true - elseif element:hasType(gui.TYPE_TEXT) then - element.color = theme.colorPrimary - element:setFont(theme.fontPrimary) - element.textColor = theme.colorPrimaryText - element:fitFont() - elseif element:hasType(gui.TYPE_FRAME) then - if element.__isHeader then - element.color = theme.colorPrimaryDark - else - element.color = theme.colorPrimary - end - end - end) - return window -end - --- ── scroll frame (unchanged from original) ──────────────────────────────────── function gui:newScrollFrame(x, y, w, h, sx, sy, sw, sh) local viewport = self:newFrame(x, y, w, h, sx, sy, sw, sh) viewport.clipDescendants = true @@ -453,6 +310,148 @@ function gui:newScrollFrame(x, y, w, h, sx, sy, sw, sh) return content end +-- ── window constructor (unchanged from original) ────────────────────────────── +local windowCount = 0 +function gui:newWindow(x, y, w, h, text, draggable, theme) + local process = gui:newProcessor(text or "window_"..windowCount) + windowCount = windowCount + 1 + local parent = self + local pointer = love.mouse.getCursor() + local sizewe = love.mouse.getSystemCursor("sizewe") + local sizens = love.mouse.getSystemCursor("sizens") + local sizenesw = love.mouse.getSystemCursor("sizenesw") + local sizenwse = love.mouse.getSystemCursor("sizenwse") + local theme = theme or default_theme + + local header = self:newFrame(x, y, w, 35) + header:setRoundness(10, 10, nil, "top") + local window = header:newFrame(0, 35, 0, h - 35, 0, 0, 1) + window.clipDescendants = true + local left = window:newFrame(0, -4, 4, 0, 0, 0, 0, 1):tag("left") + local right = window:newFrame(-4, -4, 4, 0, 1, 0, 0, 1):tag("right") + local bottom = window:newFrame(4, -4, -8, 4, 0, 1, 1):tag("bottom") + local bottomleft = window:newFrame(0, -4, 4, 4, 0, 1):tag("bleft") + local bottomright = window:newFrame(-4, -4, 4, 4, 1, 1):tag("bright") + gui.apply({ + visibility = 0, + I_enableDragging = {gui.MOUSE_PRIMARY}, + respectHierarchy = {false}, + OnUpdate = function(self) self:topStack() end, + OnDragging = function(self, dx, dy) + local ox, oy, ow, oh = header:getAbsolutes() + local tag = self:getTag() + if tag == "left" or tag == "bleft" then + window:size(0, dy) + header:move(dx, 0) + header:size(-dx, 0) + else + window:size(0, dy) + header:size(dx, 0) + end + local x, y, w, h = header:getAbsolutes() + if w < 200 and (tag == "left" or tag == "bleft") then + header:setDualDim(ox, nil, 200) + elseif w < 200 then + header:setDualDim(nil, nil, 200) + end + local x, y, w, h = window:getAbsolutes() + if h < 100 then window:setDualDim(nil, nil, nil, 100) end + end, + OnDragEnd = function(self) love.mouse.setCursor(pointer) end, + OnEnter = function(self) + local tag = self:getTag() + if tag == "left" or tag == "right" then + love.mouse.setCursor(sizewe) + elseif tag == "bleft" then + love.mouse.setCursor(sizenesw) + elseif tag == "bright" then + love.mouse.setCursor(sizenwse) + else + love.mouse.setCursor(sizens) + end + end, + OnExit = function(self) love.mouse.setCursor(pointer) end, + }, left, right, bottom, bottomleft, bottomright) + + local title = header:newTextLabel(text or "", 5, 0, w - 35, 35) + title.clipDescendants = true + title.visibility = 0 + title.ignore = true + title:setFont(theme.fontPrimary) + title:fitFont() + + function window:setTitle(t) title.text = t end + + local X = header:newTextButton("", -25, -25, 20, 20, 1, 1) + X:setRoundness(10, 10) + X.align = gui.ALIGN_CENTER + X.color = color.red + local darkenX = color.darken(color.red, .2) + X.OnEnter(function(self) self.color = darkenX end) + X.OnExit(function(self) self.color = color.red end) + + if draggable then + header:enableDragging(gui.MOUSE_PRIMARY) + header:OnDragging(function(self, dx, dy) self:move(dx, dy) end) + header:OnDragEnd(function(self) + local x, y, w, h = self:getAbsolutes() + local width, height = love.graphics.getDimensions() + if x <= 0 then self:setDualDim(0) end + if y <= 0 then self:setDualDim(nil, 0) end + if x + w >= width then self:setDualDim(width - w) end + if y + h >= height then self:setDualDim(nil, height - 35) end + end) + end + + window.OnClose = function() return window end % X.OnPressed + window.OnClose(function() + header:setParent(gui.virtual) + love.mouse.setCursor(pointer) + end) + function window:close() window.OnClose:Fire(self) end + function window:open() header:setParent(parent) end + + function window:setTheme(th) + theme = th + title.textColor = theme.colorPrimaryText + header.color = theme.colorPrimaryDark + window.color = theme.colorPrimary + end + function window:getTheme() return theme end + + process:newThread(function() window:setTheme(theme) end) + + window.OnSizeChanged(function() window:refresh() end) + function window:refresh() window:setTheme(theme) end + + window.process = process + window.OnCreated(function(element) + if element:hasType(gui.TYPE_BUTTON) then + element:setFont(theme.fontButton) + element.color = theme.colorButtonNormal + element.textColor = theme.colorButtonText + if not element.__registeredTheme then + element.OnEnter(function(self) self.color = theme.colorButtonHighlight end) + element.OnExit(function(self) self.color = theme.colorButtonNormal end) + end + element:fitFont() + element.__registeredTheme = true + elseif element:hasType(gui.TYPE_TEXT) then + element.color = theme.colorPrimary + element:setFont(theme.fontPrimary) + element.textColor = theme.colorPrimaryText + element:fitFont() + elseif element:hasType(gui.TYPE_FRAME) then + if element.__isHeader then + element.color = theme.colorPrimaryDark + else + element.color = theme.colorPrimary + end + end + end) + return window +end + -- ── row pool ────────────────────────────────────────────────────────────────── local COLOR_PROC_ROW = TM_THEME.colorPrimaryDark local COLOR_ROW_EVEN = TM_THEME.colorPrimary @@ -896,7 +895,7 @@ function gui:showTaskManager() -- ── load probe ──────────────────────────────────────────────────────────── -- Install once. getLoad() is now non-blocking — just reads the EMA state. - local schedulerProbe = require("gui.addons.probe") + local schedulerProbe = require("gui.core.probe") schedulerProbe:install(multi) -- ── main-thread update ──────────────────────────────────────────────────── diff --git a/gui/core/color.lua b/gui/core/color.lua index 716ac07..d0b191c 100644 --- a/gui/core/color.lua +++ b/gui/core/color.lua @@ -1,22 +1,22 @@ local color={} local mt = { __add = function (c1,c2) - return color.new(c1[1]+c2[1],c1[2]+c2[2],c1[2]+c2[2]) + return color.new(c1[1]+c2[1],c1[2]+c2[2],c1[3]+c2[3]) end, __sub = function (c1,c2) - return color.new(c1[1]-c2[1],c1[2]-c2[2],c1[2]-c2[2]) + return color.new(c1[1]-c2[1],c1[2]-c2[2],c1[3]-c2[3]) end, __mul = function (c1,c2) - return color.new(c1[1]*c2[1],c1[2]*c2[2],c1[2]*c2[2]) + return color.new(c1[1]*c2[1],c1[2]*c2[2],c1[3]*c2[3]) end, __div = function (c1,c2) - return color.new(c1[1]/c2[1],c1[2]/c2[2],c1[2]/c2[2]) + return color.new(c1[1]/c2[1],c1[2]/c2[2],c1[3]/c2[3]) end, __mod = function (c1,c2) - return color.new(c1[1]%c2[1],c1[2]%c2[2],c1[2]%c2[2]) + return color.new(c1[1]%c2[1],c1[2]%c2[2],c1[3]%c2[3]) end, __pow = function (c1,c2) - return color.new(c1[1]^c2[1],c1[2]^c2[2],c1[2]^c2[2]) + return color.new(c1[1]^c2[1],c1[2]^c2[2],c1[3]^c2[3]) end, __unm = function (c1) return color.new(-c1[1],-c1[2],-c1[2]) @@ -25,13 +25,13 @@ local mt = { return "("..c[1]..","..c[2]..","..c[3]..",".. (c[4] or "1") ..")" end, __eq = function (c1,c2) - return (c1[1]==c2[1] and c1[2]==c2[2] and c1[2]==c2[2]) + return (c1[1]==c2[1] and c1[2]==c2[2] and c1[3]==c2[3]) end, __lt = function (c1,c2) - return (c1[1] #data then return nil end - bitBuffer = bitBuffer + bit.lshift(string.byte(data, pos), bits) - bits = bits + 8 - pos = pos + 1 - end - - local code = bit.band(bitBuffer, bit.lshift(1, codeSize) - 1) - bitBuffer = bit.rshift(bitBuffer, codeSize) - bits = bits - codeSize - return code - end - - local first = true - - while true do - local code = readCode() - if not code or code == endCode then break end - - if code == clearCode then - dict = {} - for i = 0, clearCode - 1 do - dict[i] = {string.byte(string.char(i))} - end - nextCode = endCode + 1 - codeSize = minCodeSize + 1 - prevCode = nil - first = true - else - local entry - if dict[code] then - entry = dict[code] - elseif code == nextCode and prevCode then - -- Special case: code not in dict yet - entry = {} - for i = 1, #dict[prevCode] do - entry[i] = dict[prevCode][i] - end - entry[#entry + 1] = dict[prevCode][1] - else - -- Invalid code, stop - break - end - - -- Output the entry - for i = 1, #entry do - table.insert(output, entry[i]) - end - - -- Add new entry to dictionary - if not first and prevCode and nextCode < 4096 then - local newEntry = {} - for i = 1, #dict[prevCode] do - newEntry[i] = dict[prevCode][i] - end - newEntry[#newEntry + 1] = entry[1] - dict[nextCode] = newEntry - nextCode = nextCode + 1 - - -- Increase code size when needed - if nextCode >= bit.lshift(1, codeSize) and codeSize < 12 then - codeSize = codeSize + 1 - end - end - - prevCode = code - first = false - end - end - - -- Convert output bytes to string - local result = {} - for i = 1, #output do - result[i] = string.char(output[i]) - end - return table.concat(result) -end - -function GifLoader.load(filepath) - local fileData = love.filesystem.read(filepath) - if not fileData then - error("Could not read GIF file: " .. filepath) - end - - local gif = { - frames = {}, - frameData = {}, -- Store ImageData for frame composition - delays = {}, - currentFrame = 1, - timer = 0, - width = 0, - height = 0, - playing = true, - loop = true, - getWidth = function(self) - return self.width - end, - getHeight = function(self) - return self.height - end, - } - - -- Parse GIF header - local header = fileData:sub(1, 6) - if header ~= "GIF87a" and header ~= "GIF89a" then - error("Not a valid GIF file") - end - - -- Read logical screen descriptor - local pos = 7 - gif.width = string.byte(fileData, pos) + string.byte(fileData, pos + 1) * 256 - gif.height = string.byte(fileData, pos + 2) + string.byte(fileData, pos + 3) * 256 - - local packed = string.byte(fileData, pos + 4) - local hasGlobalColorTable = bit.band(packed, 0x80) ~= 0 - local backgroundColorIndex = string.byte(fileData, pos + 5) - - pos = pos + 7 - - -- Read global color table - local globalColorTable = {} - if hasGlobalColorTable then - local size = 2 ^ (bit.band(packed, 0x07) + 1) - for i = 1, size do - local r = string.byte(fileData, pos) / 255 - local g = string.byte(fileData, pos + 1) / 255 - local b = string.byte(fileData, pos + 2) / 255 - table.insert(globalColorTable, {r, g, b, 1}) - pos = pos + 3 - end - end - - -- Parse blocks - local delay = 0.1 - local transparentIndex = nil - local disposalMethod = 0 - local delayForNextFrame = 0.1 -- Track delay for next frame - - while pos <= #fileData do - local separator = string.byte(fileData, pos) - - if separator == 0x21 then -- Extension - local label = string.byte(fileData, pos + 1) - pos = pos + 2 - - if label == 0xF9 then -- Graphic Control Extension - local blockSize = string.byte(fileData, pos) - pos = pos + 1 - - local flags = string.byte(fileData, pos) - disposalMethod = bit.rshift(bit.band(flags, 0x1C), 2) - local hasTransparency = bit.band(flags, 0x01) ~= 0 - - local delayTime = string.byte(fileData, pos + 1) + string.byte(fileData, pos + 2) * 256 - -- GIF delay is in hundredths of a second, convert to seconds - -- Many GIFs use 0 or very small delays, set a minimum - if delayTime == 0 then - delayForNextFrame = 0.1 -- Default 100ms - elseif delayTime <= 2 then - delayForNextFrame = 0.02 -- Minimum 20ms for very fast animations - else - delayForNextFrame = delayTime / 100 - end - - if hasTransparency then - transparentIndex = string.byte(fileData, pos + 3) - else - transparentIndex = nil - end - - pos = pos + blockSize + 1 - else - -- Skip other extensions - repeat - local blockSize = string.byte(fileData, pos) - pos = pos + 1 - if blockSize > 0 then - pos = pos + blockSize - end - until blockSize == 0 - end - - elseif separator == 0x2C then -- Image Descriptor - pos = pos + 1 - - local left = string.byte(fileData, pos) + string.byte(fileData, pos + 1) * 256 - local top = string.byte(fileData, pos + 2) + string.byte(fileData, pos + 3) * 256 - local width = string.byte(fileData, pos + 4) + string.byte(fileData, pos + 5) * 256 - local height = string.byte(fileData, pos + 6) + string.byte(fileData, pos + 7) * 256 - - local imgPacked = string.byte(fileData, pos + 8) - local hasLocalColorTable = bit.band(imgPacked, 0x80) ~= 0 - local interlaced = bit.band(imgPacked, 0x40) ~= 0 - - pos = pos + 9 - - local colorTable = globalColorTable - if hasLocalColorTable then - local size = 2 ^ (bit.band(imgPacked, 0x07) + 1) - colorTable = {} - for i = 1, size do - local r = string.byte(fileData, pos) / 255 - local g = string.byte(fileData, pos + 1) / 255 - local b = string.byte(fileData, pos + 2) / 255 - table.insert(colorTable, {r, g, b, 1}) - pos = pos + 3 - end - end - - -- Read LZW minimum code size - local minCodeSize = string.byte(fileData, pos) - pos = pos + 1 - - -- Read compressed image data blocks - local compressedData = {} - while true do - local blockSize = string.byte(fileData, pos) - pos = pos + 1 - if blockSize == 0 then break end - table.insert(compressedData, fileData:sub(pos, pos + blockSize - 1)) - pos = pos + blockSize - end - - -- Decompress image data - local indexStream = decompressLZW(table.concat(compressedData), minCodeSize) - - -- Create image data - local imageData = love.image.newImageData(gif.width, gif.height) - - -- Fill with background if first frame - if #gif.frames == 0 and backgroundColorIndex and globalColorTable[backgroundColorIndex + 1] then - local bg = globalColorTable[backgroundColorIndex + 1] - for y = 0, gif.height - 1 do - for x = 0, gif.width - 1 do - imageData:setPixel(x, y, bg[1], bg[2], bg[3], bg[4]) - end - end - elseif #gif.frames > 0 then - -- Copy previous frame if needed - local prevData = gif.frameData[#gif.frameData] - imageData:paste(prevData, 0, 0, 0, 0, gif.width, gif.height) - end - - -- Draw current frame - if indexStream and #indexStream > 0 then - local idx = 1 - - -- Use mapPixel for faster pixel operations - local function setPixels(x, y, r, g, b, a) - if x >= left and x < left + width and y >= top and y < top + height then - local pixelIdx = (y - top) * width + (x - left) + 1 - if pixelIdx <= #indexStream then - local colorIndex = string.byte(indexStream, pixelIdx) - - -- Skip transparent pixels - if transparentIndex == nil or colorIndex ~= transparentIndex then - if colorTable[colorIndex + 1] then - local color = colorTable[colorIndex + 1] - return color[1], color[2], color[3], color[4] - end - end - end - end - return r, g, b, a - end - - -- Only update the region where the frame is located - for y = top, top + height - 1 do - for x = left, left + width - 1 do - local pixelIdx = (y - top) * width + (x - left) + 1 - if pixelIdx <= #indexStream then - local colorIndex = string.byte(indexStream, pixelIdx) - - -- Skip transparent pixels - if transparentIndex == nil or colorIndex ~= transparentIndex then - if colorTable[colorIndex + 1] then - local color = colorTable[colorIndex + 1] - imageData:setPixel(x, y, color[1], color[2], color[3], color[4]) - end - end - end - end - end - end - - table.insert(gif.frameData, imageData) - table.insert(gif.frames, love.graphics.newImage(imageData)) - table.insert(gif.delays, delayForNextFrame) - - -- Reset delay for next frame - delayForNextFrame = 0.1 - - elseif separator == 0x3B then -- Trailer - break - else - pos = pos + 1 - end - end - - if #gif.frames == 0 then - error("No frames found in GIF") - end - - -- Ensure all frames have valid delays - for i = 1, #gif.delays do - if gif.delays[i] <= 0 or gif.delays[i] ~= gif.delays[i] then -- check for 0 or NaN - gif.delays[i] = 0.1 - end - end - - return gif -end - -function GifLoader.Updater(gif, proc) - local wait = function() - return gif.playing or gif.kill - end - proc:newThread("Gif Handler",function() - while true do - -- Only run if not paused - if gif.kill then -- When we want to clean up - thread.kill() - end - thread.hold(wait) - thread.sleep(gif.delays[gif.currentFrame] * 4) - gif.currentFrame = gif.currentFrame + 1 - - if gif.currentFrame > #gif.frames then - if gif.loop then - gif.currentFrame = 1 - else - gif.currentFrame = #gif.frames - gif.playing = false - gif.timer = 0 - end - end - end - end) -end - -function GifLoader.update(gif, dt) - if not gif.playing or #gif.frames <= 1 then return end - - gif.timer = gif.timer + dt - - -- Simple, accurate frame advancement - if gif.timer >= gif.delays[gif.currentFrame] then - -- Subtract the current frame's delay - gif.timer = gif.timer - gif.delays[gif.currentFrame] - - -- Move to next frame - gif.currentFrame = gif.currentFrame + 1 - - if gif.currentFrame > #gif.frames then - if gif.loop then - gif.currentFrame = 1 - else - gif.currentFrame = #gif.frames - gif.playing = false - gif.timer = 0 - end - end - - -- If we've accumulated too much time (lag spike), cap it - if gif.timer > 0.5 then - gif.timer = 0 - end - end -end - -function GifLoader.draw(gif, x, y, r, sx, sy, ox, oy) - if gif.frames[gif.currentFrame] then - love.graphics.draw(gif.frames[gif.currentFrame], x, y, r or 0, sx or 1, sy or 1, ox or 0, oy or 0) - end -end - -function GifLoader.play(gif) - gif.playing = true -end - -function GifLoader.pause(gif) - gif.playing = false -end - -function GifLoader.reset(gif) - gif.currentFrame = 1 - gif.timer = 0 -end - -function GifLoader.setFixedFramerate(gif, fps) - local delay = 1 / fps - for i = 1, #gif.delays do - gif.delays[i] = delay - end -end - -function GifLoader.getInfo(gif) - return { - width = gif.width, - height = gif.height, - frameCount = #gif.frames, - delays = gif.delays, -- Show actual delays for debugging - totalDuration = (function() - local total = 0 - for i = 1, #gif.delays do - total = total + gif.delays[i] - end - return total - end)() - } -end - -return GifLoader - --- USAGE: ---[[ -local GifLoader = require("gifloader") - -function love.load() - myGif = GifLoader.load("animation.gif") - myGif.loop = true -end - -function love.update(dt) - GifLoader.update(myGif, dt) -end - -function love.draw() - GifLoader.draw(myGif, 100, 100) - - -- Draw scaled - GifLoader.draw(myGif, 300, 100, 0, 2, 2) -end -]] \ No newline at end of file +-- GIF Loader for Love2D with LZW Decompression +-- Note: love.data.compress/decompress don't support LZW, so we implement it + +local GifLoader = {} + +-- Pure Lua LZW decompression for GIF +local function decompressLZW(data, minCodeSize) + local clearCode = 2 ^ minCodeSize + local endCode = clearCode + 1 + local nextCode = endCode + 1 + local codeSize = minCodeSize + 1 + + local dict = {} + for i = 0, clearCode - 1 do + dict[i] = {string.byte(string.char(i))} + end + + local output = {} + local bits = 0 + local bitBuffer = 0 + local pos = 1 + local prevCode = nil + + local function readCode() + while bits < codeSize do + if pos > #data then return nil end + bitBuffer = bitBuffer + bit.lshift(string.byte(data, pos), bits) + bits = bits + 8 + pos = pos + 1 + end + + local code = bit.band(bitBuffer, bit.lshift(1, codeSize) - 1) + bitBuffer = bit.rshift(bitBuffer, codeSize) + bits = bits - codeSize + return code + end + + local first = true + + while true do + local code = readCode() + if not code or code == endCode then break end + + if code == clearCode then + dict = {} + for i = 0, clearCode - 1 do + dict[i] = {string.byte(string.char(i))} + end + nextCode = endCode + 1 + codeSize = minCodeSize + 1 + prevCode = nil + first = true + else + local entry + if dict[code] then + entry = dict[code] + elseif code == nextCode and prevCode then + -- Special case: code not in dict yet + entry = {} + for i = 1, #dict[prevCode] do + entry[i] = dict[prevCode][i] + end + entry[#entry + 1] = dict[prevCode][1] + else + -- Invalid code, stop + break + end + + -- Output the entry + for i = 1, #entry do + table.insert(output, entry[i]) + end + + -- Add new entry to dictionary + if not first and prevCode and nextCode < 4096 then + local newEntry = {} + for i = 1, #dict[prevCode] do + newEntry[i] = dict[prevCode][i] + end + newEntry[#newEntry + 1] = entry[1] + dict[nextCode] = newEntry + nextCode = nextCode + 1 + + -- Increase code size when needed + if nextCode >= bit.lshift(1, codeSize) and codeSize < 12 then + codeSize = codeSize + 1 + end + end + + prevCode = code + first = false + end + end + + -- Convert output bytes to string + local result = {} + for i = 1, #output do + result[i] = string.char(output[i]) + end + return table.concat(result) +end + +function GifLoader.load(filepath) + local fileData = love.filesystem.read(filepath) + if not fileData then + error("Could not read GIF file: " .. filepath) + end + + local gif = { + frames = {}, + frameData = {}, -- Store ImageData for frame composition + delays = {}, + currentFrame = 1, + timer = 0, + width = 0, + height = 0, + playing = true, + loop = true, + getWidth = function(self) + return self.width + end, + getHeight = function(self) + return self.height + end, + } + + -- Parse GIF header + local header = fileData:sub(1, 6) + if header ~= "GIF87a" and header ~= "GIF89a" then + error("Not a valid GIF file") + end + + -- Read logical screen descriptor + local pos = 7 + gif.width = string.byte(fileData, pos) + string.byte(fileData, pos + 1) * 256 + gif.height = string.byte(fileData, pos + 2) + string.byte(fileData, pos + 3) * 256 + + local packed = string.byte(fileData, pos + 4) + local hasGlobalColorTable = bit.band(packed, 0x80) ~= 0 + local backgroundColorIndex = string.byte(fileData, pos + 5) + + pos = pos + 7 + + -- Read global color table + local globalColorTable = {} + if hasGlobalColorTable then + local size = 2 ^ (bit.band(packed, 0x07) + 1) + for i = 1, size do + local r = string.byte(fileData, pos) / 255 + local g = string.byte(fileData, pos + 1) / 255 + local b = string.byte(fileData, pos + 2) / 255 + table.insert(globalColorTable, {r, g, b, 1}) + pos = pos + 3 + end + end + + -- Parse blocks + local delay = 0.1 + local transparentIndex = nil + local disposalMethod = 0 + local delayForNextFrame = 0.1 -- Track delay for next frame + + while pos <= #fileData do + local separator = string.byte(fileData, pos) + + if separator == 0x21 then -- Extension + local label = string.byte(fileData, pos + 1) + pos = pos + 2 + + if label == 0xF9 then -- Graphic Control Extension + local blockSize = string.byte(fileData, pos) + pos = pos + 1 + + local flags = string.byte(fileData, pos) + disposalMethod = bit.rshift(bit.band(flags, 0x1C), 2) + local hasTransparency = bit.band(flags, 0x01) ~= 0 + + local delayTime = string.byte(fileData, pos + 1) + string.byte(fileData, pos + 2) * 256 + -- GIF delay is in hundredths of a second, convert to seconds + -- Many GIFs use 0 or very small delays, set a minimum + if delayTime == 0 then + delayForNextFrame = 0.1 -- Default 100ms + elseif delayTime <= 2 then + delayForNextFrame = 0.02 -- Minimum 20ms for very fast animations + else + delayForNextFrame = delayTime / 100 + end + + if hasTransparency then + transparentIndex = string.byte(fileData, pos + 3) + else + transparentIndex = nil + end + + pos = pos + blockSize + 1 + else + -- Skip other extensions + repeat + local blockSize = string.byte(fileData, pos) + pos = pos + 1 + if blockSize > 0 then + pos = pos + blockSize + end + until blockSize == 0 + end + + elseif separator == 0x2C then -- Image Descriptor + pos = pos + 1 + + local left = string.byte(fileData, pos) + string.byte(fileData, pos + 1) * 256 + local top = string.byte(fileData, pos + 2) + string.byte(fileData, pos + 3) * 256 + local width = string.byte(fileData, pos + 4) + string.byte(fileData, pos + 5) * 256 + local height = string.byte(fileData, pos + 6) + string.byte(fileData, pos + 7) * 256 + + local imgPacked = string.byte(fileData, pos + 8) + local hasLocalColorTable = bit.band(imgPacked, 0x80) ~= 0 + local interlaced = bit.band(imgPacked, 0x40) ~= 0 + + pos = pos + 9 + + local colorTable = globalColorTable + if hasLocalColorTable then + local size = 2 ^ (bit.band(imgPacked, 0x07) + 1) + colorTable = {} + for i = 1, size do + local r = string.byte(fileData, pos) / 255 + local g = string.byte(fileData, pos + 1) / 255 + local b = string.byte(fileData, pos + 2) / 255 + table.insert(colorTable, {r, g, b, 1}) + pos = pos + 3 + end + end + + -- Read LZW minimum code size + local minCodeSize = string.byte(fileData, pos) + pos = pos + 1 + + -- Read compressed image data blocks + local compressedData = {} + while true do + local blockSize = string.byte(fileData, pos) + pos = pos + 1 + if blockSize == 0 then break end + table.insert(compressedData, fileData:sub(pos, pos + blockSize - 1)) + pos = pos + blockSize + end + + -- Decompress image data + local indexStream = decompressLZW(table.concat(compressedData), minCodeSize) + + -- Create image data + local imageData = love.image.newImageData(gif.width, gif.height) + + -- Fill with background if first frame + if #gif.frames == 0 and backgroundColorIndex and globalColorTable[backgroundColorIndex + 1] then + local bg = globalColorTable[backgroundColorIndex + 1] + for y = 0, gif.height - 1 do + for x = 0, gif.width - 1 do + imageData:setPixel(x, y, bg[1], bg[2], bg[3], bg[4]) + end + end + elseif #gif.frames > 0 then + -- Copy previous frame if needed + local prevData = gif.frameData[#gif.frameData] + imageData:paste(prevData, 0, 0, 0, 0, gif.width, gif.height) + end + + -- Draw current frame + if indexStream and #indexStream > 0 then + local idx = 1 + + -- Use mapPixel for faster pixel operations + local function setPixels(x, y, r, g, b, a) + if x >= left and x < left + width and y >= top and y < top + height then + local pixelIdx = (y - top) * width + (x - left) + 1 + if pixelIdx <= #indexStream then + local colorIndex = string.byte(indexStream, pixelIdx) + + -- Skip transparent pixels + if transparentIndex == nil or colorIndex ~= transparentIndex then + if colorTable[colorIndex + 1] then + local color = colorTable[colorIndex + 1] + return color[1], color[2], color[3], color[4] + end + end + end + end + return r, g, b, a + end + + -- Only update the region where the frame is located + for y = top, top + height - 1 do + for x = left, left + width - 1 do + local pixelIdx = (y - top) * width + (x - left) + 1 + if pixelIdx <= #indexStream then + local colorIndex = string.byte(indexStream, pixelIdx) + + -- Skip transparent pixels + if transparentIndex == nil or colorIndex ~= transparentIndex then + if colorTable[colorIndex + 1] then + local color = colorTable[colorIndex + 1] + imageData:setPixel(x, y, color[1], color[2], color[3], color[4]) + end + end + end + end + end + end + + table.insert(gif.frameData, imageData) + table.insert(gif.frames, love.graphics.newImage(imageData)) + table.insert(gif.delays, delayForNextFrame) + + -- Reset delay for next frame + delayForNextFrame = 0.1 + + elseif separator == 0x3B then -- Trailer + break + else + pos = pos + 1 + end + end + + if #gif.frames == 0 then + error("No frames found in GIF") + end + + -- Ensure all frames have valid delays + for i = 1, #gif.delays do + if gif.delays[i] <= 0 or gif.delays[i] ~= gif.delays[i] then -- check for 0 or NaN + gif.delays[i] = 0.1 + end + end + + return gif +end + +function GifLoader.Updater(gif, proc) + local wait = function() + return gif.playing or gif.kill + end + proc:newThread("Gif Handler",function() + while true do + -- Only run if not paused + if gif.kill then -- When we want to clean up + thread.kill() + end + thread.hold(wait) + thread.sleep(gif.delays[gif.currentFrame] * 4) + gif.currentFrame = gif.currentFrame + 1 + + if gif.currentFrame > #gif.frames then + if gif.loop then + gif.currentFrame = 1 + else + gif.currentFrame = #gif.frames + gif.playing = false + gif.timer = 0 + end + end + end + end) +end + +function GifLoader.update(gif, dt) + if not gif.playing or #gif.frames <= 1 then return end + + gif.timer = gif.timer + dt + + -- Simple, accurate frame advancement + if gif.timer >= gif.delays[gif.currentFrame] then + -- Subtract the current frame's delay + gif.timer = gif.timer - gif.delays[gif.currentFrame] + + -- Move to next frame + gif.currentFrame = gif.currentFrame + 1 + + if gif.currentFrame > #gif.frames then + if gif.loop then + gif.currentFrame = 1 + else + gif.currentFrame = #gif.frames + gif.playing = false + gif.timer = 0 + end + end + + -- If we've accumulated too much time (lag spike), cap it + if gif.timer > 0.5 then + gif.timer = 0 + end + end +end + +function GifLoader.draw(gif, x, y, r, sx, sy, ox, oy) + if gif.frames[gif.currentFrame] then + love.graphics.draw(gif.frames[gif.currentFrame], x, y, r or 0, sx or 1, sy or 1, ox or 0, oy or 0) + end +end + +function GifLoader.play(gif) + gif.playing = true +end + +function GifLoader.pause(gif) + gif.playing = false +end + +function GifLoader.reset(gif) + gif.currentFrame = 1 + gif.timer = 0 +end + +function GifLoader.setFixedFramerate(gif, fps) + local delay = 1 / fps + for i = 1, #gif.delays do + gif.delays[i] = delay + end +end + +function GifLoader.getInfo(gif) + return { + width = gif.width, + height = gif.height, + frameCount = #gif.frames, + delays = gif.delays, -- Show actual delays for debugging + totalDuration = (function() + local total = 0 + for i = 1, #gif.delays do + total = total + gif.delays[i] + end + return total + end)() + } +end + +return GifLoader diff --git a/gui/addons/probe.lua b/gui/core/probe.lua similarity index 100% rename from gui/addons/probe.lua rename to gui/core/probe.lua diff --git a/gui/core/simulate.lua b/gui/core/simulate.lua index 89172d1..15484dd 100644 --- a/gui/core/simulate.lua +++ b/gui/core/simulate.lua @@ -1,6 +1,6 @@ local gui = require("gui") local multi, thread = require("multi"):init() -local transition = require("gui.elements.transitions") +local transition = require("gui.core.transitions") -- Triggers press then release local function getPosition(obj, x, y) diff --git a/gui/elements/transitions.lua b/gui/core/transitions.lua similarity index 95% rename from gui/elements/transitions.lua rename to gui/core/transitions.lua index 53de95f..768ef0c 100644 --- a/gui/elements/transitions.lua +++ b/gui/core/transitions.lua @@ -1,78 +1,78 @@ -local gui = require("gui") -local multi, thread = require("multi"):init() -local processor = gui:newProcessor("Transistion Processor") -local transition = {} - -local width, height, flags = love.window.getMode() -local fps = 60 - -if flags.refreshrate > 0 then - fps = flags.refreshrate -end - -transition.__index = transition -transition.__call = function(t, start, stop, time, ...) - local args = {...} - return function(st, sp, ti) -- allow these values to be overridden - if not (st or start) or not (sp or stop) then return multi.error("start and stop must be supplied") end - if start == stop then - local temp = { - OnStep = function() end, - OnStop = multi:newConnection() - } - proc:newTask(function() - temp.OnStop:Fire() - end) - return temp - end - local handle = t.func(t, start, stop, (ti or time) or 1, unpack(args)) - return { - OnStep = handle.OnStatus, - OnStop = handle.OnReturn + handle.OnError, - Kill = t.Kill - } - end -end - -function transition:newTransition(func) - local c = {} - setmetatable(c, self) - - c.fps = fps - c.func = processor:newFunction(func) - c.OnStop = multi:newConnection() - c.kill = false - - function c:SetFPS(fps) - self.fps = fps - end - - function c:GetFPS(fps) - return self.fps - end - - function c:Kill() - if c.running then - c.kill = true - end - end - - return c -end - -transition.glide = transition:newTransition(function(t, start, stop, time, ...) - local steps = t.fps*time - local piece = time/steps - local split = stop-start - t.running = true - for i = 0, steps do - if not(t.kill) then - thread.sleep(piece) - thread.pushStatus(start + i*(split/steps),piece*i) - end - end - t.running = false - t.kill = false -end) - +local gui = require("gui") +local multi, thread = require("multi"):init() +local processor = gui:newProcessor("Transistion Processor") +local transition = {} + +local width, height, flags = love.window.getMode() +local fps = 60 + +if flags.refreshrate > 0 then + fps = flags.refreshrate +end + +transition.__index = transition +transition.__call = function(t, start, stop, time, ...) + local args = {...} + return function(st, sp, ti) -- allow these values to be overridden + if not (st or start) or not (sp or stop) then return multi.error("start and stop must be supplied") end + if start == stop then + local temp = { + OnStep = function() end, + OnStop = multi:newConnection() + } + proc:newTask(function() + temp.OnStop:Fire() + end) + return temp + end + local handle = t.func(t, start, stop, (ti or time) or 1, unpack(args)) + return { + OnStep = handle.OnStatus, + OnStop = handle.OnReturn + handle.OnError, + Kill = t.Kill + } + end +end + +function transition:newTransition(func) + local c = {} + setmetatable(c, self) + + c.fps = fps + c.func = processor:newFunction(func) + c.OnStop = multi:newConnection() + c.kill = false + + function c:SetFPS(fps) + self.fps = fps + end + + function c:GetFPS(fps) + return self.fps + end + + function c:Kill() + if c.running then + c.kill = true + end + end + + return c +end + +transition.glide = transition:newTransition(function(t, start, stop, time, ...) + local steps = t.fps*time + local piece = time/steps + local split = stop-start + t.running = true + for i = 0, steps do + if not(t.kill) then + thread.sleep(piece) + thread.pushStatus(start + i*(split/steps),piece*i) + end + end + t.running = false + t.kill = false +end) + return transition \ No newline at end of file diff --git a/gui/docs/gui-library-docs.md b/gui/docs/gui-library-docs.md index e410a19..5da4675 100644 --- a/gui/docs/gui-library-docs.md +++ b/gui/docs/gui-library-docs.md @@ -1,62 +1,54 @@ # GUI Library Documentation -A component-based UI framework for LÖVE2D (Love2D) built on top of the `multi` concurrency library. The library provides a scene graph with dual-dimension layout, event-driven input handling, and a rich set of built-in element types. +A LÖVE2D-based GUI framework with a dual-dimension layout system, event-driven architecture, theming, transitions, and a rich set of built-in widgets. --- ## Table of Contents -1. [Setup & Initialization](#setup--initialization) -2. [Core Concepts](#core-concepts) - - [The Scene Graph](#the-scene-graph) - - [Dual-Dimension Layout (DualDim)](#dual-dimension-layout-dualdim) - - [Element Types (Bitmask)](#element-types-bitmask) - - [Form Factors](#form-factors) -3. [Creating Elements](#creating-elements) - - [Frames](#frames) - - [Text Labels](#text-labels) - - [Text Buttons](#text-buttons) - - [Text Boxes (Input)](#text-boxes-input) - - [Image Labels](#image-labels) - - [Image Buttons](#image-buttons) - - [Videos](#videos) -4. [Layout & Positioning](#layout--positioning) -5. [Events & Connections](#events--connections) - - [Global GUI Events](#global-gui-events) - - [Per-Element Events](#per-element-events) - - [Hot Keys](#hot-keys) -6. [Element Methods](#element-methods) - - [Positioning & Sizing](#positioning--sizing) - - [Visual Properties](#visual-properties) - - [Hierarchy & Parenting](#hierarchy--parenting) - - [Utilities](#utilities) -7. [Text Elements](#text-elements) - - [Font Management](#font-management) - - [Text Box Internals](#text-box-internals) -8. [Image Elements](#image-elements) -9. [Clipping & Scissor](#clipping--scissor) -10. [Roundness & Shape](#roundness--shape) -11. [Aspect Ratio & Resize Handling](#aspect-ratio--resize-handling) -12. [The `apply` Helper](#the-apply-helper) -13. [Tagging System](#tagging-system) -14. [Cloning Elements](#cloning-elements) -15. [Processors & Threading](#processors--threading) -16. [Drawing Internals](#drawing-internals) -17. [Virtual GUI](#virtual-gui) +1. [Setup & Integration](#1-setup--integration) +2. [The Dual-Dimension Layout System](#2-the-dual-dimension-layout-system) +3. [Core Widget Reference](#3-core-widget-reference) + - [Frame](#31-frame) + - [TextLabel](#32-textlabel) + - [TextButton](#33-textbutton) + - [TextBox (Input)](#34-textbox-input) + - [ImageLabel](#35-imagelabel) + - [ImageButton](#36-imagebutton) + - [Video](#37-video) +4. [Base Object API](#4-base-object-api) + - [Positioning & Sizing](#41-positioning--sizing) + - [Appearance](#42-appearance) + - [Visibility & Lifecycle](#43-visibility--lifecycle) + - [Interaction](#44-interaction) + - [Shape & Form Factor](#45-shape--form-factor) +5. [Events & Connections](#5-events--connections) + - [Per-Object Events](#51-per-object-events) + - [Global Events](#52-global-events) + - [Hotkeys](#53-hotkeys) +6. [Theming](#6-theming) +7. [Color Module](#7-color-module) +8. [Transitions & Animation](#8-transitions--animation) +9. [Add-on Widgets](#9-add-on-widgets) + - [Window](#91-window) + - [ScrollFrame](#92-scrollframe) + - [Slide-in Menu](#93-slide-in-menu) + - [Video Player](#94-video-player) +10. [Canvas](#10-canvas) +11. [Simulation (Testing)](#11-simulation-testing) +12. [Scheduler Probe (Load Monitoring)](#12-scheduler-probe-load-monitoring) +13. [Task Manager](#13-task-manager) +14. [Tips & Patterns](#14-tips--patterns) --- -## Setup & Initialization +## 1. Setup & Integration + +Require the library at the top of your project. The library hooks into LÖVE's callbacks automatically. ```lua -local gui = require("path.to.gui") -``` +local gui = require("gui") -The library self-initializes on `require`. It hooks into LÖVE's callback system automatically (quit, resize, mouse, keyboard, touch, gamepad, etc.) and starts its internal update and draw processors. - -In your `love.update` and `love.draw`: - -```lua function love.update(dt) gui.update(dt) end @@ -66,801 +58,1065 @@ function love.draw() end ``` -> **Note:** The library hooks LÖVE callbacks via a `Hook` function that wraps any pre-existing handler you define. Define your own `love.*` callbacks **before** `require`-ing the library, or they will be chained automatically. +That is all that is required. The library installs its own hooks for `love.keypressed`, `love.mousepressed`, `love.resize`, etc., so you do not need to forward those manually. + +### Creating Additional Processors + +The library runs on the `multi` scheduler. If you need background work to integrate with the GUI update cycle, use `gui:newProcessor`: + +```lua +local proc = gui:newProcessor("MyProcessor") +proc:newThread(function() ... end) +``` --- -## Core Concepts +## 2. The Dual-Dimension Layout System -### The Scene Graph - -The library maintains two root nodes: - -| Root | Description | -|---|---| -| `gui` | The main scene root. All elements created with `gui:newXxx()` are parented here by default. | -| `gui.virtual` | A secondary root for off-screen or hidden elements. Children here are not drawn but still have their absolute positions updated. | - -Elements form a tree. Every element has a `parent`, a `children` table, and inherits methods from `gui` via `__index`. - -### Dual-Dimension Layout (DualDim) - -Every element stores its position and size as a **dual dimension**: a combination of a scale component (relative to the parent) and an offset component (absolute pixels). +Every object's size and position is described by **eight numbers** that combine pixel offsets with fractional (0–1) scale values relative to the parent. This is the library's central concept. ``` -actualX = parent.w * scale.pos.x + offset.pos.x + parent.x -actualY = parent.h * scale.pos.y + offset.pos.y + parent.y -actualW = parent.w * scale.size.x + offset.size.x -actualH = parent.h * scale.size.y + offset.size.y +setDualDim(x, y, w, h, sx, sy, sw, sh) ``` -Constructor signature for `newDualDim` / all `newXxx` creation functions: +| Parameter | Meaning | +|-----------|---------| +| `x` | Pixel offset from parent's left edge | +| `y` | Pixel offset from parent's top edge | +| `w` | Pixel width | +| `h` | Pixel height | +| `sx` | Fractional X position (0 = left, 1 = right of parent) | +| `sy` | Fractional Y position (0 = top, 1 = bottom of parent) | +| `sw` | Fractional width (0 = 0px, 1 = full parent width) | +| `sh` | Fractional height (0 = 0px, 1 = full parent height) | + +The resolved absolute values are calculated as: ``` -x, y, w, h -- pixel offset for position and size -sx, sy, sw, sh -- scale (0–1) for position and size +absolute_x = parent.w * sx + x + parent.x +absolute_y = parent.h * sy + y + parent.y +absolute_w = parent.w * sw + w +absolute_h = parent.h * sh + h ``` -Examples: +### Examples ```lua --- 200×100 box at pixel position (50, 50): -gui:newFrame(50, 50, 200, 100) +-- Full-screen frame (fills parent completely) +frame:setDualDim(0, 0, 0, 0, 0, 0, 1, 1) --- Full-screen frame (uses scale only): -local f = gui:newFrame() -f:fullFrame() -- sets scale size to (1,1) and offset to (0,0,0,0) +-- Fixed 200×50 button in the top-left corner +btn:setDualDim(10, 10, 200, 50) --- Half-width, 40px tall, starting at 25% from left: -gui:newFrame(0, 100, 0, 40, 0.25, 0, 0.5, 0) +-- Centered horizontally, 300px wide, 5% from the top +panel:setDualDim(-150, 0, 300, 0, 0.5, 0.05, 0, 0.4) +-- sx=0.5 puts the left edge at the parent's midpoint; +-- x=-150 shifts it left by half the panel's own width. + +-- Right-aligned sidebar, 20% of parent width, full height +sidebar:setDualDim(0, 0, 0, 0, 0.8, 0, 0.2, 1) + +-- Anchored to bottom-right corner, fixed 100×30 +btn:setDualDim(-110, -40, 100, 30, 1, 1) ``` -Retrieve the computed screen-space rectangle at any time: +### Helper: `fullFrame()` + +Sets the object to fill its parent completely (equivalent to `setDualDim(0,0,0,0,0,0,1,1)`). ```lua -local x, y, w, h = element:getAbsolutes() +frame:fullFrame() ``` -### Element Types (Bitmask) - -Types are stored as a bitmask so an element can have multiple roles: - -| Constant | Value | Meaning | -|---|---|---| -| `gui.TYPE_FRAME` | 0 | Basic container | -| `gui.TYPE_IMAGE` | 1 | Renders an image | -| `gui.TYPE_TEXT` | 2 | Renders text | -| `gui.TYPE_BOX` | 4 | Text input cursor/selection overlay | -| `gui.TYPE_VIDEO` | 8 | Renders a video | -| `gui.TYPE_BUTTON` | 16 | Interactive button (sets hand cursor) | -| `gui.TYPE_ANIM` | 32 | Animation / spritesheet | - -Test membership: +### Reading Position ```lua -if element:hasType(gui.TYPE_TEXT) then ... end -if element:hasType(gui.TYPE_TEXT + gui.TYPE_BOX) then ... end -- is a text box +local x, y, w, h = obj:getAbsolutes() -- resolved pixel values + +local x, y, w, h, sx, sy, sw, sh = obj:getDualDim() -- raw dual-dim values ``` -### Form Factors +### Squaring -Controls the shape used for both fills and hit-testing: - -| Constant | Shape | -|---|---| -| `gui.FORM_RECTANGLE` | Rounded or plain rectangle (default) | -| `gui.FORM_CIRCLE` | Circle; `w` and `h` are set to `2*r` | -| `gui.FORM_ARC` | Arc segment | +Setting `obj.square = "w"` forces `h = w` (width-driven square). Setting `obj.square = "h"` forces `w = h` (height-driven square). Useful for icon buttons and circle elements. --- -## Creating Elements +## 3. Core Widget Reference -All creation functions are called on a **parent** element (or on `gui` itself for top-level elements). The new element is automatically inserted into the parent's `children` table. - -### Frames - -A plain container with a background fill and optional border. +All constructors follow the same signature pattern: ```lua -local frame = parent:newFrame(x, y, w, h, sx, sy, sw, sh) +parent:newXxx(x, y, w, h, sx, sy, sw, sh) ``` -A **virtual frame** is parented to `gui.virtual` regardless of the caller: +where `parent` is either `gui` (the root) or another object. Children are drawn on top of and clipped by (if `clipDescendants` is set) their parent. + +--- + +### 3.1 Frame + +A plain rectangular container. The base building block. ```lua -local vframe = parent:newVirtualFrame(x, y, w, h, sx, sy, sw, sh) +local panel = gui:newFrame(x, y, w, h, sx, sy, sw, sh) ``` -A **visual frame** is a regular frame tagged `"visual"`. Mouse events on it and its descendants are suppressed (useful for purely decorative overlays): - ```lua -local overlay = parent:newVisualFrame(x, y, w, h, sx, sy, sw, sh) +-- Example: a full-screen dark overlay +local overlay = gui:newFrame(0, 0, 0, 0, 0, 0, 1, 1) +overlay.color = {0, 0, 0} +overlay.visibility = 0.6 ``` -### Text Labels - -A non-interactive text element. +**Virtual Frame** — exists in memory and updates but is never drawn. Used to move objects off-screen without destroying them. ```lua -local label = parent:newTextLabel("Hello world", x, y, w, h, sx, sy, sw, sh) +local vframe = gui:newVirtualFrame(...) ``` -### Text Buttons - -A text element that fires pointer events and shows a hand cursor on hover. +**Visual Frame** — a frame that does not participate in mouse hit-testing. Children of a visual frame are also non-interactive. ```lua -local btn = parent:newTextButton("Click me", x, y, w, h, sx, sy, sw, sh) -btn.OnPressed(function(self, x, y) print("pressed!") end) +local display = gui:newVisualFrame(...) ``` -### Text Boxes (Input) +--- -A single-line text input field. +### 3.2 TextLabel + +A non-interactive frame that renders text. ```lua -local box = parent:newTextBox("default text", x, y, w, h, sx, sy, sw, sh) -box.OnReturn(function(self, text) print("Submitted:", text) end) +local label = parent:newTextLabel("Hello, World!", x, y, w, h, sx, sy, sw, sh) ``` -Keyboard navigation, backspace/delete, selection (click-drag or Ctrl+A), copy/paste/cut, and undo/redo are all handled automatically when the box has focus. +**Key properties:** -### Image Labels +| Property | Type | Description | +|----------|------|-------------| +| `text` | string | The displayed text | +| `textColor` | color | Text color (default: black) | +| `font` | Font | LÖVE font object | +| `align` | constant | `gui.ALIGN_LEFT`, `gui.ALIGN_CENTER`, or `gui.ALIGN_RIGHT` | +| `textOffsetX/Y` | number | Pixel nudge for text rendering | +| `textScaleX/Y` | number | Scale multiplier for text | +| `textVisibility` | number | 0–1 alpha for text only | -A non-interactive image element. +**Font methods:** + +```lua +label:setFont(16) -- set by size (default font) +label:setFont("fonts/myfont.ttf", 18) -- set by file + size +label:setFont(myLoveFont) -- set by existing font object + +label:fitFont(minSize, maxSize) -- auto-fit text to the element's bounds +label:centerFont() -- vertically center text within element +``` + +--- + +### 3.3 TextButton + +An interactive button with text. Automatically shows a hand cursor on hover. + +```lua +local btn = parent:newTextButton("Click Me", x, y, w, h, sx, sy, sw, sh) +``` + +```lua +btn.OnReleased(function(self, x, y, button, istouch) + print("Button clicked!") +end) +``` + +Buttons automatically integrate with the theming system inside a `newWindow` — they receive button colors, hover highlights, and the button font. + +--- + +### 3.4 TextBox (Input) + +An editable single-line text input field. Gains focus on click and shows a blinking cursor. + +```lua +local input = parent:newTextBox("placeholder", x, y, w, h, sx, sy, sw, sh) +``` + +**Key properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `text` | string | Current text value | +| `cur_pos` | number | Cursor position (character index) | +| `blink` | boolean | Whether cursor blinks (default: true) | + +**Selection API:** + +```lua +input:HasSelection() -- boolean +input:GetSelection() -- start, stop (always start <= stop) +input:GetSelectedText() -- string +input:ClearSelection() +``` + +**Events:** + +```lua +input.OnReturn(function(self, text) + print("Submitted:", text) +end) +``` + +**Built-in hotkeys** (active when the textbox has focus): +- `Ctrl+A` — select all +- `Ctrl+C` — copy selection +- `Ctrl+V` — paste +- `Ctrl+X` — cut selection +- `Left/Right` — move cursor +- `Backspace/Delete` — delete character + +--- + +### 3.5 ImageLabel + +Displays an image. Supports PNG, JPG, and GIF. ```lua local img = parent:newImageLabel("path/to/image.png", x, y, w, h, sx, sy, sw, sh) ``` -GIF files are detected automatically by the `.gif` extension and animated. +The image is stretched to fill the element's bounds. GIFs animate automatically. -### Image Buttons - -An image element that fires pointer events and shows a hand cursor on hover. +**Changing the image:** ```lua -local ibtn = parent:newImageButton("icon.png", x, y, w, h, sx, sy, sw, sh) -ibtn.OnPressed(function(self, x, y) print("image clicked") end) +img:setImage("path/to/other.png") +img:setImage(loveImageObject) +-- Tile from a spritesheet: +img:setImage("spritesheet.png", srcX, srcY, srcW, srcH) ``` -### Videos - -Wraps a LÖVE `Video` object. +**Flipping:** ```lua -local vid = parent:newVideo("clip.ogv", x, y, w, h, sx, sy, sw, sh) -vid:play() -vid.OnVideoFinished(function(self) print("done") end) +img:flip() -- flip horizontally +img:flip(true) -- flip vertically ``` -Video methods: - -| Method | Description | -|---|---| -| `vid:setVideo(path_or_video)` | Load or swap the video source | -| `vid:play()` | Start playback | -| `vid:pause()` | Pause without rewinding | -| `vid:stop()` | Pause and rewind | -| `vid:rewind()` | Seek to start | -| `vid:seek(seconds)` | Jump to position | -| `vid:tell()` | Return current playback position (seconds) | -| `vid:getDuration()` | Return total duration (seconds) | -| `vid:setVolume(vol)` | Set audio volume (0–1) | -| `vid:getVideo()` | Return the underlying LÖVE Video object | - ---- - -## Layout & Positioning - -### Setting the Dual Dimension +**Gradient fill** (applies a gradient image to any frame or image element): ```lua --- Fires OnSizeChanged -element:setDualDim(x, y, w, h, sx, sy, sw, sh) - --- Silent version (no event) -element:rawSetDualDim(x, y, w, h, sx, sy, sw, sh) - --- Read back -local x, y, w, h, sx, sy, sw, sh = element:getDualDim() +panel:applyGradient("vertical", color1, color2, color3) +panel:applyGradient("horizontal", {1,0,0,1}, {0,0,1,1}) ``` -Pass `nil` for any argument to keep the current value. - -### Moving and Resizing +**Pre-loading images into cache:** ```lua --- Delta move (fires OnPositionChanged) -element:move(dx, dy) - --- Delta resize (fires OnSizeChanged) -element:size(dw, dh) - --- Move but clamp to parent bounds -element:moveInBounds(dx, dy) -``` - -### Centering - -```lua -element:centerX(true) -- horizontally center within parent -element:centerY(true) -- vertically center within parent -``` - -These attach internal loops that continuously recompute the offset whenever the element's size or position changes. - -### Convenience - -```lua -element:fullFrame() -- scale size (1,1), offset (0,0,0,0) — fills parent -``` - -### Dragging - -```lua -element:enableDragging(button) -- button = love mouse button number (1=left, 2=right, …) -element:enableDragging(nil) -- disable dragging -``` - -While dragging, `OnDragging`, `OnDragStart`, and `OnDragEnd` are fired. - -### Z-Order - -```lua -element:topStack() -- move to end of parent.children (drawn last = on top) -element:bottomStack() -- move to front of parent.children (drawn first = behind) +gui.cacheImage(gui, "assets/hero.png") +gui.cacheImage(gui, {"assets/a.png", "assets/b.png"}) ``` --- -## Events & Connections +### 3.6 ImageButton -Events use the `multi` connection system. Connect a handler by calling the connection as a function: +An image that responds to click events. Shows a hand cursor on hover. ```lua -element.OnPressed(function(self, x, y, button, istouch, presses) - -- ... +local btn = parent:newImageButton("icon.png", x, y, w, h, sx, sy, sw, sh) + +btn.OnReleased(function(self) + self:setImage("icon_pressed.png") end) ``` -Connections support composition: - -```lua --- OR: fires when either fires -(connA + connB)(handler) - --- AND: fires only when both conditions are met -(connA * connB)(handler) -``` - -### Global GUI Events - -These fire for the entire application window regardless of which element is focused. - -| Event | LÖVE callback | Arguments | -|---|---|---| -| `gui.Events.OnQuit` | `love.quit` | — | -| `gui.Events.OnDirectoryDropped` | `love.directorydropped` | `dir` | -| `gui.Events.OnDisplayRotated` | `love.displayrotated` | `index, orient` | -| `gui.Events.OnFilesDropped` | `love.filedropped` | `file` | -| `gui.Events.OnFocus` | `love.focus` | `focused` | -| `gui.Events.OnMouseFocus` | `love.mousefocus` | `focused` | -| `gui.Events.OnResized` | `love.resize` | `w, h` | -| `gui.Events.OnVisible` | `love.visible` | `visible` | -| `gui.Events.OnKeyPressed` | `love.keypressed` | `key, scancode, isrepeat` | -| `gui.Events.OnKeyReleased` | `love.keyreleased` | `key, scancode` | -| `gui.Events.OnTextEdited` | `love.textedited` | `text, start, length` | -| `gui.Events.OnTextInputed` | `love.textinput` | `text` | -| `gui.Events.OnMouseMoved` | `love.mousemoved` | `x, y, dx, dy, istouch` | -| `gui.Events.OnMousePressed` | `love.mousepressed` | `x, y, button, istouch, presses` | -| `gui.Events.OnMouseReleased` | `love.mousereleased` | `x, y, button, istouch, presses` | -| `gui.Events.OnWheelMoved` | `love.wheelmoved` | `x, y` | -| `gui.Events.OnTouchMoved` | `love.touchmoved` | `id, x, y, dx, dy, pressure` | -| `gui.Events.OnTouchPressed` | `love.touchpressed` | `id, x, y, dx, dy, pressure` | -| `gui.Events.OnTouchReleased` | `love.touchreleased` | `id, x, y, dx, dy, pressure` | -| `gui.Events.OnGamepadPressed` | `love.gamepadpressed` | `joystick, button` | -| `gui.Events.OnGamepadReleased` | `love.gamepadreleased` | `joystick, button` | -| `gui.Events.OnGamepadAxis` | `love.gamepadaxis` | `joystick, axis, value` | -| `gui.Events.OnJoystickAdded` | `love.joystickadded` | `joystick` | -| `gui.Events.OnJoystickRemoved` | `love.joystickremoved` | `joystick` | -| `gui.Events.OnJoystickHat` | `love.joystickhat` | `joystick, hat, dir` | -| `gui.Events.OnJoystickPressed` | `love.joystickpressed` | `joystick, button` | -| `gui.Events.OnJoystickReleased` | `love.joystickreleased` | `joystick, button` | -| `gui.Events.OnCreated` | internal | `element` — fires when any element is created | -| `gui.Events.OnObjectFocusChanged` | internal | `old, new` — fires when click focus changes | - -### Per-Element Events - -These are attached to each element instance. All mouse/pointer events are automatically pre-filtered: they only fire when the element is `active` and (for most events) when the pointer is within the element's bounds. - -| Event | Fires when… | -|---|---| -| `OnLoad` | (manual) element is "loaded" — user-defined | -| `OnPressed` | pointer pressed **inside** element | -| `OnPressedOuter` | pointer pressed **outside** element | -| `OnReleased` | pointer released **inside** element | -| `OnReleasedOuter` | pointer released **outside** (but was pressed inside) | -| `OnReleasedOther` | pointer released with no relevant press history | -| `OnDragStart` | drag begins (element must have `enableDragging` set) | -| `OnDragging` | pointer moves while dragging | -| `OnDragEnd` | drag ends | -| `OnEnter` | pointer enters the element bounds | -| `OnExit` | pointer leaves the element bounds | -| `OnMoved` | pointer moves while inside (or while dragging) | -| `OnWheelMoved` | scroll wheel moves while pointer is inside element | -| `OnSizeChanged` | `setDualDim` or `size` called | -| `OnPositionChanged` | `setDualDim` or `move` called | -| `OnDestroy` | element is about to be destroyed | -| `OnCreated` | element was created (forwarded from `gui.Events.OnCreated`) | -| `OnReturn` | (text boxes only) Enter/Return key pressed | -| `OnFontUpdated` | (text elements only) font changed via `setFont` | -| `OnVideoFinished` | (video elements only) video reaches its end | -| `OnLeftStickUp/Down/Left/Right` | gamepad left-stick events | -| `OnRightStickUp/Down/Left/Right` | gamepad right-stick events | - -#### Hierarchy Mode - -By default events fire if another element is not on top. Call: - -```lua -element:respectHierarchy(false) -- events will fire regardless -``` - -to make `OnPressed`, `OnReleased`, `OnEnter`, and `OnMoved` skip when the element is covered by a sibling. - --- -### Hot Keys +### 3.7 Video -Register a keyboard shortcut that fires a connection: +Plays a LÖVE-supported video file inside an element. ```lua -local conn = element:setHotKey({"lctrl", "s"}) -- returns a connection -conn(function(ref) print("Ctrl+S on", ref) end) +local vid = parent:newVideo("movie.ogv", x, y, w, h, sx, sy, sw, sh) ``` -You may pass an existing connection as the second argument to reuse it. - -#### Built-in Hot Keys - -| Hot Key | Trigger | -|---|---| -| `gui.HotKeys.OnSelectAll` | Ctrl+A | -| `gui.HotKeys.OnCopy` | Ctrl+C | -| `gui.HotKeys.OnPaste` | Ctrl+V | -| `gui.HotKeys.OnCut` | Ctrl+X | -| `gui.HotKeys.OnUndo` | Ctrl+Z | -| `gui.HotKeys.OnRedo` | Ctrl+Y / Ctrl+Shift+Z | - -These are already wired to the currently-focused text box for standard editing operations. - ---- - -## Element Methods - -### Positioning & Sizing - -| Method | Description | -|---|---| -| `el:getAbsolutes([transform])` | Returns `x, y, w, h` in screen space. Optional `transform` function is applied to each value. | -| `el:setDualDim(x,y,w,h,sx,sy,sw,sh)` | Set layout, fires `OnSizeChanged`. | -| `el:rawSetDualDim(...)` | Set layout, no event. | -| `el:getDualDim()` | Returns all 8 dual-dim components. | -| `el:move(dx, dy)` | Translate by delta, fires `OnPositionChanged`. | -| `el:size(dw, dh)` | Resize by delta, fires `OnSizeChanged`. | -| `el:moveInBounds(dx, dy)` | Translate while keeping element inside parent. | -| `el:fullFrame()` | Fill parent entirely. | -| `el:centerX(bool)` | Auto-center horizontally. | -| `el:centerY(bool)` | Auto-center vertically. | -| `el:getLocalCords(mx, my)` | Convert screen coordinates to element-local coordinates. | - -### Visual Properties - -| Property | Type | Default | Description | -|---|---|---|---| -| `color` | `{r,g,b}` | `{0.6, 0.6, 0.6}` | Background fill color | -| `borderColor` | `{r,g,b}` | black | Border color | -| `drawBorder` | boolean | `true` | Whether to draw the border | -| `visibility` | number | `1` | Background alpha (0–1) | -| `rotation` | number | `0` | Rotation in degrees | -| `active` | boolean | `true` | When `false`, element and all descendants ignore input | -| `visible` | boolean | `true` | Controls `getAllChildren` visibility filter | -| `ignore` | boolean | — | When `true`, element is skipped in coverage tests | - -Set color (also sets `visibility` if a 4th component is present): +**Playback control:** ```lua -element:setColor("color", {1, 0, 0, 0.8}) -element:setColor("borderColor", {0, 0, 0}) +vid:play() +vid:pause() +vid:stop() -- pause + rewind +vid:rewind() +vid:seek(t) -- seek to time in seconds +vid:tell() -- returns current time in seconds +vid:getDuration() +vid:setVolume(0.8) ``` -Apply a LÖVE shader: +**Events:** ```lua -element.shader = love.graphics.newShader(...) -``` - -Apply an effect wrapper (called around the draw call): - -```lua -element.effect = function(drawFunc) - love.graphics.push() - -- setup - drawFunc() - love.graphics.pop() -end -``` - -Apply a post-draw hook: - -```lua -element.post = function(self) - -- called after drawing, inside the same scissor/shader state -end -``` - -### Hierarchy & Parenting - -| Method | Description | -|---|---| -| `el:setParent(newParent)` | Re-parent element. Pass `nil` to detach. | -| `el:getChildren()` | Returns direct children table. | -| `el:getAllChildren([includeHidden])` | Returns all visible descendants recursively. | -| `el:isDescendantOf(obj)` | Returns `true` if `obj` is an ancestor of `el`. | -| `el:topStack()` | Draw on top of siblings. | -| `el:bottomStack()` | Draw behind siblings. | -| `el:destroy()` | Destroy element, its children, and all connections. | -| `el:removeChildren()` | Destroy all children but leave element itself. | -| `el:isActive()` | `true` if `active` and not parented under `gui.virtual`. | -| `el:isOffScreen()` | `true` if element rect is entirely outside screen bounds. | - -### Utilities - -| Method | Description | -|---|---| -| `el:hasType(t)` | Bitmask type test. | -| `el:canPress(mx, my)` | `true` if point is inside element (respects clip area). | -| `el:isBeingCovered(mx, my)` | `true` if a sibling is in front of this element at the given point. | -| `el:intersecpt(x, y, w, h)` | Returns intersection rect with a given AABB. | -| `el:newThread(func)` | Spawn a coroutine-style thread scoped to this element. | -| `el:getObjectFocus()` | Returns the currently focused element. | -| `el:getProcessor()` | Returns the internal updater processor. | - ---- - -## Text Elements - -All text elements (`newTextLabel`, `newTextButton`, `newTextBox`) inherit from `newTextBase`. - -### Properties - -| Property | Type | Default | Description | -|---|---|---|---| -| `text` | string | — | Displayed string | -| `textColor` | `{r,g,b}` | black | Text color | -| `font` | Font | 12px default | LÖVE Font object | -| `align` | constant | `ALIGN_LEFT` | `gui.ALIGN_LEFT`, `ALIGN_CENTER`, `ALIGN_RIGHT` | -| `textOffsetX/Y` | number | `0` | Additional pixel offset for text drawing | -| `textScaleX/Y` | number | `1` | Scale applied to text rendering | -| `textShearingFactorX/Y` | number | `0` | Shearing factor for text transform | -| `textVisibility` | number | `1` | Text alpha (0–1) | - -### Font Management - -```lua --- By size (default font) -element:setFont(14) - --- By path and size -element:setFont("fonts/myfont.ttf", 18) - --- By LÖVE font object -element:setFont(love.graphics.newFont("fonts/myfont.ttf", 18)) -``` - -Automatically resize font to fill element bounds: - -```lua --- Binary-search fit between min and max size -element:fitFont(minSize, maxSize, {scale = 1}) --- Returns bestFont, bestSize -``` - -Center text vertically inside the element: - -```lua -element:centerFont(y_offset) -``` - -Calculate where the top and bottom of rendered text actually are (pixel offsets within element): - -```lua -local top, bottom = element:calculateFontOffset(font, adjust) -``` - -### Text Box Internals - -| Property | Description | -|---|---| -| `cur_pos` | Integer cursor position (0 = before first character) | -| `selection` | `{start, stop}` character indices (may be reversed) | -| `bar_show` | `true` when the cursor bar should be visible (blinks via internal thread) | -| `doSelection` | `true` while a drag-selection is in progress | - -Methods: - -```lua -box:HasSelection() -- returns true/false -box:GetSelection() -- returns start, stop (always start ≤ stop) -box:GetSelectedText() -- returns selected substring -box:ClearSelection() -- clear selection state +vid.OnVideoFinished(function(self) + print("Video ended") +end) ``` --- -## Image Elements +## 4. Base Object API -All image elements (`newImageLabel`, `newImageButton`) inherit from `newImageBase`. +Every object in the hierarchy inherits the following API. -### `setImage` +--- + +### 4.1 Positioning & Sizing ```lua --- From a file path (PNG, JPG, etc.) -element:setImage("path/to/image.png") - --- GIF animation (auto-detected by extension) -element:setImage("path/to/anim.gif") - --- From a LÖVE Image object -element:setImage(loveImageObject) -``` - -### Properties - -| Property | Description | -|---|---| -| `imageColor` | Tint color applied when drawing | -| `imageVisibility` | Image alpha (0–1) | -| `scaleX / scaleY` | Flip/scale. Negative values flip the axis. | -| `quad` | LÖVE Quad used for rendering (sub-region) | - -### Flipping - -```lua -element:flip(false) -- flip horizontally -element:flip(true) -- flip vertically -``` - -### Gradient - -Apply a gradient as the image of any element: - -```lua -element:applyGradient("horizontal", {r,g,b,a}, {r,g,b,a}, ...) -element:applyGradient("vertical", {r,g,b,a}, {r,g,b,a}, ...) -``` - -### Image Caching - -```lua --- Pre-load a single image into the cache -gui.cacheImage(gui, "path/to/img.png") - --- Pre-load multiple images; reports progress via OnStatus -gui.cacheImage(gui, {"img1.png", "img2.png"}) - --- Tile helper: returns imagedata and quad -local imgdata, quad = gui:getTile("sheet.png", tileX, tileY, tileW, tileH) +obj:setDualDim(x, y, w, h, sx, sy, sw, sh) -- update position/size; nil preserves current value +obj:getAbsolutes() -- returns resolved x, y, w, h in pixels +obj:getDualDim() -- returns all 8 raw values +obj:move(dx, dy) -- increment pixel offset (fires OnPositionChanged) +obj:size(dx, dy) -- increment pixel size (fires OnSizeChanged) +obj:moveInBounds(dx, dy) -- move but keep within parent bounds +obj:fullFrame() -- shorthand for 100%×100% fill +obj:centerX(true) -- auto-center horizontally within parent +obj:centerY(true) -- auto-center vertically within parent ``` --- -## Clipping & Scissor - -Clipping is set on a **parent** and affects all descendants: +### 4.2 Appearance ```lua -parent.clipDescendants = true -``` +obj.color = {r, g, b} -- background color (0–1 range) +obj.borderColor = {r, g, b} -- border color +obj.drawBorder = true/false -- show/hide border +obj.visibility = 0.8 -- overall opacity 0–1 +obj.rotation = 45 -- rotation in degrees -During each draw pass, the parent propagates its screen-space rectangle to each child's `__variables.clip`. Children then apply LÖVE's scissor test to avoid drawing outside the parent. +obj:setRoundness(rx, ry, segments) -- round all corners +obj:setRoundness(rx, ry, seg, "top") -- round top corners only +obj:setRoundness(rx, ry, seg, "bottom") -- round bottom corners only + +obj.shader = myShader -- apply a LÖVE shader (image elements only) +obj.clipDescendants = true -- clip child rendering to this element's bounds +``` --- -## Roundness & Shape +### 4.3 Visibility & Lifecycle ```lua --- Rounded corners -element:setRoundness(rx, ry, segments, side) --- rx, ry: x/y radius (default 5) --- segments: arc segments (default 30) --- side: "top", "bottom", or true (all corners) +obj.visible = false -- hide (and stop receiving events) +obj.active = false -- deactivate without hiding --- Directional override -element:setRoundnessDirection(horizontal, vertical) +obj:isActive() -- true if active and not in the virtual tree +obj:isOffScreen() -- true if fully outside the window + +obj:topStack() -- move to top of parent's draw order (drawn last = on top) +obj:bottomStack() -- move to bottom of draw order + +obj:destroy() -- remove from tree, disconnect all events, free resources +obj:removeChildren() -- destroy all children but keep the object itself ``` -Circle and arc shapes are set at creation time: +--- + +### 4.4 Interaction + +```lua +-- Drag support +obj:enableDragging(gui.MOUSE_PRIMARY) -- enable drag with left mouse button +obj:enableDragging(gui.MOUSE_SECONDARY) -- enable drag with right button +obj:enableDragging(false) -- disable dragging + +-- Hierarchy hit-testing: only fires events when not occluded by a sibling +obj:respectHierarchy(true) + +-- Tag system (used for identifying objects and filtering events) +obj:tag("myTag") -- set the primary tag (accessible via :getTag()) +obj:setTag("category") -- set an arbitrary tag key +obj:hasTag("category") -- boolean +obj:parentHasTag("visual") -- checks the ancestor chain + +-- Tree queries +obj:getChildren() -- immediate children table +obj:getAllChildren() -- flat list of all visible descendants +obj:isDescendantOf(other) -- boolean +obj:canPress(mx, my) -- boolean: would a click at mx,my hit this object? + +-- Cloning +local copy = obj:clone() +local copy = obj:clone({copyTo = parent, connections = true}) +``` + +--- + +### 4.5 Shape & Form Factor + +By default elements are rectangles. You can change an element's form factor: ```lua -- Circle -element:makeCircle(x, y, radius, sx, sy, sr, segments) +obj:makeCircle(x, y, radius, sx, sy, sr, segments) -- Arc -element:makeArc(arcType, x, y, radius, sx, sy, sr, startAngle, endAngle, segments) --- arcType: "open", "closed", or "pie" (passed to love.graphics.arc) --- Angles in radians +obj:makeArc(arcType, x, y, radius, sx, sy, sr, angle1, angle2, segments) +-- arcType is a LÖVE arc type string: "open", "closed", or "pie" +``` + +The `formFactor` property can also be set directly: + +```lua +obj.formFactor = gui.FORM_RECTANGLE -- default +obj.formFactor = gui.FORM_CIRCLE +obj.formFactor = gui.FORM_ARC ``` --- -## Aspect Ratio & Resize Handling +## 5. Events & Connections -Lock the root GUI to a design resolution: +The library uses the `multi` connection system. Connections are called with the syntax: ```lua -gui:setAspectSize(1920, 1080) -- set design resolution -gui.aspect_ratio = true -- enable aspect-ratio mode +obj.OnSomeEvent(function(self, ...) + -- handler +end) ``` -When the window resizes, the library calculates letterbox/pillarbox offsets and adjusts `gui.x`, `gui.y`, `gui.w`, `gui.h` (and the same on `gui.virtual`) so all elements remain proportional. - -Disable it: +Multiple handlers can be attached to a single event. Connections can be combined: ```lua -gui:setAspectSize(nil, nil) -gui.aspect_ratio = false -``` +-- OR: fires the handler if either event fires +(obj.OnReleased + obj.OnReleasedOuter)(function() ... end) -Utility to compute the scaled size manually: - -```lua -local nw, nh, offsetX, offsetY = gui:GetSizeAdjustedToAspectRatio(windowW, windowH) +-- AND: fires the handler only when both have fired +(conn1 * conn2)(function() ... end) ``` --- -## The `apply` Helper +### 5.1 Per-Object Events -`gui.apply` is a batch property setter that inspects each field name for a prefix: +| Event | Arguments | Description | +|-------|-----------|-------------| +| `OnPressed` | `self, x, y, dx, dy, istouch` | Mouse pressed inside the element | +| `OnReleased` | `self, x, y, button, istouch, presses` | Mouse released inside the element | +| `OnReleasedOuter` | same | Released after a press, but outside the element | +| `OnReleasedOther` | same | Released with no previous press on this element | +| `OnPressedOuter` | `self, x, y, button, istouch, presses` | Pressed outside this element | +| `OnEnter` | `self, x, y` | Mouse moved onto the element | +| `OnExit` | `self, x, y` | Mouse moved off the element | +| `OnMoved` | `self, x, y, dx, dy, istouch` | Mouse moved while over the element | +| `OnDragStart` | `self, dx, dy, x, y, istouch` | Drag began | +| `OnDragging` | `self, dx, dy, x, y, istouch` | Drag in progress | +| `OnDragEnd` | `self, dx, dy, x, y, istouch, presses` | Drag ended | +| `OnWheelMoved` | `x, y` | Scroll wheel moved while cursor is over element | +| `OnSizeChanged` | `self, ...` | Element size changed | +| `OnPositionChanged` | `self, ...` | Element position changed | +| `OnDestroy` | `self` | Element is being destroyed | +| `OnCreated` | `element` | Fires for any descendant created under this element | +| `OnLoad` | — | Fires once when the element is first set up | +| `OnUpdate` | `self, dt` | Fires every update frame | -| Prefix | Meaning | -|---|---| -| `C_` | Connect to the named connection (value = handler function) | -| `I_` | Invoke the named method with args from a table | -| *(none)* | Direct assignment or smart detection (connection vs function vs value) | +**Gamepad / joystick events** are also available on every object: ```lua -gui.apply({ - color = {1, 0, 0}, - C_OnPressed = function(self) print("pressed") end, - I_setFont = {"fonts/bold.ttf", 16}, -}, buttonA, buttonB, buttonC) +obj.OnLeftStickUp / Down / Left / Right +obj.OnRightStickUp / Down / Left / Right +``` + +**TextLabel / TextButton / TextBox extras:** + +```lua +obj.OnFontUpdated(function(self) end) -- font changed +input.OnReturn(function(self, text) end) -- Enter key pressed in textbox +``` + +**Video extras:** + +```lua +vid.OnVideoFinished(function(self) end) ``` --- -## Tagging System +### 5.2 Global Events -Arbitrary string tags can be attached to any element: +All global events live under `gui.Events`: ```lua -element:setTag("draggable") -element:setTag("ui-panel") +gui.Events.OnKeyPressed(function(key, scancode, isrepeat) end) +gui.Events.OnKeyReleased(function(key, scancode) end) +gui.Events.OnTextInputed(function(text) end) +gui.Events.OnMouseMoved(function(x, y, dx, dy, istouch) end) +gui.Events.OnMousePressed(function(x, y, button, istouch, presses) end) +gui.Events.OnMouseReleased(function(x, y, button, istouch, presses) end) +gui.Events.OnWheelMoved(function(x, y) end) +gui.Events.OnResized(function(w, h) end) +gui.Events.OnQuit(function() end) +gui.Events.OnFilesDropped(function(x, y, files) end) +gui.Events.OnFocus(function(focus) end) +gui.Events.OnObjectFocusChanged(function(previous, current) end) -element:hasTag("draggable") -- true / false (direct tag) -element:parentHasTag("ui-panel") -- true if any ancestor has the tag -``` - -The built-in `"visual"` tag suppresses all mouse event connections: - -```lua -local deco = parent:newVisualFrame(...) -- automatically gets "visual" tag +-- Gamepad / joystick +gui.Events.OnGamepadPressed(function(joystick, button) end) +gui.Events.OnGamepadAxis(function(joystick, axis, value) end) ``` --- -## Cloning Elements +### 5.3 Hotkeys -Deep-copy an element and optionally its connection handlers: +Register a hotkey and get back a connection object: ```lua -local copy = element:clone({ - copyTo = targetParent, -- parent for the clone (default: gui.virtual) - connections = true, -- also copy connection handlers +local conn = obj:setHotKey({"lctrl", "s"}) +conn(function(ref) + print("Save triggered from", ref) +end) +``` + +Multiple key combinations for the same action: + +```lua +local onSave = gui:setHotKey({"lctrl", "s"}) + gui:setHotKey({"rctrl", "s"}) +onSave(function() save() end) +``` + +**Built-in hotkeys:** + +| Hotkey | Connection | +|--------|-----------| +| Ctrl+A | `gui.HotKeys.OnSelectAll` | +| Ctrl+C | `gui.HotKeys.OnCopy` | +| Ctrl+V | `gui.HotKeys.OnPaste` | +| Ctrl+X | `gui.HotKeys.OnCut` | +| Ctrl+Z | `gui.HotKeys.OnUndo` | +| Ctrl+Y / Ctrl+Shift+Z | `gui.HotKeys.OnRedo` | +| Ctrl+T | Toggle Task Manager | + +--- + +## 6. Theming + +The `theme` module generates consistent color palettes for use with `newWindow` and other themed widgets. + +```lua +local theme = require("gui.core.theme") +``` + +### Creating a Theme + +**From explicit colors:** + +```lua +local t = theme:new(primaryColor, primaryText, buttonText, buttonNormal, primaryFont, buttonFont) + +-- Using hex strings (most convenient): +local t = theme:new("#2d6a9f", "#f0f0f0", "#ffffff") +``` + +**From a table (preferred for full control):** + +```lua +local t = theme:new({ + primary = "#124559", + primaryDark = "#01161E", + primaryText = "#AEC3B0", + buttonNormal = "#1e6f8a", + buttonHighlight = "#2a9bbf", + buttonText = "#ffffff", + textFont = myFont, + buttonTextFont = myBoldFont, + -- Any extra keys are kept and accessible on the theme object }) ``` -`clone` recurses through all children. Connection handlers from the original are **bound** (not moved) to the clone's connections, so both elements remain independently connected. +**Random harmonious theme:** + +```lua +local t = theme:random() -- any brightness +local t = theme:random(nil, "dark") -- dark palette +local t = theme:random(nil, "light") -- light palette +local t = theme:random(12345) -- reproducible from a seed +print(t:getSeed()) -- retrieve the seed +print(t:dump()) -- export as hex string +``` + +### Theme Properties + +| Property | Description | +|----------|-------------| +| `colorPrimary` | Main background color | +| `colorPrimaryDark` | Darker variant (headers, accents) | +| `colorPrimaryText` | Text on primary backgrounds | +| `colorButtonNormal` | Button resting color | +| `colorButtonHighlight` | Button hover color | +| `colorButtonText` | Text on buttons | +| `fontPrimary` | Font for labels | +| `fontButton` | Font for buttons | + +### Applying a Theme to a Window + +Pass the theme as the last argument to `newWindow` — the window will automatically style all child buttons and labels that are created inside it: + +```lua +local win = gui:newWindow(x, y, w, h, "Title", draggable, myTheme) +``` --- -## Processors & Threading +## 7. Color Module -The library uses two internal processors from the `multi` library: - -| Processor | Purpose | -|---|---| -| `updater` | Input hooks, hot keys, text-box blink, video completion, image loading | -| `drawer` | Per-frame draw loop, virtual element position pass | - -Create a new processor that participates in `gui.update`: +The `color` module handles color creation, conversion, and manipulation. All colors in the library are `{r, g, b}` or `{r, g, b, a}` tables with values in the 0–1 range. ```lua -local proc = gui:newProcessor("MyProcessor") --- proc is a multi Processor; attach tasks/loops to it normally +local color = require("gui.core.color") ``` -Spawn a coroutine thread scoped to an element: +### Creating Colors ```lua -element:newThread(function(self, thread) - while true do - thread.sleep(1) - print("tick", self.text) +-- Hex string +color.new("#ff5500") +color.new("#ff550088") -- with alpha + +-- CSS-style strings +color.new("rgb(255,85,0)") +color.new("rgba(255,85,0,0.5)") +color.new("hsl(20,100,50)") +color.new("hsla(20,100,50,0.8)") + +-- Raw 0–1 floats +color.new(1, 0.33, 0) + +-- HSL (hue 0–360, sat 0–100, light 0–100) +color.new(color.hsl(200, 60, 40)) + +-- HSV (hue 0–360, sat 0–1, val 0–1) +color.new(color.hsv(200, 0.6, 0.8)) +``` + +### Manipulation + +```lua +color.lighten(c, amount) -- amount is 0–1 factor +color.darken(c, amount) +color.saturate(c, amount) +color.desaturate(c, amount) +color.invert(c) +color.lerp(c1, c2, t) -- blend; t is 0–1 +color.mix(c1, c2, t) -- alias for lerp +``` + +### Queries + +```lua +color.isLight(c) -- boolean +color.getAverageLightness(c) -- 0–1 float +color.rgbToHex(c) -- returns hex string without "#" +``` + +### Arithmetic + +Color objects support `+`, `-`, `*`, `/`, `%`, `^`, and unary `-` operators, applied component-wise. + +### Named Colors + +```lua +color.white +color.black +color.red +color.green +color.blue +color.highlighter_blue +-- (and more — check core_color.lua for the full list) +``` + +--- + +## 8. Transitions & Animation + +The `transitions` module provides smooth interpolated animations for numeric values. + +```lua +local transition = require("gui.elements.transitions") +``` + +### Built-in Transitions + +Currently `transition.glide` is provided — a linear glide from a start value to a stop value. + +### Using a Transition + +A transition factory is created by calling `transition.glide(start, stop, duration)`. This returns a **factory function** that, when called, starts the animation and returns a handle. + +```lua +-- Animate a panel sliding in from the left +local slideIn = transition.glide(-200, 0, 0.3) -- from -200 to 0 in 0.3 seconds + +local t = slideIn() -- start the animation +t.OnStep(function(position) + panel:setDualDim(position) +end) +t.OnStop(function() + print("Animation complete") +end) +``` + +### Overriding Values at Runtime + +The factory function accepts optional overrides: + +```lua +local t = slideIn(newStart, newStop, newDuration) +``` + +### Stopping Early + +```lua +t.Kill() +``` + +### Custom Transitions + +```lua +local myTransition = transition:newTransition(function(t, start, stop, time) + -- t.fps is the target FPS for this transition + local steps = t.fps * time + local piece = time / steps + t.running = true + for i = 0, steps do + if not t.kill then + thread.sleep(piece) + -- push the current interpolated value as a status update + thread.pushStatus(start + i * ((stop - start) / steps)) + end end + t.running = false + t.kill = false end) ``` -Attach a per-frame update callback (called every update loop): +### Changing FPS ```lua -gui:OnUpdate(function(self, dt) - -- called every frame -end) - -element:OnUpdate(function(self, dt) - -- called every frame with element as self -end) -``` - -Create a one-shot or reusable function that runs asynchronously: - -```lua -local fn = gui.newFunction(function(arg1, arg2) - -- runs in updater context -end) -fn(arg1, arg2) +transition.glide:SetFPS(30) -- lower for performance-sensitive animations ``` --- -## Drawing Internals +## 9. Add-on Widgets -The draw loop iterates `gui:getAllChildren()` each frame and calls `draw_handler` on each element in order (back-to-front). - -`draw_handler` does, in order: - -1. Compute and cache `child.x/y/w/h` via `getAbsolutes`. -2. Propagate clip rects to descendants if `clipDescendants` is set. -3. Activate shader if present. -4. Apply LÖVE scissor (clip or roundness-based). -5. Fill background with `child.color` and `child.visibility`. -6. Draw border with `child.borderColor`. -7. Handle special roundness sides ("top"/"bottom"). -8. Dispatch to type-specific draw functions (video → image → text → box cursor/selection). -9. Call `child:post()` if defined. -10. Remove scissor and shader. - -`gui.draw_handler` is exposed publicly so custom renderers can call it directly. +These widgets live in `gui/addons` and extend the core library. --- -## Virtual GUI +### 9.1 Window -`gui.virtual` is a root node whose children are never rendered on screen but still participate in the layout pass (absolute positions are computed). Use it to keep pre-built off-screen components ready to be re-parented: +A resizable, draggable floating window with a title bar and a close button. ```lua --- Create off-screen -local popup = gui.virtual:newFrame(0, 0, 400, 300) +require("gui.addons") -- loads addons --- Show it by re-parenting -popup:setParent(gui) - --- Hide it again -popup:setParent(gui.virtual) +local win = gui:newWindow(x, y, width, height, "Window Title", draggable, theme) ``` -`gui.virtual` shares the same screen dimensions as `gui`, so positions remain correct when an element moves between them. +The returned object is the **inner content frame** (inside the title bar). Add children directly to `win`. + +**API:** + +```lua +win:setTitle("New Title") +win:close() -- moves the window to the virtual tree (hides it) +win:open() -- brings the window back to the main tree +win:setTheme(theme) -- re-apply a different theme +win:getTheme() -- returns current theme + +win.OnClose(function(self) + -- fires when the X button is pressed +end) +``` + +**Minimum dimensions:** 200px wide, 100px tall (enforced by resize handles). + +**Children auto-styled:** Any `TYPE_BUTTON` or `TYPE_TEXT` created inside the window automatically receives the theme's colors and fonts via the `OnCreated` event. + +--- + +### 9.2 ScrollFrame + +A viewport with automatic vertical and horizontal scrollbars. Returns the **content frame** — add children to that. + +```lua +require("gui.addons") + +local content = gui:newScrollFrame(x, y, w, h, sx, sy, sw, sh) +-- Add children to `content`: +local row = content:newFrame(0, rowY, 0, 30, 0, 0, 1) +``` + +**Scrolling API (on the content frame):** + +```lua +content:scrollTo(scrollY, scrollX) -- jump to absolute scroll position +content:scrollBy(deltaY, deltaX) -- scroll by a relative amount +content:scrollToTop() +content:scrollToBottom() +content:setScrollSpeed(speed) -- default is 40 pixels per wheel tick +content:getScrollPos() -- returns scrollX, scrollY +content:getMaxScroll() -- returns maxScrollX, maxScrollY +content:setContentSize(width, height) -- explicitly set the content dimensions +``` + +The scrollbars appear automatically when the content overflows the viewport and hide when it does not. + +--- + +### 9.3 Slide-in Menu + +A panel that slides in from the left, right, or top with an animated transition. + +```lua +local transition = require("gui.elements.transitions") + +local menu = gui:newMenu(title, size, position, trans) +-- title : string label (required) +-- size : fractional width/height (e.g. 0.25 for 25% of screen) +-- position : gui.ALIGN_LEFT (default), gui.ALIGN_RIGHT, or gui.ALIGN_CENTER +-- trans : transition factory (default: transition.glide) +``` + +**API:** + +```lua +menu:Open(true) -- slide open +menu:Open(false) -- slide closed +menu:isOpen() -- boolean +``` + +**Example:** + +```lua +local sidebar = gui:newMenu("Navigation", 0.2, gui.ALIGN_LEFT) + +-- Add content to the menu frame +local navBtn = sidebar:newTextButton("Home", 10, 60, 180, 40) + +-- Wire toggle +toggleBtn.OnReleased(function() + sidebar:Open(not sidebar:isOpen()) +end) +``` + +--- + +### 9.4 Video Player + +A pre-built media player widget with play/pause toggle and a seek bar. + +```lua +require("gui.addons") + +gui:newVideoPlayer(source, x, y, w, h, sx, sy, sw, sh) +``` + +The player creates its own themed window containing the video, a play/pause button, and a progress bar. + +--- + +## 10. Canvas + +The `canvas` module creates full-screen root frames. + +```lua +local newCanvas = require("gui.core.canvas") + +local visual = newCanvas("visual") -- visual frame: non-interactive, for backgrounds/effects +local regular = newCanvas() -- regular interactive frame +``` + +**Swapping child trees between canvases:** + +```lua +visual:swap(frameA, frameB) +-- Exchanges the children of frameA and frameB, re-parenting correctly. +-- Useful for scene transitions. +``` + +--- + +## 11. Simulation (Testing) + +The `simulate` module lets you programmatically fire mouse events, useful for automated testing or scripted UI demos. + +```lua +local simulate = require("gui.core.simulate") +``` + +### Methods + +```lua +-- Immediate (synchronous) mouse press and release +simulate:Press(button, x, y, istouch) +simulate:Release(button, x, y, istouch) + +-- Async click (press then release after one scheduler tick) +simulate.Click(obj, button, x, y, istouch) + +-- Animated mouse movement +simulate.Move(obj, dx, dy, x, y, istouch) +``` + +When called on an object (`obj:Press()`), the position defaults to the object's center. When called on `simulate` directly, `x` and `y` default to the current mouse position. + +**Example:** + +```lua +-- Programmatically click a button +simulate.Click(myButton) + +-- Simulate a drag from one point to another +simulate.Move(nil, 100, 0, startX, startY) -- move 100px to the right +``` + +--- + +## 12. Scheduler Probe (Load Monitoring) + +Measures scheduler responsiveness using tick-slip detection. Gives a 0–100% load estimate without blocking. + +```lua +local probe = require("gui.core.probe") +probe:install(multi) +``` + +**Options:** + +```lua +probe:install(multi, { + interval = 0.05, -- probe fires every N seconds (default: 0.05) + alpha = 0.15, -- EMA smoothing factor 0–1 (default: 0.15, lower = smoother) + maxLag = 0.5, -- seconds of lag that equals 100% load (default: 0.5) +}) +``` + +**Reading load:** + +```lua +-- Both are non-blocking — safe to call every frame +local load, lagMs = multi:getLoad() +-- load : integer 0–100 +-- lagMs : smoothed scheduler lag in milliseconds + +local lagMs, lagRatio = multi:getSchedulerLag() +``` + +The probe is automatically installed when `gui:showTaskManager()` is called. + +--- + +## 13. Task Manager + +A built-in debug overlay showing all active scheduler tasks, their state, uptime, and priority. + +```lua +require("gui.addons") +gui:showTaskManager() +``` + +**Default hotkey:** `Ctrl+T` toggles it open/closed. + +The task manager window provides: +- A list of all processors, threads, and tasks with live state +- Pause/Resume buttons for individual tasks +- Kill buttons to terminate tasks +- Clickable priority column to cycle a task's scheduler priority +- An Error Log tab that captures all thread errors in real-time +- A load bar showing current scheduler utilization + +--- + +## 14. Tips & Patterns + +### Aspect Ratio Locking + +```lua +-- Lock the root to a 16:9 canvas; letterbox on resize +gui:setAspectSize(1920, 1080) +gui.aspect_ratio = true +``` + +### Clipping Children + +```lua +local container = gui:newFrame(x, y, w, h) +container.clipDescendants = true -- children are scissored to container bounds +``` + +### Tag-Based Queries + +```lua +-- Mark a group of elements and query by tag +for _, child in ipairs(parent:getAllChildren()) do + if child:hasTag("card") then + child.color = selectedColor + end +end +``` + +### Using `gui.apply` for Bulk Property Setting + +`gui.apply` sets properties on multiple objects at once. It understands connection names (`C_` prefix), invoke-style functions (`I_` prefix), and plain properties. + +```lua +gui.apply({ + color = {0.2, 0.2, 0.2}, + drawBorder = false, + I_enableDragging = {gui.MOUSE_PRIMARY}, + OnReleased = function(self) print("clicked", self:getTag()) end, +}, btn1, btn2, btn3) +``` + +### Stacking Order + +Objects are drawn in the order they appear in their parent's `children` table. The last child is drawn on top. + +```lua +obj:topStack() -- draw on top of siblings +obj:bottomStack() -- draw below all siblings +``` + +### Responsive Layouts + +Combine fractional scale with negative pixel offsets for padding: + +```lua +-- A frame inset 10px on all sides within its parent +inner:setDualDim(10, 10, -20, -20, 0, 0, 1, 1) +``` + +### Intersection Testing + +```lua +local ix, iy, iw, ih = obj:intersecpt(x, y, w, h) +-- Returns the overlapping rectangle, or 0,0,0,0 if no overlap +``` + +### Programmatic Focus + +```lua +local focused = gui:getObjectFocus() -- the currently focused object +``` + +### OnUpdate (per-frame callback) + +```lua +obj:OnUpdate(function(self, dt) + -- runs every frame while the object exists + self.rotation = self.rotation + 90 * dt +end) +``` diff --git a/gui/elements/init.lua b/gui/elements/init.lua deleted file mode 100644 index caf5822..0000000 --- a/gui/elements/init.lua +++ /dev/null @@ -1,69 +0,0 @@ -local gui = require("gui") -local color = require("gui.core.color") -local theme = require("gui.core.theme") -local transition = require("gui.elements.transitions") - -function gui:newMenu(title, sx, position, trans) - if not title then multi.error("Argument 1 string('title') is required") end - if not sx then multi.error("Argument 2 number('sx') is required") end - - local position = position or gui.ALIGN_LEFT - local trans = trans or transition.glide - - local menu, to, tc, open - if position == gui.ALIGN_LEFT then - menu = self:newFrame(0, 0, 0, 0, -sx, 0, sx, 1) - to = trans(-sx, 0, .25) - tc = trans(0, -sx, .25) - elseif position == gui.ALIGN_CENTER then - menu = self:newFrame(0, 0, 0, 0, .5 -sx/2, 1.1, sx, 1) - to = trans(1.1, 0, .35) - tc = trans(0, 1.1, .35) - elseif position == gui.ALIGN_RIGHT then - menu = self:newFrame(0, 0, 0, 0, 1, 0, sx, 1) - to = trans(1, 1 - sx, .25) - tc = trans(1 - sx, 1, .25) - end - - function menu:isOpen() - return open - end - - function menu:Open(show) - if show then - if not menu.lock then - menu.lock = true - local t = to() - t.OnStop(function() - open = true - menu.lock = false - end) - t.OnStep(function(p) - if position == gui.ALIGN_CENTER then - menu:setDualDim(nil, nil, nil, nil, nil, p) - else - menu:setDualDim(nil, nil, nil, nil, p) - end - end) - end - else - if not menu.lock then - menu.lock = true - local t = tc() - t.OnStop(function() - open = false - menu.lock = false - end) - t.OnStep(function(p) - if position == gui.ALIGN_CENTER then - menu:setDualDim(nil, nil, nil, nil, nil, p) - else - menu:setDualDim(nil, nil, nil, nil, p) - end - end) - end - end - end - - return menu -end diff --git a/gui/init.lua b/gui/init.lua index 44c834a..7339ecc 100644 --- a/gui/init.lua +++ b/gui/init.lua @@ -2,10 +2,9 @@ local utf8 = require("utf8") local multi, thread = require("multi"):init() local GLOBAL, THREAD = require("multi.integration.loveManager"):init() local color = require("gui.core.color") -local gif = require("gui.addons.gifloader") +local gif = require("gui.core.gifloader") local gui = {} local updater = multi:newProcessor("UpdateManager", true) - local drawer = multi:newProcessor("DrawManager", true) local bit = require("bit") @@ -139,6 +138,10 @@ end) -- Hotkeys +local function noOf(sx,sy,sw,sh) + return nil,nil,nil,nil,sx,sy,sw,sh +end + local has_hotkey = false local hot_keys = {} @@ -575,8 +578,7 @@ function gui:isActive() end function gui:isOnScreen() - - return + return not self:isOffScreen() end -- Base get uniques @@ -1123,6 +1125,302 @@ function gui:newTextBase(typ, txt, x, y, w, h, sx, sy, sw, sh) return c end +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 + function gui:newTextButton(txt, x, y, w, h, sx, sy, sw, sh) local c = self:newTextBase(button, txt, x, y, w, h, sx, sy, sw, sh) c:respectHierarchy(true) diff --git a/main.lua b/main.lua index 17036dc..8f602ac 100644 --- a/main.lua +++ b/main.lua @@ -1,5 +1,4 @@ -local gui, color, theme, utils, board, yaml, loader, system, elements, scoreUpdater - +local gui, color, theme, utils, board, yaml, loader, scoreUpdater local activePlayer local playerList = {} local playerStaticList = {} @@ -49,8 +48,7 @@ function init() board = require("board") yaml = require("yaml") loader = require("loader") - system = require("gui.addons.system") - elements = require("gui.elements") + require("gui.addons") scoreUpdater = gui:getProcessor():newProcessor("score-updater") scoreUpdater.Start() @@ -165,7 +163,44 @@ function ScoreBoard(frame, x, y, w, h, sx, sy, sw, sh) end local add_player = leaderboard:newFrame(5,-5,-10,0,0,1-PLAYER_HEIGHT,1,PLAYER_HEIGHT) - local remove_player = leaderboard:newTextButton("Remove Selected",5,-10,-10,0,0,1-2*PLAYER_HEIGHT,1,PLAYER_HEIGHT) + local edit_player = leaderboard:newFrame(5,-10,-10,0,0,1-2*PLAYER_HEIGHT,1,PLAYER_HEIGHT) + local remove_player = leaderboard:newTextButton("Remove Selected",5,-15,-10,0,0,1-3*PLAYER_HEIGHT,1,PLAYER_HEIGHT) + remove_player:setFont(20) + remove_player.align = gui.ALIGN_CENTER + local embededWatch = {remove_player} + scoreUpdater:newThread(function() + while true do + thread.sleep(.01) + for i,v in pairs(embededWatch) do + v:centerFont() + end + end + end) + + local function embedTextEdit(reference, default, but_text, callback) + reference.color = C_BORDER_NRM + local textbox = reference:newTextBox(default,0,0,0,0,.015,.1,.8,.8) + textbox.textColor = C_GOLD + textbox.blink = false + textbox.color = C_BORDER_TOP + textbox.textColor = C_WHITE + textbox:OnPressed(function() + textbox.text = "" + end) + + local button = reference:newTextButton(but_text,5,0,-10,0,.815,.1,.185,.8) + button.color = color.new("#7eae5b") + button:OnReleased(function() + callback(textbox) + end) + gui.apply({ + setFont = {20}, + align = gui.ALIGN_CENTER + },textbox,button,reference) + table.insert(embededWatch,textbox) + table.insert(embededWatch,button) + end + remove_player.color = color.new("#a13a3a") remove_player:OnReleased(function() local player = GetActivePlayer() @@ -185,42 +220,16 @@ function ScoreBoard(frame, x, y, w, h, sx, sy, sw, sh) scoreboard:RenderPlayer(playerList) player.Ref.Frame:destroy() end) - add_player.color = C_BORDER_NRM - local textbox = add_player:newTextBox("Player name",0,0,0,0,.015,.1,.8,.8) - textbox.textColor = C_GOLD - textbox.blink = false - textbox.color = C_BORDER_TOP - textbox.textColor = C_WHITE - textbox:OnPressed(function() - textbox.text = "" + + embedTextEdit(add_player, "Player Name", "Add", function(self) + scoreboard:AddPlayer(self.text, "0") end) - -- A bit glitchy - -- gui:setHotKey({"return"})(function() - -- local object_focus = gui:getObjectFocus() - -- if object_focus:hasType(gui.TYPE_BOX) then - -- scoreboard:AddPlayer(textbox.text, "0") - -- end - -- end) - - local addbutton = add_player:newTextButton("Add",5,0,-10,0,.815,.1,.185,.8) - addbutton.color = color.new("#7eae5b") - - addbutton:OnReleased(function() - scoreboard:AddPlayer(textbox.text, "0") - end) - - gui.apply({ - setFont = {20}, - align = gui.ALIGN_CENTER - },textbox,addbutton,remove_player) - - thread:newThread(function() - while true do - thread.sleep(.01) - textbox:centerFont() - addbutton:centerFont() - remove_player:centerFont() + embedTextEdit(edit_player, "Modify Score", "Edit", function(self) + local player = GetActivePlayer() + if player then + player.Score = self.text + scoreboard:RenderPlayer(playerList) end end) @@ -305,22 +314,51 @@ function ScoreBoard(frame, x, y, w, h, sx, sy, sw, sh) end -require("gui.addons.players") + -- local webp = require("webp") +init() + +local function noOf(sx,sy,sw,sh) + return nil,nil,nil,nil,sx,sy,sw,sh +end + function love.load() - init() gui:cacheImage({"assets/checked.png","assets/unchecked.png"}) gui:setAspectSize(1920, 1080) gui.aspect_ratio = true + -- local ext = require("gui.addons.extensions") local bg = gui:newFrame() bg:fullFrame() bg.color = color.new("#242f9b") + -- pb = bg:newProgressBar(0, 0, 200, 40, 0, 0, 0, 0, 200, 0) + + -- thread:newThread(function() + -- for i=1,200 do + -- thread.sleep(.01) + -- pb:add(1) + -- end + -- end) + + -- local group = bg:newRadioGroup({ + -- padding = 5, + -- "Option A", + -- "Option B", + -- "Option C" + -- },0,0,0,0, 80) + + -- group.OnSelectionChanged(function(group, selection) + -- print(selection:getLabel()) + -- end) + + + + local qframe = bg:newFrame(0, 0, 0, 0, .2, .05, .75, .9) qframe.color = color.new("#060ee9") local scoreboard = ScoreBoard(bg, 0, 0, 0, 0, .015, .05, .170, .9) - board.buildBoard(qframe, "ai-anime") + board.buildBoard(qframe, "anime") -- gui:newVideoPlayer("test.ogv",0,0,428,240) -- local img = webp.load("test.webp") diff --git a/test.webp b/test.webp deleted file mode 100644 index 0aed682..0000000 Binary files a/test.webp and /dev/null differ diff --git a/utils.lua b/utils.lua index cce8378..c9a2fc4 100644 --- a/utils.lua +++ b/utils.lua @@ -1,7 +1,117 @@ +-- local gui = require("gui") +-- local color = require("gui.core.color") +-- local transition = require("gui.core.transitions") +-- local multi = require("multi"):init() +-- local timer = transition.glide(3.5,1.5,5) + +-- local function startTimer(opt) +-- local default = { +-- duration = 30, +-- autoText = true, +-- autoColor = true, +-- autoCleanup = true, +-- startColor = color.green, +-- textColor = color.black, +-- warnColor = color.yellow, +-- timeColor = color.red, +-- finegrained = false, +-- visibility = 1 +-- } + +-- if type(opt) == "number" then +-- local d = opt +-- opt = default +-- opt.duration = d +-- elseif type(opt) ~= "table" then +-- opt = default +-- elseif type(opt) == "table" then +-- for i,v in pairs(opt) do +-- default[i] = v +-- end +-- opt = default +-- end + +-- local timeRemaining = opt.duration or 30 + +-- transition.glide:SetFPS(60) +-- local handle = timer(3.5, 1.5, timeRemaining) +-- local tpie = gui:newFrame():makeArc("pie",-200, 0, 100,1 ,0 ,0 ,1.5*math.pi, 3.5*math.pi, 360) +-- local tlabel = tpie:newTextLabel("") +-- tlabel.textColor = opt.textColor +-- tlabel.align = gui.ALIGN_CENTER +-- tlabel:fullFrame() +-- tlabel.visibility = 0 +-- tpie.color = opt.startColor +-- tpie.visibility = opt.visibility + +-- local tm, num +-- local func = function(p,t) +-- if num ~= timeRemaining-math.floor(t) then +-- num = timeRemaining-math.floor(t) +-- end + +-- if opt.autoColor then +-- tpie.color = opt.startColor +-- if num <= timeRemaining/3 then +-- tpie.color = opt.timeColor +-- elseif num <= timeRemaining/2 then +-- tpie.color = opt.warnColor +-- end +-- end + +-- tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5 * math.pi, p * math.pi, 360) + +-- if opt.autoText then +-- tlabel.text = num +-- tlabel:fitFont(nil, nil, {scale=1/2}) +-- tlabel:centerFont() +-- end + +-- if num == 0 then +-- thread:newThread("Pie Timer",function() +-- thread.yield() +-- if opt.autoText then +-- tlabel.text = "Time" +-- tlabel:fitFont(nil, nil, {scale=1/2}) +-- tlabel:centerFont() +-- end +-- tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5 * math.pi, 3.5 * math.pi, 360) +-- tm.OnStop:Fire(tm) +-- if opt.autoCleanup then +-- tm:Cleanup() +-- end +-- tm.OnTime:Destroy() +-- tm.OnStop:Destroy() +-- end) +-- end + +-- return tm, num, t +-- end + +-- tm = { +-- Duration = timeRemaining, +-- Cleanup = function() handle:Kill() tpie:destroy() tm.Cleanup = function() end end, +-- SetText = function(str) tlabel.text = str end, +-- SetColor = function(c) tpie.color = c end, +-- OnStop = multi:newConnection() +-- } + +-- if opt.finegrained then +-- tm.OnTime = func % handle.OnStep +-- else +-- tm.OnTime = function(self, sec, t) return math.floor(t) == t, self, sec end / (func % handle.OnStep) +-- end + +-- return tm +-- end + +-- return { +-- startTimer = startTimer +-- } + local gui = require("gui") local color = require("gui.core.color") -local transition = require("gui.elements.transitions") -local multi = require("multi"):init() +local multi, thread = require("multi"):init() local function startTimer(opt) local default = { @@ -23,19 +133,16 @@ local function startTimer(opt) opt.duration = d elseif type(opt) ~= "table" then opt = default - elseif type(opt) == "table" then - for i,v in pairs(opt) do + else + for i, v in pairs(opt) do default[i] = v end opt = default end local timeRemaining = opt.duration or 30 - local timer = transition.glide(3.5,1.5,5) - transition.glide:SetFPS(120) - local handle = timer(3.5,1.5,timeRemaining) - local tpie = gui:newFrame():makeArc("pie",-200, 0, 100,1 ,0 ,0 ,1.5*math.pi, 3.5*math.pi, 360) + local tpie = gui:newFrame():makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5*math.pi, 3.5*math.pi, 360) local tlabel = tpie:newTextLabel("") tlabel.textColor = opt.textColor tlabel.align = gui.ALIGN_CENTER @@ -44,62 +151,92 @@ local function startTimer(opt) tpie.color = opt.startColor tpie.visibility = opt.visibility - local tm, num - local func = function(p,t) - if num ~= timeRemaining-math.floor(t) then - num = timeRemaining-math.floor(t) - end + local tm + local stopped = false + local onTimeConn = multi:newConnection() + local onStopConn = multi:newConnection() - if opt.autoColor then - tpie.color = opt.startColor - if num <= timeRemaining/3 then - tpie.color = opt.timeColor - elseif num <= timeRemaining/2 then - tpie.color = opt.warnColor + local function cleanup() + if stopped then return end + stopped = true + if onTimeConn and not onTimeConn.destroyed then + onTimeConn:Destroy() + end + if not tpie.destroyed then + tpie:destroy() + end + end + + local timerThread = thread:newThread("Pie Timer", function() + local startTime = love.timer.getTime() + local lastSecond = timeRemaining + + while not stopped do + thread.sleep(1/60) + + local now = love.timer.getTime() + local elapsed = now - startTime + local t = math.min(elapsed, timeRemaining) + local num = timeRemaining - math.floor(t) + local p = 3.5 - (t / timeRemaining) * 2 + + if opt.autoColor then + tpie.color = opt.startColor + if num <= timeRemaining / 3 then + tpie.color = opt.timeColor + elseif num <= timeRemaining / 2 then + tpie.color = opt.warnColor + end + end + + tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5*math.pi, p*math.pi, 360) + + if opt.autoText then + tlabel.text = num + tlabel:fitFont(nil, nil, {scale=1/2}) + tlabel:centerFont() + end + + if opt.finegrained then + onTimeConn:Fire(tm, num, t) + elseif num < lastSecond then + -- crossed a second boundary + lastSecond = num + onTimeConn:Fire(tm, num, t) + end + + if elapsed >= timeRemaining then + break end end - tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5 * math.pi, p * math.pi, 360) - + -- Timer finished + if stopped then return end + if opt.autoText then - tlabel.text = num + tlabel.text = "Time" tlabel:fitFont(nil, nil, {scale=1/2}) tlabel:centerFont() end + tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5*math.pi, 3.5*math.pi, 360) - if num == 0 then - thread:newThread("Pie Timer",function() - thread.yield() - if opt.autoText then - tlabel.text = "Time" - tlabel:fitFont(nil, nil, {scale=1/2}) - tlabel:centerFont() - end - tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5 * math.pi, 3.5 * math.pi, 360) - tm.OnStop:Fire(tm) - if opt.autoCleanup then - tm:Cleanup() - end - end) - end + local onStop = onStopConn + cleanup() + onStop:Fire(tm) + end) - return tm, num, t - end - tm = { Duration = timeRemaining, - Cleanup = function() handle:Kill() tpie:destroy() tm.Cleanup = function() end end, + OnTime = onTimeConn, + OnStop = onStopConn, SetText = function(str) tlabel.text = str end, SetColor = function(c) tpie.color = c end, - OnStop = multi:newConnection() + Cleanup = function() + timerThread:Kill() + cleanup() + end, } - if opt.finegrained then - tm.OnTime = func % handle.OnStep - else - tm.OnTime = function(self, sec, t) return math.floor(t) == t, self, sec end / (func % handle.OnStep) - end - return tm end diff --git a/webp-old.lua b/webp-old.lua deleted file mode 100644 index 7f01cdf..0000000 --- a/webp-old.lua +++ /dev/null @@ -1,602 +0,0 @@ --- webp.lua — Pure Lua VP8L (lossless WebP) decoder for Love2D (LuaJIT) --- Supports: VP8L (lossless) only. VP8 (lossy) is not feasible in pure Lua. --- Usage: local WebP = require("webp"); local img = WebP.load("sprite.webp") - -local WebP = {} - -local bit = require("bit") -local band = bit.band -local bor = bit.bor -local lshift = bit.lshift -local rshift = bit.rshift -local tobit = bit.tobit - --- ─── Bitstream reader ──────────────────────────────────────────────────────── --- VP8L is LSB-first. We maintain a 32-bit window since LuaJIT bit ops are 32-bit. - -local function newBitReader(data) - local r = { - data = data, - pos = 1, -- next byte to load (1-based) - window = 0, -- current bit window (32-bit) - bits = 0, -- valid bits in window - } - - function r:fill() - while self.bits <= 24 and self.pos <= #self.data do - local byte = self.data:byte(self.pos) - self.window = bor(self.window, lshift(byte, self.bits)) - self.bits = self.bits + 8 - self.pos = self.pos + 1 - end - end - - function r:read(n) - if self.bits < n then self:fill() end - local v = band(self.window, lshift(1, n) - 1) - self.window = rshift(self.window, n) - self.bits = self.bits - n - return v - end - - function r:readBool() - return self:read(1) == 1 - end - - r:fill() - return r -end - --- ─── Huffman trees ─────────────────────────────────────────────────────────── - -local function buildHuffmanTable(codeLengths) - local n = #codeLengths - local counts = {} - for i = 0, 15 do counts[i] = 0 end - for _, cl in ipairs(codeLengths) do - if cl > 0 then counts[cl] = counts[cl] + 1 end - end - - local nextCode = {} - local code = 0 - counts[0] = 0 - for bits = 1, 15 do - code = lshift(code + counts[bits - 1], 1) - nextCode[bits] = code - end - - local htable = {} - for i = 1, n do - local len = codeLengths[i] - if len > 0 then - htable[nextCode[len]] = { sym = i - 1, len = len } - nextCode[len] = nextCode[len] + 1 - end - end - return htable -end - -local function decodeHuffman(br, htable) - local code = 0 - for len = 1, 15 do - code = bor(lshift(code, 1), br:read(1)) - local entry = htable[code] - if entry and entry.len == len then - return entry.sym - end - end - error("Invalid Huffman code") -end - --- ─── Code length decoding ──────────────────────────────────────────────────── - -local CODE_LENGTH_ORDER = {17,18,0,1,2,3,4,5,16,6,7,8,9,10,11,12,13,14,15} - -local function readCodeLengths(br, n) - local numCLCodes = br:read(4) + 4 - local clLengths = {} - for i = 1, 19 do clLengths[i] = 0 end - for i = 1, numCLCodes do - clLengths[CODE_LENGTH_ORDER[i] + 1] = br:read(3) - end - local clTable = buildHuffmanTable(clLengths) - - local lengths = {} - local prev = 8 - while #lengths < n do - local sym = decodeHuffman(br, clTable) - if sym <= 15 then - lengths[#lengths + 1] = sym - if sym ~= 0 then prev = sym end - elseif sym == 16 then - local rep = br:read(2) + 3 - for _ = 1, rep do lengths[#lengths + 1] = prev end - elseif sym == 17 then - local rep = br:read(3) + 3 - for _ = 1, rep do lengths[#lengths + 1] = 0 end - elseif sym == 18 then - local rep = br:read(7) + 11 - for _ = 1, rep do lengths[#lengths + 1] = 0 end - end - end - return lengths -end - --- ─── Prefix code reading ───────────────────────────────────────────────────── - -local function readPrefixCode(br, alphabetSize) - local simpleCode = br:read(1) - if simpleCode == 1 then - local numSyms = br:read(1) + 1 - local firstSym = br:read(1) - local sym1 = br:read(firstSym == 1 and 8 or 1) - local lengths = {} - for i = 1, alphabetSize do lengths[i] = 0 end - lengths[sym1 + 1] = 1 - if numSyms == 2 then - local sym2 = br:read(8) - lengths[sym2 + 1] = 1 - end - return buildHuffmanTable(lengths) - else - local lengths = readCodeLengths(br, alphabetSize) - return buildHuffmanTable(lengths) - end -end - --- ─── Color cache ───────────────────────────────────────────────────────────── - -local function newColorCache(bits) - local size = lshift(1, bits) - local cache = {} - for i = 0, size - 1 do cache[i] = 0 end - return { - size = size, - data = cache, - insert = function(self, color) - -- hash: (0x1e35a7bd * color) >> (32 - bits), masked to cache size - local hash = tobit(0x1e35a7bd * color) - local idx = band(rshift(hash, 32 - bits), size - 1) - self.data[idx] = color - end, - lookup = function(self, idx) - return self.data[idx] - end, - } -end - --- ─── Transform type constants ──────────────────────────────────────────────── - -local TRANSFORM_PREDICTOR = 0 -local TRANSFORM_COLOR = 1 -local TRANSFORM_SUBTRACT_GREEN = 2 -local TRANSFORM_COLOR_INDEXING = 3 - --- ─── Prefix length/distance tables ────────────────────────────────────────── - -local PREFIX_EXTRA_BITS = { - 0,0,0,0, 1,1,2,2, 3,3,4,4, 5,5,6,6, 7,7,8,8, 9,9,10,10, 11,11,12,12, 13,13 -} -local PREFIX_OFFSET = { - 0,1,2,3, 4,6,8,12, 16,24,32,48, 64,96,128,192, 256,384,512,768, - 1024,1536,2048,3072, 4096,6144,8192,12288, 16384,24576 -} - -local function prefixToValue(br, code) - if code < 4 then return code end - local extra = PREFIX_EXTRA_BITS[code + 1] or 0 - local offset = PREFIX_OFFSET[code + 1] or 0 - return offset + br:read(extra) -end - --- ─── VP8L distance offset table (120 entries) ──────────────────────────────── - -local DIST_MAP = { - {0,1},{1,0},{1,1},{-1,1},{0,2},{2,0},{1,2},{-1,2}, - {2,1},{-2,1},{2,2},{-2,2},{0,3},{3,0},{1,3},{-1,3}, - {3,1},{-3,1},{2,3},{-2,3},{3,2},{-3,2},{0,4},{4,0}, - {1,4},{-1,4},{4,1},{-4,1},{3,3},{-3,3},{2,4},{-2,4}, - {4,2},{-4,2},{0,5},{3,4},{-3,4},{4,3},{-4,3},{5,0}, - {1,5},{-1,5},{5,1},{-5,1},{2,5},{-2,5},{5,2},{-5,2}, - {4,4},{-4,4},{3,5},{-3,5},{5,3},{-5,3},{0,6},{6,0}, - {1,6},{-1,6},{6,1},{-6,1},{2,6},{-2,6},{6,2},{-6,2}, - {4,5},{-4,5},{5,4},{-5,4},{3,6},{-3,6},{6,3},{-6,3}, - {0,7},{7,0},{1,7},{-1,7},{5,5},{-5,5},{7,1},{-7,1}, - {4,6},{-4,6},{6,4},{-6,4},{2,7},{-2,7},{7,2},{-7,2}, - {3,7},{-3,7},{7,3},{-7,3},{5,6},{-5,6},{6,5},{-6,5}, - {8,0},{4,7},{-4,7},{7,4},{-7,4},{8,1},{8,2},{6,6}, - {-6,6},{8,3},{5,7},{-5,7},{7,5},{-7,5},{8,4},{6,7}, - {-6,7},{7,6},{-7,6},{8,5},{8,6},{7,7},{-7,7},{8,7}, -} - --- ─── Main VP8L decode (recursive for transform sub-images) ─────────────────── - -local function decodeVP8L(br, width, height) - - -- color cache - local hasCCache = br:readBool() - local ccache = nil - local ccacheBits = 0 - if hasCCache then - ccacheBits = br:read(4) - ccache = newColorCache(ccacheBits) - end - - -- collect transforms (applied in reverse after decode) - local transforms = {} - while br:readBool() do - local ttype = br:read(2) - local t = { ttype = ttype } - if ttype == TRANSFORM_PREDICTOR or ttype == TRANSFORM_COLOR then - t.sizeBits = br:read(3) + 2 - local tw = math.floor((width + lshift(1, t.sizeBits) - 1) / lshift(1, t.sizeBits)) - local th = math.floor((height + lshift(1, t.sizeBits) - 1) / lshift(1, t.sizeBits)) - t.data = decodeVP8L(br, tw, th) - elseif ttype == TRANSFORM_COLOR_INDEXING then - t.numColors = br:read(8) + 1 - t.colors = decodeVP8L(br, t.numColors, 1) - end - -- SUBTRACT_GREEN has no extra data - transforms[#transforms + 1] = t - end - - -- entropy/meta-Huffman image - local groupBits = 0 - local groupImage = nil - local numGroups = 1 - if br:readBool() then - groupBits = br:read(3) + 2 - local gw = math.floor((width + lshift(1, groupBits) - 1) / lshift(1, groupBits)) - local gh = math.floor((height + lshift(1, groupBits) - 1) / lshift(1, groupBits)) - groupImage = decodeVP8L(br, gw, gh) - local maxG = 0 - for _, v in ipairs(groupImage) do - local g = band(rshift(v, 8), 0xffff) - if g > maxG then maxG = g end - end - numGroups = maxG + 1 - end - - -- alphabet sizes - local ccSize = hasCCache and lshift(1, ccacheBits) or 0 - local alphabetG = 256 + 24 + ccSize - - -- read Huffman tables for each group (G, R, B, A + distance) - local huffGroups = {} - for g = 1, numGroups do - huffGroups[g] = { - G = readPrefixCode(br, alphabetG), - R = readPrefixCode(br, 256), - B = readPrefixCode(br, 256), - A = readPrefixCode(br, 256), - dist = readPrefixCode(br, 40), - } - end - - -- decode pixels - local pixels = {} - local numPixels = width * height - local px, py = 0, 0 - - local function getGroup() - if not groupImage then return huffGroups[1] end - local gx = rshift(px, groupBits) - local gy = rshift(py, groupBits) - local gw = math.floor((width + lshift(1, groupBits) - 1) / lshift(1, groupBits)) - local idx = gy * gw + gx + 1 - local g = band(rshift(groupImage[idx] or 0, 8), 0xffff) - return huffGroups[g + 1] or huffGroups[1] - end - - while #pixels < numPixels do - local hg = getGroup() - local code = decodeHuffman(br, hg.G) - - if code < 256 then - -- literal ARGB (green first, then R, B, A) - local g = code - local r = decodeHuffman(br, hg.R) - local b = decodeHuffman(br, hg.B) - local a = decodeHuffman(br, hg.A) - local color = bor(bor(bor(lshift(a, 24), lshift(r, 16)), lshift(g, 8)), b) - pixels[#pixels + 1] = color - if ccache then ccache:insert(color) end - - elseif code < 256 + 24 then - -- LZ77 back-reference - local lenCode = code - 256 - local length = prefixToValue(br, lenCode) + 1 - local distCode = decodeHuffman(br, hg.dist) - local dist = prefixToValue(br, distCode) + 1 - - -- remap small distances through VP8L spatial table - if dist <= 120 then - local d = DIST_MAP[dist] - local srcX = px - d[1] - local srcY = py - d[2] - dist = py * width + px - (srcY * width + srcX) - end - - local src = #pixels - dist + 1 - for i = 0, length - 1 do - local c = pixels[src + i] or 0 - pixels[#pixels + 1] = c - if ccache then ccache:insert(c) end - end - - else - -- color cache reference - local cacheIdx = code - 256 - 24 - pixels[#pixels + 1] = ccache:lookup(cacheIdx) - end - - px = px + 1 - if px >= width then px = 0; py = py + 1 end - end - - -- ─── Apply transforms in reverse order ─────────────────────────────────── - - for i = #transforms, 1, -1 do - local t = transforms[i] - - -- SUBTRACT_GREEN: R += G, B += G (mod 256) - if t.ttype == TRANSFORM_SUBTRACT_GREEN then - for idx = 1, #pixels do - local c = pixels[idx] - local a = band(rshift(c, 24), 0xff) - local r = band(rshift(c, 16), 0xff) - local g = band(rshift(c, 8), 0xff) - local b = band(c, 0xff) - r = band(r + g, 0xff) - b = band(b + g, 0xff) - pixels[idx] = bor(bor(bor(lshift(a, 24), lshift(r, 16)), lshift(g, 8)), b) - end - - -- COLOR_INDEXING: replace each pixel's green channel with palette entry - elseif t.ttype == TRANSFORM_COLOR_INDEXING then - local bpp = 8 - if t.numColors <= 2 then bpp = 1 - elseif t.numColors <= 4 then bpp = 2 - elseif t.numColors <= 16 then bpp = 4 end - - local newPixels = {} - if bpp == 8 then - for _, c in ipairs(pixels) do - local idx = band(rshift(c, 8), 0xff) - newPixels[#newPixels + 1] = t.colors[idx + 1] or 0 - end - else - local pxPerByte = 8 / bpp - local mask = lshift(1, bpp) - 1 - for _, c in ipairs(pixels) do - local packed = band(rshift(c, 8), 0xff) - for p = 0, pxPerByte - 1 do - if #newPixels < width * height then - local idx = band(rshift(packed, p * bpp), mask) - newPixels[#newPixels + 1] = t.colors[idx + 1] or 0 - end - end - end - end - pixels = newPixels - - -- PREDICTOR: undo per-block spatial prediction - elseif t.ttype == TRANSFORM_PREDICTOR then - local sb = t.sizeBits - local tw = math.floor((width + lshift(1, sb) - 1) / lshift(1, sb)) - - local function getpx(x, y) - if x < 0 then x = 0 end - if y < 0 then return tobit(0xff000000) end - return pixels[y * width + x + 1] or 0 - end - - local function addARGB(a, b) - local aa = band(rshift(a, 24), 0xff) - local ar = band(rshift(a, 16), 0xff) - local ag = band(rshift(a, 8), 0xff) - local ab = band(a, 0xff) - local ba = band(rshift(b, 24), 0xff) - local br_ = band(rshift(b, 16), 0xff) - local bg = band(rshift(b, 8), 0xff) - local bb = band(b, 0xff) - return bor(bor(bor( - lshift(band(aa + ba, 0xff), 24), - lshift(band(ar + br_, 0xff), 16)), - lshift(band(ag + bg, 0xff), 8)), - band(ab + bb, 0xff)) - end - - local function avgARGB(a, b) - local aa = band(rshift(a, 24), 0xff) - local ar = band(rshift(a, 16), 0xff) - local ag = band(rshift(a, 8), 0xff) - local ab = band(a, 0xff) - local ba = band(rshift(b, 24), 0xff) - local br_ = band(rshift(b, 16), 0xff) - local bg = band(rshift(b, 8), 0xff) - local bb = band(b, 0xff) - return bor(bor(bor( - lshift(rshift(aa + ba, 1), 24), - lshift(rshift(ar + br_, 1), 16)), - lshift(rshift(ag + bg, 1), 8)), - rshift(ab + bb, 1)) - end - - local function clampByte(v) - return math.max(0, math.min(255, v)) - end - - local function clampAddARGB(a, b) - local aa = band(rshift(a, 24), 0xff) - local ar = band(rshift(a, 16), 0xff) - local ag = band(rshift(a, 8), 0xff) - local ab = band(a, 0xff) - local ba = band(rshift(b, 24), 0xff) - local br_ = band(rshift(b, 16), 0xff) - local bg = band(rshift(b, 8), 0xff) - local bb = band(b, 0xff) - return bor(bor(bor( - lshift(clampByte(aa + ba), 24), - lshift(clampByte(ar + br_), 16)), - lshift(clampByte(ag + bg), 8)), - clampByte(ab + bb)) - end - - local function subARGB(a, b) - local aa = band(rshift(a, 24), 0xff) - local ar = band(rshift(a, 16), 0xff) - local ag = band(rshift(a, 8), 0xff) - local ab = band(a, 0xff) - local ba = band(rshift(b, 24), 0xff) - local br_ = band(rshift(b, 16), 0xff) - local bg = band(rshift(b, 8), 0xff) - local bb = band(b, 0xff) - return bor(bor(bor( - lshift(band(aa - ba, 0xff), 24), - lshift(band(ar - br_, 0xff), 16)), - lshift(band(ag - bg, 0xff), 8)), - band(ab - bb, 0xff)) - end - - local function halfSubARGB(a, b) - local aa = band(rshift(a, 24), 0xff) - local ar = band(rshift(a, 16), 0xff) - local ag = band(rshift(a, 8), 0xff) - local ab = band(a, 0xff) - local ba = band(rshift(b, 24), 0xff) - local br_ = band(rshift(b, 16), 0xff) - local bg = band(rshift(b, 8), 0xff) - local bb = band(b, 0xff) - return bor(bor(bor( - lshift(rshift(aa - ba, 1), 24), - lshift(rshift(ar - br_, 1), 16)), - lshift(rshift(ag - bg, 1), 8)), - rshift(ab - bb, 1)) - end - - local function selectARGB(l, tp, tl) - local function absdiff(x, y, s) - return math.abs(band(rshift(x, s), 0xff) - band(rshift(y, s), 0xff)) - end - local function dist(x, y) - return absdiff(x,y,24) + absdiff(x,y,16) + absdiff(x,y,8) + absdiff(x,y,0) - end - return dist(l, tl) <= dist(tp, tl) and l or tp - end - - for iy = 0, height - 1 do - for ix = 0, width - 1 do - if not (ix == 0 and iy == 0) then - local pidx = iy * width + ix + 1 - local L = getpx(ix - 1, iy) - local T = getpx(ix, iy - 1) - local TL = getpx(ix - 1, iy - 1) - local TR = getpx(ix + 1, iy - 1) - local tx = rshift(ix, sb) - local ty = rshift(iy, sb) - local mode = band(t.data[ty * tw + tx + 1] or 0, 0xff) - - local pred - if mode == 0 then pred = tobit(0xff000000) - elseif mode == 1 then pred = L - elseif mode == 2 then pred = T - elseif mode == 3 then pred = TR - elseif mode == 4 then pred = TL - elseif mode == 5 then pred = avgARGB(avgARGB(L, TR), T) - elseif mode == 6 then pred = avgARGB(L, TL) - elseif mode == 7 then pred = avgARGB(L, T) - elseif mode == 8 then pred = avgARGB(TL, T) - elseif mode == 9 then pred = avgARGB(T, TR) - elseif mode == 10 then pred = avgARGB(avgARGB(L, TL), avgARGB(T, TR)) - elseif mode == 11 then pred = selectARGB(L, T, TL) - elseif mode == 12 then pred = clampAddARGB(L, subARGB(T, TL)) - elseif mode == 13 then - local avg = avgARGB(L, T) - pred = clampAddARGB(avg, halfSubARGB(avg, TL)) - else pred = L end - - pixels[pidx] = addARGB(pixels[pidx], pred) - end - end - end - - -- COLOR: undo green/red channel correlations - elseif t.ttype == TRANSFORM_COLOR then - local sb = t.sizeBits - local tw = math.floor((width + lshift(1, sb) - 1) / lshift(1, sb)) - for iy = 0, height - 1 do - for ix = 0, width - 1 do - local pidx = iy * width + ix + 1 - local c = pixels[pidx] or 0 - local tx = rshift(ix, sb) - local ty = rshift(iy, sb) - local m = t.data[ty * tw + tx + 1] or 0 - - -- unpack signed bytes from transform pixel (stored as ARGB) - local g2r = band(rshift(m, 16), 0xff) - local r2b = band(rshift(m, 8), 0xff) - local g2b = band(m, 0xff) - if g2r >= 128 then g2r = g2r - 256 end - if r2b >= 128 then r2b = r2b - 256 end - if g2b >= 128 then g2b = g2b - 256 end - - local a = band(rshift(c, 24), 0xff) - local r = band(rshift(c, 16), 0xff) - local g = band(rshift(c, 8), 0xff) - local b = band(c, 0xff) - - r = band(r + math.floor(g2r * g / 256), 0xff) - b = band(b + math.floor(g2b * g / 256) + math.floor(r2b * r / 256), 0xff) - - pixels[pidx] = bor(bor(bor(lshift(a, 24), lshift(r, 16)), lshift(g, 8)), b) - end - end - end - end - - return pixels -end - --- ─── Public API ────────────────────────────────────────────────────────────── - -function WebP.decode(data) - assert(data:sub(1, 4) == "RIFF", "Not a RIFF file") - assert(data:sub(9, 12) == "WEBP", "Not a WEBP file") - - local fourCC = data:sub(13, 16) - assert(fourCC == "VP8L", "Only lossless VP8L WebP is supported (got: " .. fourCC .. ")") - - -- byte 21 = VP8L signature (0x2f), bitstream starts at byte 22 - assert(data:byte(21) == 0x2f, "Invalid VP8L signature byte") - local br = newBitReader(data:sub(22)) - - local width = br:read(14) + 1 - local height = br:read(14) + 1 - br:readBool() -- alpha hint flag, unused - local version = br:read(3) - assert(version == 0, "Unsupported VP8L version: " .. version) - - local pixels = decodeVP8L(br, width, height) - - -- VP8L stores ARGB; Love2D mapPixel expects normalised RGBA floats - local imageData = love.image.newImageData(width, height, "rgba8") - imageData:mapPixel(function(x, y) - local c = pixels[y * width + x + 1] or 0 - local a = band(rshift(c, 24), 0xff) - local r = band(rshift(c, 16), 0xff) - local g = band(rshift(c, 8), 0xff) - local b = band(c, 0xff) - return r / 255, g / 255, b / 255, a / 255 - end) - - return love.graphics.newImage(imageData) -end - -function WebP.load(path) - local data = love.filesystem.read(path) - assert(data, "Could not read file: " .. path) - return WebP.decode(data) -end - -return WebP diff --git a/webp.lua b/webp.lua deleted file mode 100644 index 757619c..0000000 --- a/webp.lua +++ /dev/null @@ -1,1626 +0,0 @@ --- webp.lua — Pure Lua WebP decoder for Love2D (LuaJIT) --- Supports: VP8 (lossy), VP8L (lossless), VP8X (extended container) --- Usage: local WebP = require("webp"); local img = WebP.load("sprite.webp") - -local WebP = {} - -local bit = require("bit") -local band = bit.band -local bor = bit.bor -local lshift = bit.lshift -local rshift = bit.rshift -local tobit = bit.tobit - --- ─── Bitstream reader ──────────────────────────────────────────────────────── --- VP8L is LSB-first. We maintain a 32-bit window since LuaJIT bit ops are 32-bit. - --- ─── Bitstream reader (unchanged, still LSB‑first) ─────────────────────────── - -local function newBitReader(data) - local r = { - data = data, - pos = 1, - window = 0, - bits = 0, - } - - function r:fill() - while self.bits <= 24 and self.pos <= #self.data do - local byte = self.data:byte(self.pos) - self.window = bor(self.window, lshift(byte, self.bits)) - self.bits = self.bits + 8 - self.pos = self.pos + 1 - end - end - - function r:read(n) - if self.bits < n then self:fill() end - local v = band(self.window, lshift(1, n) - 1) - self.window = rshift(self.window, n) - self.bits = self.bits - n - return v - end - - function r:readBool() - return self:read(1) == 1 - end - - r:fill() - return r -end - --- ─── Helper: reverse bits for LSB‑first Huffman codes ─────────────────────── - -local function reverseBits(code, len) - local r = 0 - for _ = 1, len do - r = lshift(r, 1) + band(code, 1) - code = rshift(code, 1) - end - return r -end - --- ─── Huffman trees (fixed for LSB‑first) ──────────────────────────────────── - -local function buildHuffmanTable(codeLengths) - local n = #codeLengths - local counts = {} - for i = 0, 15 do counts[i] = 0 end - for _, cl in ipairs(codeLengths) do - if cl > 0 then counts[cl] = counts[cl] + 1 end - end - - local nextCode = {} - local code = 0 - counts[0] = 0 - for bits = 1, 15 do - code = lshift(code + counts[bits - 1], 1) - nextCode[bits] = code - end - - local htable = {} - for i = 1, n do - local len = codeLengths[i] - if len > 0 then - local c = nextCode[len] - -- IMPORTANT: reverse bits because we read LSB‑first - local rev = reverseBits(c, len) - htable[rev] = { sym = i - 1, len = len } - nextCode[len] = c + 1 - end - end - return htable -end - -local function decodeHuffman(br, htable) - local code = 0 - for len = 1, 15 do - -- we read one bit at a time, LSB‑first - local bit = br:read(1) - code = bor(code, lshift(bit, len - 1)) - local entry = htable[code] - if entry and entry.len == len then - return entry.sym - end - end - error("Invalid Huffman code") -end - --- ─── Code length decoding ──────────────────────────────────────────────────── - -local CODE_LENGTH_ORDER = {17,18,0,1,2,3,4,5,16,6,7,8,9,10,11,12,13,14,15} - -local function readCodeLengths(br, n) - local numCLCodes = br:read(4) + 4 - local clLengths = {} - for i = 1, 19 do clLengths[i] = 0 end - for i = 1, numCLCodes do - clLengths[CODE_LENGTH_ORDER[i] + 1] = br:read(3) - end - local clTable = buildHuffmanTable(clLengths) - - local lengths = {} - local prev = 8 - while #lengths < n do - local sym = decodeHuffman(br, clTable) - if sym <= 15 then - lengths[#lengths + 1] = sym - if sym ~= 0 then prev = sym end - elseif sym == 16 then - local rep = br:read(2) + 3 - for _ = 1, rep do lengths[#lengths + 1] = prev end - elseif sym == 17 then - local rep = br:read(3) + 3 - for _ = 1, rep do lengths[#lengths + 1] = 0 end - elseif sym == 18 then - local rep = br:read(7) + 11 - for _ = 1, rep do lengths[#lengths + 1] = 0 end - end - end - return lengths -end - --- ─── Prefix code reading ───────────────────────────────────────────────────── - -local function readPrefixCode(br, alphabetSize) - local simpleCode = br:read(1) - if simpleCode == 1 then - local numSyms = br:read(1) + 1 - local firstSym = br:read(1) - local sym1 = br:read(firstSym == 1 and 8 or 1) - local lengths = {} - for i = 1, alphabetSize do lengths[i] = 0 end - lengths[sym1 + 1] = 1 - if numSyms == 2 then - local sym2 = br:read(8) - lengths[sym2 + 1] = 1 - end - return buildHuffmanTable(lengths) - else - local lengths = readCodeLengths(br, alphabetSize) - return buildHuffmanTable(lengths) - end -end - --- ─── Color cache ───────────────────────────────────────────────────────────── - -local function newColorCache(bits) - local size = lshift(1, bits) - local cache = {} - for i = 0, size - 1 do cache[i] = 0 end - return { - size = size, - data = cache, - insert = function(self, color) - -- hash: (0x1e35a7bd * color) >> (32 - bits), masked to cache size - local hash = tobit(0x1e35a7bd * color) - local idx = band(rshift(hash, 32 - bits), size - 1) - self.data[idx] = color - end, - lookup = function(self, idx) - return self.data[idx] - end, - } -end - --- ─── Transform type constants ──────────────────────────────────────────────── - -local TRANSFORM_PREDICTOR = 0 -local TRANSFORM_COLOR = 1 -local TRANSFORM_SUBTRACT_GREEN = 2 -local TRANSFORM_COLOR_INDEXING = 3 - --- ─── Prefix length/distance tables ────────────────────────────────────────── - -local PREFIX_EXTRA_BITS = { - 0,0,0,0, 1,1,2,2, 3,3,4,4, 5,5,6,6, 7,7,8,8, 9,9,10,10, 11,11,12,12, 13,13 -} -local PREFIX_OFFSET = { - 0,1,2,3, 4,6,8,12, 16,24,32,48, 64,96,128,192, 256,384,512,768, - 1024,1536,2048,3072, 4096,6144,8192,12288, 16384,24576 -} - -local function prefixToValue(br, code) - if code < 4 then return code end - local extra = PREFIX_EXTRA_BITS[code + 1] or 0 - local offset = PREFIX_OFFSET[code + 1] or 0 - return offset + br:read(extra) -end - --- ─── VP8L distance offset table (120 entries) ──────────────────────────────── - -local DIST_MAP = { - {0,1},{1,0},{1,1},{-1,1},{0,2},{2,0},{1,2},{-1,2}, - {2,1},{-2,1},{2,2},{-2,2},{0,3},{3,0},{1,3},{-1,3}, - {3,1},{-3,1},{2,3},{-2,3},{3,2},{-3,2},{0,4},{4,0}, - {1,4},{-1,4},{4,1},{-4,1},{3,3},{-3,3},{2,4},{-2,4}, - {4,2},{-4,2},{0,5},{3,4},{-3,4},{4,3},{-4,3},{5,0}, - {1,5},{-1,5},{5,1},{-5,1},{2,5},{-2,5},{5,2},{-5,2}, - {4,4},{-4,4},{3,5},{-3,5},{5,3},{-5,3},{0,6},{6,0}, - {1,6},{-1,6},{6,1},{-6,1},{2,6},{-2,6},{6,2},{-6,2}, - {4,5},{-4,5},{5,4},{-5,4},{3,6},{-3,6},{6,3},{-6,3}, - {0,7},{7,0},{1,7},{-1,7},{5,5},{-5,5},{7,1},{-7,1}, - {4,6},{-4,6},{6,4},{-6,4},{2,7},{-2,7},{7,2},{-7,2}, - {3,7},{-3,7},{7,3},{-7,3},{5,6},{-5,6},{6,5},{-6,5}, - {8,0},{4,7},{-4,7},{7,4},{-7,4},{8,1},{8,2},{6,6}, - {-6,6},{8,3},{5,7},{-5,7},{7,5},{-7,5},{8,4},{6,7}, - {-6,7},{7,6},{-7,6},{8,5},{8,6},{7,7},{-7,7},{8,7}, -} - --- ─── Main VP8L decode (recursive for transform sub-images) ─────────────────── - -local function decodeVP8L(br, width, height) - - -- color cache - local hasCCache = br:readBool() - local ccache = nil - local ccacheBits = 0 - if hasCCache then - ccacheBits = br:read(4) - ccache = newColorCache(ccacheBits) - end - - -- collect transforms (applied in reverse after decode) - local transforms = {} - while br:readBool() do - local ttype = br:read(2) - local t = { ttype = ttype } - if ttype == TRANSFORM_PREDICTOR or ttype == TRANSFORM_COLOR then - t.sizeBits = br:read(3) + 2 - local tw = math.floor((width + lshift(1, t.sizeBits) - 1) / lshift(1, t.sizeBits)) - local th = math.floor((height + lshift(1, t.sizeBits) - 1) / lshift(1, t.sizeBits)) - t.data = decodeVP8L(br, tw, th) - elseif ttype == TRANSFORM_COLOR_INDEXING then - t.numColors = br:read(8) + 1 - t.colors = decodeVP8L(br, t.numColors, 1) - end - -- SUBTRACT_GREEN has no extra data - transforms[#transforms + 1] = t - end - - -- entropy/meta-Huffman image - local groupBits = 0 - local groupImage = nil - local numGroups = 1 - if br:readBool() then - groupBits = br:read(3) + 2 - local gw = math.floor((width + lshift(1, groupBits) - 1) / lshift(1, groupBits)) - local gh = math.floor((height + lshift(1, groupBits) - 1) / lshift(1, groupBits)) - groupImage = decodeVP8L(br, gw, gh) - local maxG = 0 - for _, v in ipairs(groupImage) do - local g = band(rshift(v, 8), 0xffff) - if g > maxG then maxG = g end - end - numGroups = maxG + 1 - end - - -- alphabet sizes - local ccSize = hasCCache and lshift(1, ccacheBits) or 0 - local alphabetG = 256 + 24 + ccSize - - -- read Huffman tables for each group (G, R, B, A + distance) - local huffGroups = {} - for g = 1, numGroups do - huffGroups[g] = { - G = readPrefixCode(br, alphabetG), - R = readPrefixCode(br, 256), - B = readPrefixCode(br, 256), - A = readPrefixCode(br, 256), - dist = readPrefixCode(br, 40), - } - end - - -- decode pixels - local pixels = {} - local numPixels = width * height - local px, py = 0, 0 - - local function getGroup() - if not groupImage then return huffGroups[1] end - local gx = rshift(px, groupBits) - local gy = rshift(py, groupBits) - local gw = math.floor((width + lshift(1, groupBits) - 1) / lshift(1, groupBits)) - local idx = gy * gw + gx + 1 - local g = band(rshift(groupImage[idx] or 0, 8), 0xffff) - return huffGroups[g + 1] or huffGroups[1] - end - - while #pixels < numPixels do - local hg = getGroup() - local code = decodeHuffman(br, hg.G) - - if code < 256 then - -- literal ARGB (green first, then R, B, A) - local g = code - local r = decodeHuffman(br, hg.R) - local b = decodeHuffman(br, hg.B) - local a = decodeHuffman(br, hg.A) - local color = bor(bor(bor(lshift(a, 24), lshift(r, 16)), lshift(g, 8)), b) - pixels[#pixels + 1] = color - if ccache then ccache:insert(color) end - - elseif code < 256 + 24 then - -- LZ77 back-reference - local lenCode = code - 256 - local length = prefixToValue(br, lenCode) + 1 - local distCode = decodeHuffman(br, hg.dist) - local dist = prefixToValue(br, distCode) + 1 - - -- remap small distances through VP8L spatial table - if dist <= 120 then - local d = DIST_MAP[dist] - local srcX = px - d[1] - local srcY = py - d[2] - dist = py * width + px - (srcY * width + srcX) - end - - local src = #pixels - dist + 1 - for i = 0, length - 1 do - local c = pixels[src + i] or 0 - pixels[#pixels + 1] = c - if ccache then ccache:insert(c) end - end - - else - -- color cache reference - local cacheIdx = code - 256 - 24 - pixels[#pixels + 1] = ccache:lookup(cacheIdx) - end - - px = px + 1 - if px >= width then px = 0; py = py + 1 end - end - - -- ─── Apply transforms in reverse order ─────────────────────────────────── - - for i = #transforms, 1, -1 do - local t = transforms[i] - - -- SUBTRACT_GREEN: R += G, B += G (mod 256) - if t.ttype == TRANSFORM_SUBTRACT_GREEN then - for idx = 1, #pixels do - local c = pixels[idx] - local a = band(rshift(c, 24), 0xff) - local r = band(rshift(c, 16), 0xff) - local g = band(rshift(c, 8), 0xff) - local b = band(c, 0xff) - r = band(r + g, 0xff) - b = band(b + g, 0xff) - pixels[idx] = bor(bor(bor(lshift(a, 24), lshift(r, 16)), lshift(g, 8)), b) - end - - -- COLOR_INDEXING: replace each pixel's green channel with palette entry - elseif t.ttype == TRANSFORM_COLOR_INDEXING then - local bpp = 8 - if t.numColors <= 2 then bpp = 1 - elseif t.numColors <= 4 then bpp = 2 - elseif t.numColors <= 16 then bpp = 4 end - - local newPixels = {} - if bpp == 8 then - for _, c in ipairs(pixels) do - local idx = band(rshift(c, 8), 0xff) - newPixels[#newPixels + 1] = t.colors[idx + 1] or 0 - end - else - local pxPerByte = 8 / bpp - local mask = lshift(1, bpp) - 1 - for _, c in ipairs(pixels) do - local packed = band(rshift(c, 8), 0xff) - for p = 0, pxPerByte - 1 do - if #newPixels < width * height then - local idx = band(rshift(packed, p * bpp), mask) - newPixels[#newPixels + 1] = t.colors[idx + 1] or 0 - end - end - end - end - pixels = newPixels - - -- PREDICTOR: undo per-block spatial prediction - elseif t.ttype == TRANSFORM_PREDICTOR then - local sb = t.sizeBits - local tw = math.floor((width + lshift(1, sb) - 1) / lshift(1, sb)) - - local function getpx(x, y) - if x < 0 then x = 0 end - if y < 0 then return tobit(0xff000000) end - return pixels[y * width + x + 1] or 0 - end - - local function addARGB(a, b) - local aa = band(rshift(a, 24), 0xff) - local ar = band(rshift(a, 16), 0xff) - local ag = band(rshift(a, 8), 0xff) - local ab = band(a, 0xff) - local ba = band(rshift(b, 24), 0xff) - local br_ = band(rshift(b, 16), 0xff) - local bg = band(rshift(b, 8), 0xff) - local bb = band(b, 0xff) - return bor(bor(bor( - lshift(band(aa + ba, 0xff), 24), - lshift(band(ar + br_, 0xff), 16)), - lshift(band(ag + bg, 0xff), 8)), - band(ab + bb, 0xff)) - end - - local function avgARGB(a, b) - local aa = band(rshift(a, 24), 0xff) - local ar = band(rshift(a, 16), 0xff) - local ag = band(rshift(a, 8), 0xff) - local ab = band(a, 0xff) - local ba = band(rshift(b, 24), 0xff) - local br_ = band(rshift(b, 16), 0xff) - local bg = band(rshift(b, 8), 0xff) - local bb = band(b, 0xff) - return bor(bor(bor( - lshift(rshift(aa + ba, 1), 24), - lshift(rshift(ar + br_, 1), 16)), - lshift(rshift(ag + bg, 1), 8)), - rshift(ab + bb, 1)) - end - - local function clampByte(v) - return math.max(0, math.min(255, v)) - end - - local function clampAddARGB(a, b) - local aa = band(rshift(a, 24), 0xff) - local ar = band(rshift(a, 16), 0xff) - local ag = band(rshift(a, 8), 0xff) - local ab = band(a, 0xff) - local ba = band(rshift(b, 24), 0xff) - local br_ = band(rshift(b, 16), 0xff) - local bg = band(rshift(b, 8), 0xff) - local bb = band(b, 0xff) - return bor(bor(bor( - lshift(clampByte(aa + ba), 24), - lshift(clampByte(ar + br_), 16)), - lshift(clampByte(ag + bg), 8)), - clampByte(ab + bb)) - end - - local function subARGB(a, b) - local aa = band(rshift(a, 24), 0xff) - local ar = band(rshift(a, 16), 0xff) - local ag = band(rshift(a, 8), 0xff) - local ab = band(a, 0xff) - local ba = band(rshift(b, 24), 0xff) - local br_ = band(rshift(b, 16), 0xff) - local bg = band(rshift(b, 8), 0xff) - local bb = band(b, 0xff) - return bor(bor(bor( - lshift(band(aa - ba, 0xff), 24), - lshift(band(ar - br_, 0xff), 16)), - lshift(band(ag - bg, 0xff), 8)), - band(ab - bb, 0xff)) - end - - local function halfSubARGB(a, b) - local aa = band(rshift(a, 24), 0xff) - local ar = band(rshift(a, 16), 0xff) - local ag = band(rshift(a, 8), 0xff) - local ab = band(a, 0xff) - local ba = band(rshift(b, 24), 0xff) - local br_ = band(rshift(b, 16), 0xff) - local bg = band(rshift(b, 8), 0xff) - local bb = band(b, 0xff) - return bor(bor(bor( - lshift(rshift(aa - ba, 1), 24), - lshift(rshift(ar - br_, 1), 16)), - lshift(rshift(ag - bg, 1), 8)), - rshift(ab - bb, 1)) - end - - local function selectARGB(l, tp, tl) - local function absdiff(x, y, s) - return math.abs(band(rshift(x, s), 0xff) - band(rshift(y, s), 0xff)) - end - local function dist(x, y) - return absdiff(x,y,24) + absdiff(x,y,16) + absdiff(x,y,8) + absdiff(x,y,0) - end - return dist(l, tl) <= dist(tp, tl) and l or tp - end - - for iy = 0, height - 1 do - for ix = 0, width - 1 do - if not (ix == 0 and iy == 0) then - local pidx = iy * width + ix + 1 - local L = getpx(ix - 1, iy) - local T = getpx(ix, iy - 1) - local TL = getpx(ix - 1, iy - 1) - local TR = getpx(ix + 1, iy - 1) - local tx = rshift(ix, sb) - local ty = rshift(iy, sb) - local mode = band(t.data[ty * tw + tx + 1] or 0, 0xff) - - local pred - if mode == 0 then pred = tobit(0xff000000) - elseif mode == 1 then pred = L - elseif mode == 2 then pred = T - elseif mode == 3 then pred = TR - elseif mode == 4 then pred = TL - elseif mode == 5 then pred = avgARGB(avgARGB(L, TR), T) - elseif mode == 6 then pred = avgARGB(L, TL) - elseif mode == 7 then pred = avgARGB(L, T) - elseif mode == 8 then pred = avgARGB(TL, T) - elseif mode == 9 then pred = avgARGB(T, TR) - elseif mode == 10 then pred = avgARGB(avgARGB(L, TL), avgARGB(T, TR)) - elseif mode == 11 then pred = selectARGB(L, T, TL) - elseif mode == 12 then pred = clampAddARGB(L, subARGB(T, TL)) - elseif mode == 13 then - local avg = avgARGB(L, T) - pred = clampAddARGB(avg, halfSubARGB(avg, TL)) - else pred = L end - - pixels[pidx] = addARGB(pixels[pidx], pred) - end - end - end - - -- COLOR: undo green/red channel correlations - elseif t.ttype == TRANSFORM_COLOR then - local sb = t.sizeBits - local tw = math.floor((width + lshift(1, sb) - 1) / lshift(1, sb)) - for iy = 0, height - 1 do - for ix = 0, width - 1 do - local pidx = iy * width + ix + 1 - local c = pixels[pidx] or 0 - local tx = rshift(ix, sb) - local ty = rshift(iy, sb) - local m = t.data[ty * tw + tx + 1] or 0 - - -- unpack signed bytes from transform pixel (stored as ARGB) - local g2r = band(rshift(m, 16), 0xff) - local r2b = band(rshift(m, 8), 0xff) - local g2b = band(m, 0xff) - if g2r >= 128 then g2r = g2r - 256 end - if r2b >= 128 then r2b = r2b - 256 end - if g2b >= 128 then g2b = g2b - 256 end - - local a = band(rshift(c, 24), 0xff) - local r = band(rshift(c, 16), 0xff) - local g = band(rshift(c, 8), 0xff) - local b = band(c, 0xff) - - r = band(r + math.floor(g2r * g / 256), 0xff) - b = band(b + math.floor(g2b * g / 256) + math.floor(r2b * r / 256), 0xff) - - pixels[pidx] = bor(bor(bor(lshift(a, 24), lshift(r, 16)), lshift(g, 8)), b) - end - end - end - end - - return pixels -end - --- ═══════════════════════════════════════════════════════════════════════════ --- VP8 (LOSSY) DECODER --- ═══════════════════════════════════════════════════════════════════════════ - --- ─── Boolean arithmetic decoder ────────────────────────────────────────────── --- VP8 uses a range coder (not Huffman). Each symbol is decoded against a --- probability in [0,255]. The range is maintained in [128,255]<= bigsplit then - retval = 1 - b.range = b.range - split - b.value = b.value - bigsplit - else - retval = 0 - b.range = split - end - -- renormalise - while b.range < 128 do - b.range = lshift(b.range, 1) - b.value = lshift(b.value, 1) - b.bits = b.bits + 1 - if b.bits == 8 then - local byte = b.data:byte(b.pos) or 0 - b.pos = b.pos + 1 - b.value = bor(b.value, byte) - b.bits = 0 - end - end - return retval -end - -local function boolReadLit(b, n) - local v = 0 - for _ = 1, n do - v = lshift(v, 1) + boolRead(b, 128) - end - return v -end - -local function boolReadSigned(b, n) - local v = boolReadLit(b, n) - if boolRead(b, 128) == 1 then v = -v end - return v -end - --- ─── VP8 probability tables ─────────────────────────────────────────────────── - --- Coefficient probability table (384 entries, from the VP8 spec Annex B) --- Indexed as [band][ctx][node] but flattened here for brevity. --- This is the default/initial probability table before any updates. -local VP8_COEF_PROBS = { - -- block type 0: Y after DC (4x4 intra) - { -- band 0 - {128,128,128,128,128,128,128,128,128,128,128}, - {128,128,128,128,128,128,128,128,128,128,128}, - {128,128,128,128,128,128,128,128,128,128,128}, - }, - { -- band 1 - {253,136,254,255,228,219,128,128,128,128,128}, - {189,129,242,255,227,213,255,219,128,128,128}, - {106,126,227,252,214,209,255,255,128,128,128}, - }, - { -- band 2 - { 1, 98,248,255,236,226,255,255,128,128,128}, - {181,133,238,254,221,234,255,154,128,128,128}, - { 78,134,202,247,198,180,255,219,128,128,128}, - }, - { -- band 3 - { 1,185,249,255,243,255,128,128,128,128,128}, - {184,150,247,255,236,224,128,128,128,128,128}, - { 77,110,216,255,236,230,128,128,128,128,128}, - }, - { -- band 4 - { 1,101,251,255,241,255,128,128,128,128,128}, - {170,139,241,252,236,209,255,255,128,128,128}, - { 37, 116,196,243,228,255,255,255,128,128,128}, - }, - { -- band 5 - { 1,204,254,255,245,255,128,128,128,128,128}, - {207,160,250,255,238,128,128,128,128,128,128}, - {102,103,231,255,211,171,128,128,128,128,128}, - }, - { -- band 6 - { 1,152,252,255,240,255,128,128,128,128,128}, - {177,135,243,255,234,225,128,128,128,128,128}, - { 80,129,211,255,194,224,128,128,128,128,128}, - }, - { -- band 7 - { 1, 1,255,128,128,128,128,128,128,128,128}, - {246, 1,255,128,128,128,128,128,128,128,128}, - {255,128,128,128,128,128,128,128,128,128,128}, - }, -} - --- Intra mode probabilities (from VP8 spec section 11) -local VP8_KEYFRAME_YMODE_PROB = {145, 156, 163, 128} -- DC, V, H, TM -local VP8_KEYFRAME_UVMODE_PROB = {142, 114, 183} - --- Default MV probabilities (from spec) -local VP8_MV_UPDATE_PROBS = { - {237,246,253,253,254,254,254,254,254,254,254,254,254,254,254,254,254,254,254}, - {231,243,245,253,254,254,254,254,254,254,254,254,254,254,254,254,254,254,254}, -} -local VP8_MV_DEFAULT_PROBS = { - {162,128,225,146,172,147,214, 39,156,128,129,132, 75,145,178,206,239,254,254}, - {164,128,204,170,119,235,140,230,228,128,130,130, 74,148,180,203,236,254,254}, -} - --- ─── Zigzag scan order for 4x4 DCT block ───────────────────────────────────── -local ZIGZAG = {0,1,4,8,5,2,3,6,9,12,13,10,7,11,14,15} - --- ─── Quantizer table ────────────────────────────────────────────────────────── -local function dcQ(q) - if q == 0 then return 4 end - return q < 134 and (q * 2 + 3 > 132 and 132 or q * 2 + 3) - or (5 * q + 77 > 2040 and 2040 or 5 * q + 77) -end -local function acQ(q) - return q < 6 and 8 or - q < 10 and (6 * q - 12) or - q < 126 and (q * 2 + 3 > 255 and 255 or q * 2 + 3) or - (5 * q - 370 > 2040 and 2040 or 5 * q - 370) -end - --- From VP8 spec Table 14 (DC/AC quantizer index tables) -local VP8_DC_QLOOKUP = {} -local VP8_AC_QLOOKUP = {} -do - -- spec tables verbatim - local dc = {4,5,6,7,8,9,10,10,11,12,13,14,15,16,17,17,18,19,20,20,21,21,22,22,23,23,24,25,25,26,27,28,29,30,31,32,33,34,35,36,37,37,38,39,40,41,42,43,44,45,46,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,76,77,78,79,80,81,82,83,84,85,86,87,88,89,91,93,95,96,97,98,100,101,102,104,106,108,110,112,114,116,118,122,124,126,128,130,132,134,136,138,140,143,145,148,151,154,157} - local ac = {4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,60,62,64,66,68,70,72,74,76,79,81,84,87,90,93,96,99,102,105,108,111,114,117,120,124,128,132,136,140,144,148,152,156,160,164,168,172,176,180,184,188,192,196,200,205,210,215,220,225,230,235,240,245,249,254,254} - for i,v in ipairs(dc) do VP8_DC_QLOOKUP[i-1] = v end - for i,v in ipairs(ac) do VP8_AC_QLOOKUP[i-1] = v end -end - --- ─── Inverse DCT (4x4, in-place on a 16-element array, 1-based) ────────────── -local function idct4x4(c) - -- rows - for i = 0, 3 do - local a0 = c[i*4+1] + c[i*4+3] - local a1 = c[i*4+1] - c[i*4+3] - local a2 = rshift(c[i*4+2], 1) - c[i*4+4] - local a3 = c[i*4+2] + rshift(c[i*4+4], 1) - c[i*4+1] = a0 + a3 - c[i*4+2] = a1 + a2 - c[i*4+3] = a1 - a2 - c[i*4+4] = a0 - a3 - end - -- columns - for i = 0, 3 do - local a0 = c[i+1] + c[i+9] - local a1 = c[i+1] - c[i+9] - local a2 = rshift(c[i+5], 1) - c[i+13] - local a3 = c[i+5] + rshift(c[i+13], 1) - c[i+1] = rshift(a0 + a3 + 4, 3) - c[i+5] = rshift(a1 + a2 + 4, 3) - c[i+9] = rshift(a1 - a2 + 4, 3) - c[i+13] = rshift(a0 - a3 + 4, 3) - end -end - --- WHT (Walsh-Hadamard, 4x4 for DC coefficients) -local function iwht4x4(c) - for i = 0, 3 do - local a0 = c[i*4+1] + c[i*4+3] - local a1 = c[i*4+2] + c[i*4+4] - local a2 = c[i*4+2] - c[i*4+4] - local a3 = c[i*4+1] - c[i*4+3] - c[i*4+1] = a0 + a1 - c[i*4+2] = a3 + a2 - c[i*4+3] = a0 - a1 - c[i*4+4] = a3 - a2 - end - for i = 0, 3 do - local a0 = c[i+1] + c[i+9] - local a1 = c[i+5] + c[i+13] - local a2 = c[i+5] - c[i+13] - local a3 = c[i+1] - c[i+9] - c[i+1] = rshift(a0 + a1, 3) - c[i+5] = rshift(a3 + a2, 3) - c[i+9] = rshift(a0 - a1, 3) - c[i+13] = rshift(a3 - a2, 3) - end -end - --- ─── Coefficient decoding ───────────────────────────────────────────────────── --- Decode one 4x4 block of DCT coefficients from the bool decoder. --- plane: 0=Y 1=UV, blockCtx: context from neighbouring blocks -local function decodeCoefficients(bool, probs, firstCoef, lastCoef, blockCtx) - local coeffs = {} - for i = 0, 15 do coeffs[i] = 0 end - - local ctx = blockCtx - local i = firstCoef - - while i <= lastCoef do - local p = probs[i + 1] or probs[1] - -- EOB or nonzero? - if boolRead(bool, p[ctx + 1] and p[ctx+1][1] or 128) == 0 then - break -- end of block - end - -- skip zeros - while boolRead(bool, (p[ctx+1] and p[ctx+1][2]) or 128) == 0 do - i = i + 1 - if i > lastCoef then return coeffs, 0 end - ctx = 0 - p = probs[i + 1] or probs[1] - end - -- read coefficient value - local v - local pp = p[ctx+1] or {} - if boolRead(bool, pp[3] or 128) == 0 then - v = 1 - elseif boolRead(bool, pp[4] or 128) == 0 then - if boolRead(bool, pp[5] or 128) == 0 then - v = 2 - else - v = 3 + boolRead(bool, pp[6] or 128) - end - elseif boolRead(bool, pp[7] or 128) == 0 then - if boolRead(bool, pp[8] or 128) == 0 then - v = 5 + boolRead(bool, 159) - else - v = 7 + 2 * boolRead(bool, 165) + boolRead(bool, 145) - end - elseif boolRead(bool, pp[9] or 128) == 0 then - local cat3 = {pp[10] or 128, 165, 145} - v = 11 - for _, pr in ipairs(cat3) do v = v * 2 + boolRead(bool, pr) end - elseif boolRead(bool, pp[10] or 128) == 0 then - local cat4 = {pp[11] or 128, 165, 145, 128} - v = 19 - for _, pr in ipairs(cat4) do v = v * 2 + boolRead(bool, pr) end - else - -- cat5 or cat6 - if boolRead(bool, 128) == 0 then - v = 35 - for _, pr in ipairs({165,145,128,128,128}) do - v = v * 2 + boolRead(bool, pr) - end - else - v = 67 - for _, pr in ipairs({145,128,128,128,128,128}) do - v = v * 2 + boolRead(bool, pr) - end - end - end - -- sign bit - if boolRead(bool, 128) == 1 then v = -v end - coeffs[ZIGZAG[i + 1]] = v - ctx = (math.abs(v) == 1) and 1 or 2 - i = i + 1 - end - - local nzCount = 0 - for _, v in ipairs(coeffs) do if v ~= 0 then nzCount = nzCount + 1 end end - return coeffs, (nzCount > 0 and 1 or 0) -end - --- ─── Intra prediction modes ─────────────────────────────────────────────────── --- B_PRED sub-modes for 4x4 luma intra prediction -local B_DC_PRED, B_TM_PRED, B_VE_PRED, B_HE_PRED = 0,1,2,3 -local B_LD_PRED, B_RD_PRED, B_VR_PRED, B_VL_PRED = 4,5,6,7 -local B_HD_PRED, B_HU_PRED = 8,9 - --- Clamp to [0,255] -local function clamp8(v) return math.max(0, math.min(255, v)) end - --- Fill a 4x4 block in a flat array (stride = mbw*4) using intra prediction. --- out: flat Y/U/V plane array (1-based, row-major) --- x4,y4: top-left pixel coords in the plane --- stride: row stride --- mode: prediction mode --- above: 8 pixels above (indices 0..7, may be nil for top-of-image) --- left: 4 pixels to left (indices 0..3, may be nil for left-of-image) -local function predictBlock4x4(out, x4, y4, stride, mode, above, left, aboveLeft) - local function set(px, py, v) - out[(y4 + py) * stride + x4 + px + 1] = v - end - local A = above or {127,127,127,127,127,127,127,127} - local L = left or {129,129,129,129} - local TL = aboveLeft or 127 - - if mode == B_DC_PRED then - local sum, n = 0, 0 - if above then for i=0,3 do sum=sum+A[i+1]; n=n+1 end end - if left then for i=0,3 do sum=sum+L[i+1]; n=n+1 end end - local dc = n > 0 and rshift(sum + rshift(n, 1), n == 8 and 3 or 2) or 128 - for py=0,3 do for px=0,3 do set(px,py,dc) end end - - elseif mode == B_TM_PRED then - for py=0,3 do for px=0,3 do - set(px, py, clamp8(A[px+1] + L[py+1] - TL)) - end end - - elseif mode == B_VE_PRED then - for py=0,3 do for px=0,3 do set(px,py,A[px+1]) end end - - elseif mode == B_HE_PRED then - for py=0,3 do for px=0,3 do set(px,py,L[py+1]) end end - - elseif mode == B_LD_PRED then - local a = {A[1],A[2],A[3],A[4],A[5],A[6],A[7],A[8]} - local function ld(i) - local x0 = i < 7 and a[i+1] or a[8] - local x1 = i < 7 and a[i+2] or a[8] - local x2 = i < 6 and a[i+3] or a[8] - return rshift(x0 + 2*x1 + x2 + 2, 2) - end - for py=0,3 do for px=0,3 do set(px,py,ld(px+py)) end end - - elseif mode == B_RD_PRED then - local a = {TL,A[1],A[2],A[3],A[4],L[1],L[2],L[3],L[4]} - local function rd(i) -- i in 0..6 - local x0 = a[i+1] or a[1] - local x1 = a[i+2] or a[1] - local x2 = a[i+3] or a[1] - return rshift(x0 + 2*x1 + x2 + 2, 2) - end - for py=0,3 do for px=0,3 do - local d = px - py - set(px, py, rd(d + 4)) - end end - - elseif mode == B_VR_PRED then - local a = {TL,A[1],A[2],A[3],A[4],L[1],L[2],L[3]} - for py=0,3 do for px=0,3 do - local x = 2*px - py - local v - if x >= 0 then - local ai = x / 2 + 1 - if x % 2 == 0 then - v = rshift((a[ai] or 127) + (a[ai+1] or 127) + 1, 1) - else - v = rshift((a[ai] or 127) + 2*(a[ai+1] or 127) + (a[ai+2] or 127) + 2, 2) - end - else - v = rshift(L[-x] + 2*(L[-x+1] or L[4]) + (L[-x+2] or L[4]) + 2, 2) - end - set(px, py, clamp8(v)) - end end - - elseif mode == B_VL_PRED then - for py=0,3 do for px=0,3 do - local x = px + rshift(py, 1) - local v - if py % 2 == 0 then - v = rshift((A[x+1] or A[8]) + (A[x+2] or A[8]) + 1, 1) - else - v = rshift((A[x+1] or A[8]) + 2*(A[x+2] or A[8]) + (A[x+3] or A[8]) + 2, 2) - end - set(px, py, clamp8(v)) - end end - - elseif mode == B_HD_PRED then - local a = {L[4],L[3],L[2],L[1],TL,A[1],A[2],A[3],A[4]} - for py=0,3 do for px=0,3 do - local x = 2*py - px - local v - if x >= 0 then - local ai = rshift(x, 1) + 1 - if x % 2 == 0 then - v = rshift((a[ai] or 127) + (a[ai+1] or 127) + 1, 1) - else - v = rshift((a[ai] or 127) + 2*(a[ai+1] or 127) + (a[ai+2] or 127) + 2, 2) - end - else - v = rshift(A[-x] + 2*(A[-x+1] or A[8]) + (A[-x+2] or A[8]) + 2, 2) - end - set(px, py, clamp8(v)) - end end - - elseif mode == B_HU_PRED then - for py=0,3 do for px=0,3 do - local x = px + 2*py - local v - if x + 1 < 4 then - v = rshift(L[x+1] + L[x+2] + 1, 1) - elseif x == 6 then - v = rshift(L[4] + 3*L[4] + 2, 2) - else - v = L[4] - end - set(px, py, clamp8(v)) - end end - end -end - --- 16x16 luma intra prediction (DC, V, H, TM) -local function predictMB16(plane, mbx, mby, mbw, mode, aboveRow, leftCol) - local base_x = mbx * 16 - local base_y = mby * 16 - local stride = mbw * 16 - - if mode == 0 then -- DC_PRED - local sum, n = 0, 0 - if aboveRow then for i=0,15 do sum=sum+aboveRow[i+1]; n=n+1 end end - if leftCol then for i=0,15 do sum=sum+leftCol[i+1]; n=n+1 end end - local dc = n > 0 and rshift(sum + rshift(n, 1), n == 32 and 5 or 4) or 128 - for py=0,15 do for px=0,15 do - plane[(base_y+py)*stride + base_x+px + 1] = dc - end end - - elseif mode == 1 then -- V_PRED - local src = aboveRow or {} - for py=0,15 do for px=0,15 do - plane[(base_y+py)*stride + base_x+px + 1] = src[px+1] or 127 - end end - - elseif mode == 2 then -- H_PRED - local src = leftCol or {} - for py=0,15 do for px=0,15 do - plane[(base_y+py)*stride + base_x+px + 1] = src[py+1] or 129 - end end - - elseif mode == 3 then -- TM_PRED - local tl = (aboveRow and leftCol) and 127 or 128 - if aboveRow and leftCol then - -- tl is above-left of macroblock - tl = 127 -- approximation; proper value from frame buffer - end - local A = aboveRow or {} - local L = leftCol or {} - for py=0,15 do for px=0,15 do - plane[(base_y+py)*stride + base_x+px + 1] = - clamp8((A[px+1] or 127) + (L[py+1] or 129) - tl) - end end - end -end - --- 8x8 chroma intra prediction -local function predictMB8(plane, mbx, mby, mbw, mode, aboveRow, leftCol) - local base_x = mbx * 8 - local base_y = mby * 8 - local stride = mbw * 8 - - if mode == 0 then -- DC - local sum, n = 0, 0 - if aboveRow then for i=0,7 do sum=sum+aboveRow[i+1]; n=n+1 end end - if leftCol then for i=0,7 do sum=sum+leftCol[i+1]; n=n+1 end end - local dc = n > 0 and rshift(sum + rshift(n, 1), n == 16 and 4 or 3) or 128 - for py=0,7 do for px=0,7 do - plane[(base_y+py)*stride + base_x+px + 1] = dc - end end - elseif mode == 1 then -- V - local src = aboveRow or {} - for py=0,7 do for px=0,7 do - plane[(base_y+py)*stride + base_x+px + 1] = src[px+1] or 127 - end end - elseif mode == 2 then -- H - local src = leftCol or {} - for py=0,7 do for px=0,7 do - plane[(base_y+py)*stride + base_x+px + 1] = src[py+1] or 129 - end end - elseif mode == 3 then -- TM - local A = aboveRow or {} - local L = leftCol or {} - for py=0,7 do for px=0,7 do - plane[(base_y+py)*stride + base_x+px + 1] = clamp8((A[px+1] or 127) + (L[py+1] or 129) - 127) - end end - end -end - --- ─── Add residuals to prediction ───────────────────────────────────────────── -local function addResiduals(plane, base_x, base_y, stride, coeffs) - for py = 0, 3 do - for px = 0, 3 do - local idx = (base_y + py) * stride + base_x + px + 1 - plane[idx] = clamp8((plane[idx] or 0) + (coeffs[py * 4 + px + 1] or 0)) - end - end -end - --- ─── Simple loop filter ─────────────────────────────────────────────────────── -local function filterSimple(plane, stride, w, h) - -- horizontal edges - for y = 1, h - 1 do - for x = 0, w - 1 do - local p1 = plane[(y-1)*stride + x + 1] or 128 - local q1 = plane[ y *stride + x + 1] or 128 - local d = rshift(3*(q1 - p1) + 4, 3) - d = math.max(-4, math.min(4, d)) - plane[(y-1)*stride+x+1] = clamp8(p1 + d) - plane[ y *stride+x+1] = clamp8(q1 - d) - end - end - -- vertical edges - for y = 0, h - 1 do - for x = 1, w - 1 do - local p1 = plane[y*stride + x] or 128 - local q1 = plane[y*stride + x + 1] or 128 - local d = rshift(3*(q1 - p1) + 4, 3) - d = math.max(-4, math.min(4, d)) - plane[y*stride+x] = clamp8(p1 + d) - plane[y*stride+x+1] = clamp8(q1 - d) - end - end -end - --- ─── YCbCr → RGB conversion ─────────────────────────────────────────────────── --- VP8 uses BT.601 studio swing: --- R = Y + 1.402*(Cr-128) --- G = Y - 0.344*(Cb-128) - 0.714*(Cr-128) --- B = Y + 1.772*(Cb-128) -local function yuvToRgb(y, cb, cr) - local r = clamp8(math.floor(y + 1.402 * (cr - 128) + 0.5)) - local g = clamp8(math.floor(y - 0.34414 * (cb - 128) - 0.71414 * (cr - 128) + 0.5)) - local b = clamp8(math.floor(y + 1.772 * (cb - 128) + 0.5)) - return r, g, b -end - --- ─── Main VP8 decode ────────────────────────────────────────────────────────── -local function decodeVP8(data, offset, length) - offset = offset or 1 - -- Frame tag (3 bytes) - local b0 = data:byte(offset) - local b1 = data:byte(offset + 1) - local b2 = data:byte(offset + 2) - local keyFrame = band(b0, 1) == 0 - local version = band(rshift(b0, 1), 7) - local showFrame = band(rshift(b0, 4), 1) == 1 - local firstPartSize = bor(rshift(b0, 5), bor(lshift(b1, 3), lshift(b2, 11))) - - assert(keyFrame, "VP8 inter frames not supported (not a keyframe)") - - -- Start code (3 bytes: 0x9d 0x01 0x2a) - assert(data:byte(offset+3) == 0x9d and - data:byte(offset+4) == 0x01 and - data:byte(offset+5) == 0x2a, "Invalid VP8 start code") - - local w_raw = bor(data:byte(offset+6), lshift(data:byte(offset+7), 8)) - local h_raw = bor(data:byte(offset+8), lshift(data:byte(offset+9), 8)) - local width = band(w_raw, 0x3fff) - local height = band(h_raw, 0x3fff) - local hscale = rshift(w_raw, 14) - local vscale = rshift(h_raw, 14) - - -- macroblock dimensions - local mbw = rshift(width + 15, 4) - local mbh = rshift(height + 15, 4) - - -- First partition starts at offset+3 (after frame tag) - local bool = newBoolDecoder(data, offset + 3) - - -- ── Frame header ── - -- color space and clamping - local colorSpace = boolRead(bool, 128) - local clampType = boolRead(bool, 128) - - -- segmentation - local segmentEnabled = boolRead(bool, 128) - local segmentAbsDelta = false - local segQuant = {0,0,0,0} - local segFilter = {0,0,0,0} - if segmentEnabled == 1 then - local updateMap = boolRead(bool, 128) - local updateData = boolRead(bool, 128) - if updateData == 1 then - segmentAbsDelta = boolRead(bool, 128) == 1 - for i = 1, 4 do - if boolRead(bool, 128) == 1 then - segQuant[i] = boolReadSigned(bool, 7) - end - end - for i = 1, 4 do - if boolRead(bool, 128) == 1 then - segFilter[i] = boolReadSigned(bool, 6) - end - end - end - if updateMap == 1 then - -- read 3 probabilities (skip for keyframe - all MBs resend) - for _ = 1, 3 do - if boolRead(bool, 128) == 1 then boolReadLit(bool, 8) end - end - end - end - - -- loop filter - local filterType = boolRead(bool, 128) -- 0=normal, 1=simple - local filterLevel = boolReadLit(bool, 6) - local filterSharp = boolReadLit(bool, 3) - local lfAdjEnable = boolRead(bool, 128) - if lfAdjEnable == 1 then - if boolRead(bool, 128) == 1 then -- mode_ref_lf_delta_update - for _ = 1, 4 do -- ref_frame deltas - if boolRead(bool, 128) == 1 then boolReadSigned(bool, 6) end - end - for _ = 1, 4 do -- mb mode deltas - if boolRead(bool, 128) == 1 then boolReadSigned(bool, 6) end - end - end - end - - -- partition count - local log2Partitions = boolReadLit(bool, 2) - local numPartitions = lshift(1, log2Partitions) - - -- quantizer indices - local baseQ = boolReadLit(bool, 7) - local dqY1dc = boolRead(bool,128)==1 and boolReadSigned(bool,4) or 0 - local dqY2dc = boolRead(bool,128)==1 and boolReadSigned(bool,4) or 0 - local dqY2ac = boolRead(bool,128)==1 and boolReadSigned(bool,4) or 0 - local dqUVdc = boolRead(bool,128)==1 and boolReadSigned(bool,4) or 0 - local dqUVac = boolRead(bool,128)==1 and boolReadSigned(bool,4) or 0 - - -- build quantizer tables per segment - local function qi(base, delta, isAbs) - local q = isAbs and delta or base + delta - return math.max(0, math.min(127, q)) - end - - -- coefficient probability updates - -- (use default table; read updates from bitstream) - local coefProbs = {} - for t = 1, 4 do - coefProbs[t] = {} - for b = 1, 8 do - coefProbs[t][b] = {} - for c = 1, 3 do - coefProbs[t][b][c] = {} - for n = 1, 11 do - coefProbs[t][b][c][n] = VP8_COEF_PROBS[b] and - VP8_COEF_PROBS[b][c] and VP8_COEF_PROBS[b][c][n] or 128 - end - end - end - end - -- read coefficient probability updates - for t = 1, 4 do - for b = 1, 8 do - for c = 1, 3 do - for n = 1, 11 do - if boolRead(bool, 255) == 1 then - coefProbs[t][b][c][n] = boolReadLit(bool, 8) - end - end - end - end - end - - -- skip probability - local mbSkipEnabled = boolRead(bool, 128) - local skipProb = mbSkipEnabled == 1 and boolReadLit(bool, 8) or 0 - - -- intra mode probabilities for key frames are fixed (not transmitted) - -- (use VP8_KEYFRAME_YMODE_PROB / VP8_KEYFRAME_UVMODE_PROB) - - -- ── Locate second partition(s) ── - -- The first partition ends at offset+3+firstPartSize - -- Immediately after are (numPartitions-1) * 3 bytes of partition sizes, - -- then the partition data. - local firstPartEnd = offset + 3 + firstPartSize - local partSizeBase = firstPartEnd - local partDataBase = partSizeBase + (numPartitions - 1) * 3 - local partOffsets = {} - local acc = 0 - for p = 1, numPartitions - 1 do - local o = partSizeBase + (p-1)*3 - local sz = bor(data:byte(o) or 0, - bor(lshift(data:byte(o+1) or 0, 8), - lshift(data:byte(o+2) or 0, 16))) - partOffsets[p] = partDataBase + acc - acc = acc + sz - end - partOffsets[numPartitions] = partDataBase + acc - - -- create bool decoders for each residual partition - local partBools = {} - for p = 1, numPartitions do - partBools[p] = newBoolDecoder(data, partOffsets[p]) - end - - -- ── Allocate planes ── - local Yplane = {} - local Uplane = {} - local Vplane = {} - local Ystride = mbw * 16 - local Cstride = mbw * 8 - for i = 1, Ystride * mbh * 16 do Yplane[i] = 128 end - for i = 1, Cstride * mbh * 8 do Uplane[i] = 128; Vplane[i] = 128 end - - -- ── Per-macroblock decode ── - local partIdx = 1 - - for mby = 0, mbh - 1 do - for mbx = 0, mbw - 1 do - -- select residual partition (round-robin) - local pb = partBools[partIdx] - partIdx = (partIdx % numPartitions) + 1 - - -- skip flag - local mbSkip = false - if mbSkipEnabled == 1 then - mbSkip = boolRead(bool, skipProb) == 1 - end - - -- intra prediction mode for luma (16x16 or B_PRED / 4x4) - local lumaMode = 0 -- DC default - -- read y_mode - local yMode - if boolRead(bool, 145) == 0 then - -- B_PRED: 4x4 sub-modes per sub-block - yMode = 4 -- B_PRED sentinel - else - if boolRead(bool, 156) == 0 then - if boolRead(bool, 163) == 0 then - yMode = 1 -- V_PRED - else - yMode = 2 -- H_PRED - end - else - if boolRead(bool, 128) == 0 then - yMode = 0 -- DC_PRED - else - yMode = 3 -- TM_PRED - end - end - end - - -- UV mode - local uvMode - if boolRead(bool, 142) == 0 then - uvMode = 0 -- DC - elseif boolRead(bool, 114) == 0 then - uvMode = 1 -- V - elseif boolRead(bool, 183) == 0 then - uvMode = 2 -- H - else - uvMode = 3 -- TM - end - - -- read 4x4 sub-modes if B_PRED - local subModes = {} - if yMode == 4 then - local B_PROB = { - {231,120, 48, 89,115,113,120,152,112}, - {152,179, 64,126,170,118, 46, 70, 95}, - {175, 69,143, 80, 85, 82, 72,155, 64}, - {212,188,128, 97,151,195, 9, 41, 15}, - { 3, 9, 1, 7, 3, 3, 5, 1, 16}, - } - for _ = 0, 15 do - -- simplified: use DC_PRED as default for all sub-blocks - -- full implementation would use context-adaptive probs - local m = 0 - if boolRead(bool, 128) == 0 then m = 0 - elseif boolRead(bool, 156) == 0 then m = 1 - elseif boolRead(bool, 163) == 0 then m = 2 - elseif boolRead(bool, 128) == 0 then m = 3 - elseif boolRead(bool, 128) == 0 then m = 4 - elseif boolRead(bool, 128) == 0 then m = 5 - elseif boolRead(bool, 128) == 0 then m = 6 - elseif boolRead(bool, 128) == 0 then m = 7 - elseif boolRead(bool, 128) == 0 then m = 8 - else m = 9 end - subModes[#subModes + 1] = m - end - end - - -- ── Intra prediction (luma) ── - local base_x = mbx * 16 - local base_y = mby * 16 - - -- gather above/left context - local function getAbove16(plane, bx, by, stride) - if by == 0 then return nil end - local row = {} - for i = 0, 15 do - row[i+1] = plane[(by-1)*stride + bx + i + 1] or 127 - end - return row - end - local function getLeft16(plane, bx, by, stride) - if bx == 0 then return nil end - local col = {} - for i = 0, 15 do - col[i+1] = plane[by*stride + bx - 1 + (i * stride) - (stride-1)] or 129 - end - return col - end - - if yMode ~= 4 then - -- whole-MB prediction - local abv = getAbove16(Yplane, base_x, base_y, Ystride) - local lft = nil - if mbx > 0 then - lft = {} - for i = 0, 15 do - lft[i+1] = Yplane[(base_y+i)*Ystride + base_x] or 129 - end - end - predictMB16(Yplane, mbx, mby, mbw, yMode, abv, lft) - else - -- B_PRED: 4x4 sub-block prediction - for si = 0, 15 do - local sx = band(si, 3) * 4 - local sy = rshift(si, 2) * 4 - local px = base_x + sx - local py = base_y + sy - - local above4 = {} - local left4 = {} - local tl - - for i = 0, 7 do - above4[i+1] = py > 0 and (Yplane[(py-1)*Ystride + px + i + 1] or 127) or 127 - end - for i = 0, 3 do - left4[i+1] = px > 0 and (Yplane[(py+i)*Ystride + px] or 129) or 129 - end - tl = (py > 0 and px > 0) and (Yplane[(py-1)*Ystride + px] or 127) or 127 - - predictBlock4x4(Yplane, px, py, Ystride, subModes[si+1] or 0, - above4, left4, tl) - end - end - - -- UV prediction - do - local ubx = mbx * 8 - local uby = mby * 8 - local function above8(plane) - if mby == 0 then return nil end - local r = {} - for i=0,7 do r[i+1]=plane[(uby-1)*Cstride+ubx+i+1] or 127 end - return r - end - local function left8(plane) - if mbx == 0 then return nil end - local c = {} - for i=0,7 do c[i+1]=plane[(uby+i)*Cstride+ubx] or 129 end - return c - end - predictMB8(Uplane, mbx, mby, mbw, uvMode, above8(Uplane), left8(Uplane)) - predictMB8(Vplane, mbx, mby, mbw, uvMode, above8(Vplane), left8(Vplane)) - end - - -- ── Residuals ── - if not mbSkip then - local q = math.max(0, math.min(127, baseQ)) - local yDCq = VP8_DC_QLOOKUP[math.max(0,math.min(127, q + dqY1dc))] - local yACq = VP8_AC_QLOOKUP[q] - local y2DCq= VP8_DC_QLOOKUP[math.max(0,math.min(127, q + dqY2dc))] - local y2ACq= VP8_AC_QLOOKUP[math.max(0,math.min(127, q + dqY2ac))] - local uvDCq= VP8_DC_QLOOKUP[math.max(0,math.min(127, q + dqUVdc))] - local uvACq= VP8_AC_QLOOKUP[math.max(0,math.min(127, q + dqUVac))] - - -- Y2 (DC only for 16x16 modes) - local y2coeffs = nil - if yMode ~= 4 then - local c16 = {} - for i=1,16 do c16[i] = 0 end - -- decode 4x4 WHT block for Y2 - local rawC, nz = decodeCoefficients(pb, coefProbs[1], 0, 15, 0) - if nz > 0 then - for k=0,15 do rawC[k] = (rawC[k] or 0) * (k==0 and y2DCq or y2ACq) end - local wht = {} - for k=1,16 do wht[k] = rawC[k-1] or 0 end - iwht4x4(wht) - y2coeffs = wht - end - end - - -- 16 Y sub-blocks - for si = 0, 15 do - local sx = band(si, 3) * 4 - local sy = rshift(si, 2) * 4 - local px = base_x + sx - local py = base_y + sy - local firstCoef = yMode ~= 4 and 1 or 0 - local rawC, nz = decodeCoefficients(pb, coefProbs[yMode~=4 and 2 or 1], - firstCoef, 15, 0) - -- inject Y2 DC if present - if y2coeffs then - rawC[0] = y2coeffs[si + 1] or 0 - end - -- dequantize - for k = 0, 15 do - rawC[k] = (rawC[k] or 0) * (k == 0 and yDCq or yACq) - end - -- inverse DCT - local block = {} - for k = 1, 16 do block[k] = rawC[k-1] or 0 end - idct4x4(block) - addResiduals(Yplane, px, py, Ystride, block) - end - - -- 4 U + 4 V sub-blocks - for _, plane in ipairs({Uplane, Vplane}) do - for si = 0, 3 do - local sx = band(si, 1) * 4 - local sy = rshift(si, 1) * 4 - local px = mbx * 8 + sx - local py = mby * 8 + sy - local rawC, nz = decodeCoefficients(pb, coefProbs[3], 0, 15, 0) - for k = 0, 15 do - rawC[k] = (rawC[k] or 0) * (k == 0 and uvDCq or uvACq) - end - local block = {} - for k = 1, 16 do block[k] = rawC[k-1] or 0 end - idct4x4(block) - addResiduals(plane, px, py, Cstride, block) - end - end - end - - end -- mbx - end -- mby - - -- optional simple loop filter - if filterType == 1 and filterLevel > 0 then - filterSimple(Yplane, Ystride, mbw*16, mbh*16) - filterSimple(Uplane, Cstride, mbw*8, mbh*8) - filterSimple(Vplane, Cstride, mbw*8, mbh*8) - end - - -- ── Assemble RGBA image ── - local imageData = love.image.newImageData(width, height, "rgba8") - imageData:mapPixel(function(px, py) - local yi = py * Ystride + px + 1 - local cy = rshift(py, 1) - local cx = rshift(px, 1) - local ci = cy * Cstride + cx + 1 - local Y = Yplane[yi] or 128 - local Cb = Uplane[ci] or 128 - local Cr = Vplane[ci] or 128 - local r, g, b = yuvToRgb(Y, Cb, Cr) - return r/255, g/255, b/255, 1 - end) - - return love.graphics.newImage(imageData) -end - --- ═══════════════════════════════════════════════════════════════════════════ --- VP8L → Love2D image (unchanged, just wrapped for the dispatcher) --- ═══════════════════════════════════════════════════════════════════════════ - -local function decodeVP8L_image(data, offset) - offset = offset or 1 - assert(data:byte(offset) == 0x2f, "Invalid VP8L signature byte") - local br = newBitReader(data:sub(offset + 1)) - local width = br:read(14) + 1 - local height = br:read(14) + 1 - br:readBool() -- alpha hint - local version = br:read(3) - assert(version == 0, "Unsupported VP8L version: " .. version) - - local pixels = decodeVP8L(br, width, height) - - local imageData = love.image.newImageData(width, height, "rgba8") - imageData:mapPixel(function(x, y) - local c = pixels[y * width + x + 1] or 0 - local a = band(rshift(c, 24), 0xff) - local r = band(rshift(c, 16), 0xff) - local g = band(rshift(c, 8), 0xff) - local b = band(c, 0xff) - return r/255, g/255, b/255, a/255 - end) - - return love.graphics.newImage(imageData) -end - --- ═══════════════════════════════════════════════════════════════════════════ --- PUBLIC API --- ═══════════════════════════════════════════════════════════════════════════ - --- Read a little-endian uint32 from data at pos (1-based) -local function readU32LE(data, pos) - return bor(data:byte(pos), - bor(lshift(data:byte(pos+1), 8), - bor(lshift(data:byte(pos+2), 16), - lshift(data:byte(pos+3), 24)))) -end - -function WebP.decode(data) - assert(data:sub(1, 4) == "RIFF", "Not a RIFF file") - assert(data:sub(9, 12) == "WEBP", "Not a WEBP file") - - local fourCC = data:sub(13, 16) - - if fourCC == "VP8 " then - -- Simple lossy: VP8 bitstream starts after the 8-byte chunk header - -- bytes 13-16: "VP8 ", 17-20: chunk size (LE), 21+: VP8 bitstream - return decodeVP8(data, 21) - - elseif fourCC == "VP8L" then - -- Simple lossless: VP8L bitstream starts at byte 21 - return decodeVP8L_image(data, 21) - - elseif fourCC == "VP8X" then - -- Extended format: parse chunks after the VP8X header - -- VP8X chunk: 4cc(4) + size(4) + flags(4) + canvas_w-1(3) + canvas_h-1(3) = 18 bytes - local pos = 13 -- start of VP8X chunk - local chunkSize = readU32LE(data, pos + 4) - pos = pos + 8 + chunkSize -- skip past VP8X chunk data - - -- walk remaining chunks - while pos + 8 <= #data do - local cc = data:sub(pos, pos + 3) - local sz = readU32LE(data, pos + 4) - local dOff = pos + 8 -- chunk data offset (1-based) - - if cc == "VP8 " then - return decodeVP8(data, dOff) - elseif cc == "VP8L" then - return decodeVP8L_image(data, dOff) - elseif cc == "ANIM" or cc == "ANMF" then - error("Animated WebP is not supported") - end - - pos = pos + 8 + sz - if band(sz, 1) == 1 then pos = pos + 1 end -- padding byte - end - error("VP8X: no VP8 or VP8L chunk found") - else - error("Unsupported WebP chunk type: " .. fourCC) - end -end - -function WebP.load(path) - local data = love.filesystem.read(path) - assert(data, "Could not read file: " .. path) - return WebP.decode(data) -end - -return WebP