Initial commit

This commit is contained in:
2026-05-06 19:39:08 -07:00
commit ea48705ee4
56 changed files with 13592 additions and 0 deletions
+458
View File
@@ -0,0 +1,458 @@
-- 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
-- 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
]]
View File
+43
View File
@@ -0,0 +1,43 @@
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
+115
View File
@@ -0,0 +1,115 @@
--[[
scheduler_probe.lua
-------------------
A drop-in replacement for multi:getLoad() based on scheduler tick-slip
rather than step-count benchmarking.
THEORY
------
Schedule a repeating timer at a fixed interval T. Each time it fires,
measure how much later than T it actually arrived. On an idle scheduler
the slip is near zero. Under load the main loop is busy with other tasks
between iterations, so ticks are delayed.
We express load as:
lag = actual_interval - target_interval (seconds)
lag_ratio = lag / target_interval (0 = perfect, 1 = 1 full interval late)
load% = clamp(lag_ratio * 100, 0, 100)
To smooth out single-frame spikes we keep an exponential moving average
(EMA) of lag_ratio with a configurable smoothing factor.
WHY THIS IS BETTER THAN THE STEP-COUNT APPROACH
------------------------------------------------
- No calibration baseline that drifts with object count or warm-up
- No magic exponents or divisors
- Does not block or create temporary objects on each call
- Measures actual scheduler responsiveness, not raw throughput
- Works correctly regardless of which processor calls it
- A single lightweight TLoop is the only permanent overhead
USAGE
-----
local probe = require("scheduler_probe")
probe:install(multi) -- once, at startup
-- anywhere, non-blocking:
local load, lagMs = multi:getLoad()
-- load : integer 0-100
-- lagMs : smoothed lag in milliseconds (useful for display)
OPTIONAL PARAMETERS
-------------------
probe:install(multi, {
interval = 0.05, -- probe fires every N seconds (default 0.05 = 50ms)
alpha = 0.15, -- EMA smoothing factor 0-1 (default 0.15)
-- lower = smoother but slower to react
-- higher = more reactive but noisier
maxLag = 0.5, -- lag value (seconds) that maps to 100% load (default 0.5)
-- tune this to match your target frame budget
})
]]
local probe = {}
-- EMA state — written by the TLoop callback, read by getLoad()
-- Both are plain numbers so Lua's assignment is atomic within one thread.
local _emaRatio = 0 -- smoothed lag / maxLag, clamped 0-1
local _lagMs = 0 -- smoothed lag in milliseconds for display
local _installed = false
function probe:install(multi_obj, opts)
if _installed then return end
_installed = true
opts = opts or {}
local INTERVAL = opts.interval or 0.05 -- seconds between probes
local ALPHA = opts.alpha or 0.15 -- EMA weight for new sample
local MAX_LAG = opts.maxLag or 0.5 -- seconds of lag = 100% load
local clock = os.clock
-- Track when the tick *should* have fired so we can compute slip
-- relative to the scheduled time, not relative to the previous firing.
-- This avoids error accumulation over long runs.
local expectedTime = clock() + INTERVAL
local tloop = multi_obj:newTLoop(nil, INTERVAL)
tloop:setName("SchedulerProbe")
tloop:setPriority("core") -- run as early as possible each frame
tloop.OnLoop(function(self, life, dt)
local now = clock()
local lag = math.max(0, now - expectedTime) -- never negative
local ratio = math.min(lag / MAX_LAG, 1) -- clamp to [0,1]
-- Exponential moving average: new = alpha*sample + (1-alpha)*old
_emaRatio = ALPHA * ratio + (1 - ALPHA) * _emaRatio
_lagMs = ALPHA * lag*1000 + (1 - ALPHA) * _lagMs
-- Advance expected time by one interval from where it *should* have been,
-- not from now — prevents the probe from drifting under sustained load.
expectedTime = expectedTime + INTERVAL
-- If we fall more than one interval behind (e.g. after a long GC pause),
-- re-anchor so we don't fire in a catch-up burst.
if now > expectedTime + INTERVAL then
expectedTime = now + INTERVAL
end
end)
-- Replace multi:getLoad() with a non-blocking version that just reads the EMA
function multi_obj:getLoad()
local pct = math.ceil(_emaRatio * 100)
return pct, _lagMs
end
-- Also expose raw probe state for diagnostics
function multi_obj:getSchedulerLag()
return _lagMs, _emaRatio
end
return tloop
end
return probe
+950
View File
@@ -0,0 +1,950 @@
local multi, thread = require("multi"):init()
local gui = require("gui")
local theme = require("gui.core.theme")
local color = require("gui.core.color")
local TM_THEME = theme:new({
primary = "#124559",
primaryDark = "#01161E",
primaryText = "#AEC3B0"
})
local default_theme = TM_THEME
-- ── layout constants ──────────────────────────────────────────────────────────
-- Columns: Indent+Name, State, Status, Uptime, Priority, Pause, Kill
-- Name shrunk so data columns have room to breathe
local COL_WIDTHS = { 300, 130, 120, 100, 130, 100, 100, 70 }
local COL_KEYS = { "name", "kind", "state", "status", "uptime", "priority", "pause", "kill" }
local COL_LABELS = { "Name", "Type", "State", "Status", "Uptime", "Priority", "", "" }
local SORT_COLS = { "name", "kind", "state", "status", "uptime", "priority" }
local ROW_H = 28
local COL_X = {}
do
local acc = 0
for i, w in ipairs(COL_WIDTHS) do
COL_X[i] = acc
acc = acc + w
end
end
local TOTAL_W = COL_X[#COL_X] + COL_WIDTHS[#COL_WIDTHS] -- 700
-- ── thread state names ────────────────────────────────────────────────────────
local STATE_NAMES = {
[1] = "holding",
[2] = "sleeping",
[3] = "hold+time",
[4] = "skipping",
[5] = "hold+cyc",
[6] = "yielding",
[7] = "running",
}
local function fmtState(obj)
if obj._isPaused then return "paused" end
local t = obj.task
if t == nil then return "running" end
return STATE_NAMES[t] or ("state:"..tostring(t))
end
local PRIORITY_NAMES = {
[1] = "Core",
[4] = "V.High",
[16] = "High",
[64] = "Above",
[256] = "Normal",
[1024] = "Below",
[4096] = "Low",
[16384] = "V.Low",
[65536] = "Idle",
}
local PRIORITY_CYCLE = { 1, 4, 16, 64, 256, 1024, 4096, 16384, 65536 }
local function fmtPriority(obj)
local p = rawget(obj, "Priority") or rawget(obj, "priority")
if not p then return "n/a" end
return PRIORITY_NAMES[p] or tostring(p)
end
local function nextPriority(current)
for i, v in ipairs(PRIORITY_CYCLE) do
if v == current then
return PRIORITY_CYCLE[(i % #PRIORITY_CYCLE) + 1]
end
end
return 256
end
-- ── helpers ───────────────────────────────────────────────────────────────────
local function fmtUptime(secs)
secs = math.floor(secs)
if secs < 60 then return secs .. "s" end
if secs < 3600 then return math.floor(secs/60).."m "..(secs%60).."s" end
return math.floor(secs/3600).."h "..math.floor((secs%3600)/60).."m"
end
local function nowClock() return os.clock() end
-- ── data collection ───────────────────────────────────────────────────────────
-- Returns a flat list of rows with depth so the UI can indent names.
local function collectTasks()
local rows = {}
local stats = multi:getStats()
local function addProc(fullname, proc, depth)
-- Processor header row
rows[#rows+1] = {
isProc = true,
depth = depth,
name = proc.name or fullname,
fullname = fullname,
kind = "processor",
conns = proc.connections or 0,
subs = proc.subscriptions or 0,
}
-- Tasks (Mainloop actors)
local tasks = proc.tasks or {}
for _, task in pairs(tasks) do
if not task.isProcessThread then
rows[#rows+1] = {
isProc = false,
depth = depth + 1,
name = task:getName() or "?",
fullname = fullname,
kind = tostring(task.Type),
state = fmtState(task),
active = not task:isPaused(),
uptime = nowClock() - (task.UPTIME or nowClock()),
priority = rawget(task, "Priority") or 256,
fmtPri = fmtPriority(task),
obj = task,
isThread = false,
}
end
end
-- Threads
local threads = proc.threads or {}
for _, th in pairs(threads) do
rows[#rows+1] = {
isProc = false,
depth = depth + 1,
name = th:getName() or "?",
fullname = fullname,
kind = tostring(th.Type),
state = fmtState(th),
active = not th:isPaused(),
uptime = nowClock() - (th.UPTIME or nowClock()),
priority = rawget(th, "Priority") or 256,
fmtPri = fmtPriority(th),
obj = th,
isThread = true,
}
end
end
-- Root first, then sub-processors sorted
if stats["root"] then
addProc("root", stats["root"], 0)
end
local procNames = {}
for k in pairs(stats) do
if k ~= "root" then procNames[#procNames+1] = k end
end
table.sort(procNames)
for _, k in ipairs(procNames) do
addProc(k, stats[k], 1)
end
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
viewport.drawBorder = false
local content = viewport:newFrame(0, 0, w, 0)
content.drawBorder = false
local scrollY = 0
local maxScrollY = 0
local scrollX = 0
local maxScrollX = 0
local SCROLL_SPEED = 40
local SCROLL_BAR_W = 8
local vBar = viewport:newFrame(-SCROLL_BAR_W, 0, SCROLL_BAR_W, 0, 1, 0, 0, 1)
vBar.color = {0.3, 0.3, 0.3}
vBar.drawBorder = false
vBar.visible = false
local vThumb = vBar:newFrame(0, 0, SCROLL_BAR_W, 40)
vThumb.color = {0.6, 0.6, 0.6}
vThumb.drawBorder = false
local hBar = viewport:newFrame(0, -SCROLL_BAR_W, 0, SCROLL_BAR_W, 0, 1, 1)
hBar.color = {0.3, 0.3, 0.3}
hBar.drawBorder = false
hBar.visible = false
local hThumb = hBar:newFrame(0, 0, 40, SCROLL_BAR_W)
hThumb.color = {0.6, 0.6, 0.6}
hThumb.drawBorder = false
local applying = false
local function getViewSize()
local _, _, vw, vh = viewport:getAbsolutes()
return vw, vh
end
local function clamp(val, lo, hi)
return math.max(lo, math.min(hi, val))
end
local function updateScrollbars()
if applying then return end
local vw, vh = getViewSize()
local _, _, cw, ch = content:getAbsolutes()
maxScrollY = math.max(0, ch - vh)
if maxScrollY > 0 then
vBar.visible = true
local thumbH = math.max(20, vh * (vh / ch))
local thumbY = (scrollY / maxScrollY) * (vh - thumbH)
vThumb:setDualDim(0, thumbY, SCROLL_BAR_W, thumbH)
else
vBar.visible = false
scrollY = 0
end
maxScrollX = math.max(0, cw - vw)
if maxScrollX > 0 then
hBar.visible = true
local thumbW = math.max(20, vw * (vw / cw))
local thumbX = (scrollX / maxScrollX) * (vw - thumbW)
hThumb:setDualDim(thumbX, 0, thumbW, SCROLL_BAR_W)
else
hBar.visible = false
scrollX = 0
end
end
local function applyScroll()
if applying then return end
applying = true
scrollY = clamp(scrollY, 0, maxScrollY)
scrollX = clamp(scrollX, 0, maxScrollX)
content:setDualDim(-scrollX, -scrollY)
updateScrollbars()
applying = false
end
viewport.OnWheelMoved(function(x, y)
scrollY = scrollY - y * SCROLL_SPEED
applyScroll()
end)
vThumb:enableDragging(gui.MOUSE_PRIMARY)
vThumb.OnDragging(function(self, dx, dy)
local _, vh = getViewSize()
local _, _, _, thumbH = vThumb:getAbsolutes()
local trackH = vh - thumbH
if trackH <= 0 then return end
scrollY = scrollY + dy * (maxScrollY / trackH)
applyScroll()
end)
hThumb:enableDragging(gui.MOUSE_PRIMARY)
hThumb.OnDragging(function(self, dx, dy)
local vw, _ = getViewSize()
local _, _, thumbW = hThumb:getAbsolutes()
local trackW = vw - thumbW
if trackW <= 0 then return end
scrollX = scrollX + dx * (maxScrollX / trackW)
applyScroll()
end)
content.OnSizeChanged(function()
if applying then return end
local _, _, cw, ch = content:getAbsolutes()
local vw, vh = getViewSize()
maxScrollY = math.max(0, ch - vh)
maxScrollX = math.max(0, cw - vw)
scrollY = clamp(scrollY, 0, maxScrollY)
scrollX = clamp(scrollX, 0, maxScrollX)
updateScrollbars()
end)
viewport.OnSizeChanged(function()
if applying then return end
applyScroll()
end)
function content:scrollTo(sy, sx)
scrollY = sy or scrollY
scrollX = sx or scrollX
applyScroll()
end
function content:scrollBy(dy, dx)
scrollY = scrollY + (dy or 0)
scrollX = scrollX + (dx or 0)
applyScroll()
end
function content:scrollToBottom() scrollY = maxScrollY; applyScroll() end
function content:scrollToTop() scrollY = 0; applyScroll() end
function content:setScrollSpeed(speed) SCROLL_SPEED = speed end
function content:getScrollPos() return scrollX, scrollY end
function content:getMaxScroll() return maxScrollX, maxScrollY end
local _baseSDD = content.setDualDim
function content:setContentSize(cw, ch)
_baseSDD(self, nil, nil, cw or select(3, self:getAbsolutes()), ch)
applyScroll()
end
local _baseDestroy = viewport.destroy
function viewport:destroy()
content:destroy()
_baseDestroy(self)
end
applyScroll()
return content
end
-- ── row pool ──────────────────────────────────────────────────────────────────
local COLOR_PROC_ROW = TM_THEME.colorPrimaryDark
local COLOR_ROW_EVEN = TM_THEME.colorPrimary
local COLOR_ROW_ODD = TM_THEME.colorPrimaryDark
local COLOR_DEAD = { 0.5, 0.1, 0.1 }
local function makeRowPool(scrollFrame)
local pool = { rows = {}, active = 0 }
local function makeRow(idx)
local yOff = (idx - 1) * ROW_H
local bg = scrollFrame:newFrame(0, yOff, TOTAL_W, ROW_H)
bg.drawBorder = false
-- Name label (col 1) with indent support
local nameLabel = bg:newTextLabel("", COL_X[1] + 4, 0, COL_WIDTHS[1] - 4, ROW_H)
nameLabel.align = gui.ALIGN_LEFT
nameLabel.ignore = true
-- Type, State, Status, Uptime, Priority labels
local kindLbl = bg:newTextLabel("", COL_X[2], 0, COL_WIDTHS[2], ROW_H)
local stateLbl = bg:newTextLabel("", COL_X[3], 0, COL_WIDTHS[3], ROW_H)
local statusLbl = bg:newTextLabel("", COL_X[4], 0, COL_WIDTHS[4], ROW_H)
local uptimeLbl = bg:newTextLabel("", COL_X[5], 0, COL_WIDTHS[5], ROW_H)
local priorityLbl = bg:newTextLabel("", COL_X[6], 0, COL_WIDTHS[6], ROW_H)
for _, lbl in ipairs({kindLbl, stateLbl, statusLbl, uptimeLbl, priorityLbl}) do
lbl.align = gui.ALIGN_CENTER
lbl.ignore = true
end
-- Pause / Resume button
local pauseBtn = bg:newTextButton("", COL_X[7] + 2, 2, COL_WIDTHS[7] - 4, ROW_H - 4)
pauseBtn.align = gui.ALIGN_CENTER
-- Kill button (red)
local killBtn = bg:newTextButton("Kill", COL_X[8] + 2, 2, COL_WIDTHS[8] - 4, ROW_H - 4)
killBtn.align = gui.ALIGN_CENTER
killBtn.color = color.darken(color.red, .1)
local row = {
bg = bg,
nameLabel = nameLabel,
kindLbl = kindLbl,
stateLbl = stateLbl,
statusLbl = statusLbl,
uptimeLbl = uptimeLbl,
priorityLbl = priorityLbl,
pauseBtn = pauseBtn,
killBtn = killBtn,
obj = nil,
isProc = false,
}
pauseBtn.OnReleased(function()
if not row.obj then return end
if row.obj:isPaused() then
row.obj:Resume()
else
row.obj:Pause()
end
statusLbl.text = row.obj:isPaused() and "Paused" or "Running"
pauseBtn.text = row.obj:isPaused() and "Resume" or "Pause"
end)
killBtn.OnReleased(function()
if not row.obj or row.isProc then return end
if row.obj.Kill then
row.obj:Kill()
elseif row.obj.Destroy then
row.obj:Destroy()
end
bg.color = COLOR_DEAD
end)
-- Priority label is clickable to cycle priority
priorityLbl.ignore = false
priorityLbl.OnReleased(function()
if not row.obj or row.isProc then return end
local cur = rawget(row.obj, "Priority") or 256
local next = nextPriority(cur)
if row.obj.setPriority then
row.obj:setPriority(next)
priorityLbl.text = PRIORITY_NAMES[next] or tostring(next)
end
end)
return row
end
function pool:ensure(n)
while #self.rows < n do
self.rows[#self.rows + 1] = makeRow(#self.rows + 1)
end
end
function pool:apply(data)
self:ensure(#data)
self.active = #data
for i, d in ipairs(data) do
local row = self.rows[i]
row.isProc = d.isProc
row.obj = d.isProc and nil or d.obj
-- Row y position
row.bg:setDualDim(nil, (i - 1) * ROW_H)
row.bg.visible = true
if d.isProc then
-- Processor header row
row.bg.color = COLOR_PROC_ROW
row.nameLabel.text = string.rep(" ", d.depth) .. "[" .. d.name .. "]"
row.kindLbl.text = "processor"
row.stateLbl.text = ""
row.statusLbl.text = ""
row.uptimeLbl.text = d.conns .. "c/" .. d.subs .. "s"
row.priorityLbl.text = ""
row.pauseBtn.text = ""
row.pauseBtn.visible = false
row.killBtn.visible = false
else
row.bg.color = (i % 2 == 0) and COLOR_ROW_EVEN or COLOR_ROW_ODD
row.nameLabel.text = string.rep(" ", d.depth) .. d.name
row.kindLbl.text = d.kind or ""
row.stateLbl.text = d.state or ""
row.statusLbl.text = d.active and "Running" or "Paused"
row.uptimeLbl.text = fmtUptime(d.uptime)
row.priorityLbl.text = d.fmtPri or ""
row.pauseBtn.text = d.active and "Pause" or "Resume"
row.pauseBtn.visible = true
row.killBtn.visible = true
end
end
-- Hide unused rows
for i = #data + 1, #self.rows do
self.rows[i].bg.visible = false
self.rows[i].obj = nil
end
scrollFrame:setDualDim(nil, nil, nil, math.max(#data * ROW_H, 1))
end
return pool
end
-- ── error log pool ────────────────────────────────────────────────────────────
local ERROR_ROW_H = 22
local MAX_ERRORS = 200
local function makeErrorPool(scrollFrame)
local pool = { rows = {}, entries = {} }
local function makeRow(idx)
local yOff = (idx - 1) * ERROR_ROW_H
local bg = scrollFrame:newFrame(0, yOff, TOTAL_W, ERROR_ROW_H)
bg.color = (idx % 2 == 0) and COLOR_ROW_EVEN or COLOR_ROW_ODD
bg.drawBorder = false
local lbl = bg:newTextLabel("", 4, 0, TOTAL_W - 4, ERROR_ROW_H)
lbl.align = gui.ALIGN_LEFT
lbl.ignore = true
return { bg = bg, lbl = lbl }
end
function pool:ensure(n)
while #self.rows < n do
self.rows[#self.rows + 1] = makeRow(#self.rows + 1)
end
end
function pool:addEntry(msg, source)
if #self.entries >= MAX_ERRORS then
table.remove(self.entries, 1)
end
local ts = string.format("[%.1fs]", os.clock())
self.entries[#self.entries + 1] = ts .. " [" .. (source or "?") .. "] " .. tostring(msg)
self:refresh()
end
function pool:refresh()
local n = #self.entries
self:ensure(n)
for i, entry in ipairs(self.entries) do
local row = self.rows[i]
row.bg:setDualDim(nil, (i - 1) * ERROR_ROW_H)
row.bg.visible = true
row.lbl.text = entry
end
for i = n + 1, #self.rows do
self.rows[i].bg.visible = false
end
scrollFrame:setDualDim(nil, nil, nil, math.max(n * ERROR_ROW_H, 1))
scrollFrame:scrollToBottom()
end
function pool:clear()
self.entries = {}
self:refresh()
end
return pool
end
-- ── column header row ─────────────────────────────────────────────────────────
local function makeHeader(parent, onSort)
local hdr = parent:newFrame(0, 0, TOTAL_W, ROW_H)
hdr.color = TM_THEME.colorPrimaryDark
hdr.drawBorder = false
local sortCol = nil
local sortAsc = true
local indicators = {}
for i, t in ipairs(COL_LABELS) do
local isSortable = false
for _, k in ipairs(SORT_COLS) do
if k == COL_KEYS[i] then isSortable = true; break end
end
if isSortable then
local btn = hdr:newTextButton(t, COL_X[i], 0, COL_WIDTHS[i], ROW_H)
btn.align = (i == 1) and gui.ALIGN_LEFT or gui.ALIGN_CENTER
indicators[COL_KEYS[i]] = btn
local key = COL_KEYS[i]
btn.OnReleased(function()
if sortCol == key then
sortAsc = not sortAsc
else
sortCol = key
sortAsc = true
end
-- Reset all sortable headers, then mark the active one
for j, label in ipairs(COL_LABELS) do
local b = indicators[COL_KEYS[j]]
if b then
if COL_KEYS[j] == sortCol then
b.text = label .. (sortAsc and "" or "")
else
b.text = label
end
end
end
if onSort then onSort(key, sortAsc) end
end)
else
local lbl = hdr:newTextLabel(t, COL_X[i], 0, COL_WIDTHS[i], ROW_H)
lbl.align = gui.ALIGN_CENTER
lbl.ignore = true
lbl.textColor = TM_THEME.colorPrimaryText
end
end
return hdr
end
-- ── tab bar ───────────────────────────────────────────────────────────────────
local TAB_H = 28
local function makeTabBar(parent, tabs, onSwitch)
local bar = parent:newFrame(0, 0, 0, TAB_H, 0, 0, 1)
bar.color = TM_THEME.colorPrimaryDark
bar.drawBorder = false
local tabW = math.floor(TOTAL_W / #tabs)
local btns = {}
for i, label in ipairs(tabs) do
local btn = bar:newTextButton(label, (i-1)*tabW, 0, tabW, TAB_H)
btn.align = gui.ALIGN_CENTER
btns[i] = btn
btn.OnReleased(function()
onSwitch(i)
end)
end
return bar, btns
end
-- ── sort helper ───────────────────────────────────────────────────────────────
local function sortRows(rows, key, asc)
local function cmp(a, b)
-- Processor rows always float to top within their group; we keep them stable
if a.isProc and b.isProc then return a.fullname < b.fullname end
if a.isProc then return true end
if b.isProc then return false end
local va, vb
if key == "name" then va, vb = a.name or "", b.name or ""
elseif key == "kind" then va, vb = a.kind or "", b.kind or ""
elseif key == "state" then va, vb = a.state or "", b.state or ""
elseif key == "status" then va, vb = (a.active and 0 or 1), (b.active and 0 or 1)
elseif key == "uptime" then va, vb = a.uptime or 0, b.uptime or 0
elseif key == "priority" then va, vb = a.priority or 256, b.priority or 256
else va, vb = tostring(a[key] or ""), tostring(b[key] or "")
end
if asc then return va < vb else return va > vb end
end
-- Stable-ish sort: keep proc header immediately before its children
-- For simplicity we sort the flat list but keep proc rows pinned before
-- the first non-proc row that shares the same fullname.
table.sort(rows, cmp)
end
-- ── public API ────────────────────────────────────────────────────────────────
local taskManager
function gui:showTaskManager()
if taskManager then return end
local WIN_W = TOTAL_W + 20
local WIN_H = 620
taskManager = gui:newWindow(0, 0, WIN_W, WIN_H, "Task Manager", true, TM_THEME)
taskManager.clipDescendants = true
-- ── tab bar ──────────────────────────────────────────────────────────────
local currentTab = 1 -- 1 = tasks, 2 = errors
local taskPanel, errorPanel
local tabBar, tabBtns = makeTabBar(taskManager, {"Tasks", "Errors"}, function(idx)
currentTab = idx
taskPanel.visible = (idx == 1)
errorPanel.visible = (idx == 2)
end)
-- ── tasks panel ──────────────────────────────────────────────────────────
taskPanel = taskManager:newFrame(0, TAB_H, 0, -TAB_H, 0, 0, 1, 1)
taskPanel.drawBorder = false
taskPanel.clipDescendants = true
-- Load bar strip (sits below tab bar, above column headers)
local LOAD_H = 20
local loadStrip = taskPanel:newFrame(0, 0, 0, LOAD_H, 0, 0, 1)
loadStrip.color = TM_THEME.colorPrimaryDark
loadStrip.drawBorder = false
local loadFill = loadStrip:newFrame(0, 2, 1, LOAD_H - 4) -- absolute w=1, no relative anchors
loadFill.color = { 0.1, 0.6, 0.3 }
loadFill.drawBorder = false
-- Label is created AFTER fill so it draws on top of it
local loadLbl = loadStrip:newTextLabel("Load: …", 4, 0, TOTAL_W - 8, LOAD_H)
loadLbl.align = gui.ALIGN_LEFT
loadLbl.ignore = true
local sortKey = nil
local sortAsc = true
local colHdr = makeHeader(taskPanel, function(key, asc)
sortKey = key
sortAsc = asc
end)
colHdr:setDualDim(nil, LOAD_H)
local scrollFrame = taskPanel:newScrollFrame(0, LOAD_H + ROW_H, 0, -(LOAD_H + ROW_H), 0, 0, 1, 1)
local pool = makeRowPool(scrollFrame)
-- ── error panel ──────────────────────────────────────────────────────────
errorPanel = taskManager:newFrame(0, TAB_H, 0, -TAB_H, 0, 0, 1, 1)
errorPanel.drawBorder = false
errorPanel.clipDescendants = true
errorPanel.visible = false
local errHdr = errorPanel:newFrame(0, 0, 0, ROW_H, 0, 0, 1)
errHdr.color = TM_THEME.colorPrimaryDark
errHdr.drawBorder = false
local errTitle = errHdr:newTextLabel("Error Log", 4, 0, 200, ROW_H)
errTitle.align = gui.ALIGN_LEFT
errTitle.ignore = true
local clearBtn = errHdr:newTextButton("Clear", -70, 2, 66, ROW_H - 4, 1)
clearBtn.align = gui.ALIGN_CENTER
local errScroll = errorPanel:newScrollFrame(0, ROW_H, 0, -ROW_H, 0, 0, 1, 1)
local errorPool = makeErrorPool(errScroll)
clearBtn.OnReleased(function() errorPool:clear() end)
-- ── wire up error capture ─────────────────────────────────────────────────
-- Thread errors fire on the *thread's own* OnError, not the processor's.
-- We use OnObjectCreated to hook every thread as it is born, on every
-- processor (including ones created after the task manager opens).
-- We also walk existing threads retroactively for processors already running.
local hookedThreads = {} -- weak set so we don't prevent GC
local function hookThread(th, procName)
if not th.OnError then return end
if hookedThreads[th] then return end
hookedThreads[th] = true
th.OnError(function(self, err,t)
local msg = type(err) == "string" and err or tostring(err or "unknown error")
local name = (th.getName and th:getName()) or "?"
errorPool:addEntry(msg, procName .. "/" .. name)
end)
end
local function hookProc(proc, procName)
-- Hook threads already alive on this processor
local threads = proc.threads or {}
for _, th in ipairs(threads) do
hookThread(th, procName)
end
-- Hook threads created in future on this processor
proc.OnObjectCreated(function(obj)
if obj.Type == multi.registerType("thread", "threads") then
hookThread(obj, procName)
end
end)
end
-- Root process
hookProc(multi, "root")
-- All processors currently registered
for _, proc in ipairs(multi:getProcessors()) do
hookProc(proc, proc:getName())
end
-- Any processors created after this point
multi.OnObjectCreated(function(obj)
if obj.Type == multi.registerType("process", "processes") then
hookProc(obj, obj:getName())
end
end)
-- ── stat line ─────────────────────────────────────────────────────────────
local function setStatLine(n)
taskManager:setTitle("Task Manager — " .. n .. " objects")
end
-- ── background thread: collect tasks ──────────────────────────────────────
local function isOpen()
return not taskManager:isDescendantOf(gui.virtual)
end
local pendingData = nil
local dirty = false
taskManager.process:newThread("TM_collect", function()
while true do
thread.hold(isOpen)
local data = collectTasks()
pendingData = data
dirty = true
thread.sleep(1)
end
end)
-- ── load probe ────────────────────────────────────────────────────────────
-- Install once. getLoad() is now non-blocking — just reads the EMA state.
local schedulerProbe = require("gui.addons.probe")
schedulerProbe:install(multi)
-- ── main-thread update ────────────────────────────────────────────────────
taskManager.OnUpdate(function()
taskManager:topStack()
-- Apply task data
if dirty and pendingData then
dirty = false
local data = pendingData
pendingData = nil
if sortKey then
sortRows(data, sortKey, sortAsc)
end
pool:apply(data)
setStatLine(#data)
end
-- Load bar — getLoad() is now non-blocking, safe to call every frame
local pct, lagMs = multi:getLoad()
local _, _, barW, _ = loadStrip:getAbsolutes()
local fillW = math.max(1, math.floor(barW * pct / 100))
loadFill:setDualDim(nil, nil, fillW)
if pct < 50 then
loadFill.color = { 0.1, 0.6, 0.3 }
elseif pct < 80 then
loadFill.color = { 0.8, 0.6, 0.1 }
else
loadFill.color = { 0.8, 0.15, 0.1 }
end
loadLbl.text = string.format("Load: %d%% Lag: %.1fms", pct, lagMs)
end)
end
-- ── hotkey ────────────────────────────────────────────────────────────────────
ToggleTaskManager = gui:setHotKey({"lctrl","t"}) +
gui:setHotKey({"rctrl","t"})
ToggleTaskManager(function()
if not taskManager then
gui:showTaskManager()
elseif taskManager:isActive() then
taskManager:close()
else
taskManager:open()
end
end)
ToggleTaskManager:Fire()
taskManager:close()