951 lines
35 KiB
Lua
Executable File
951 lines
35 KiB
Lua
Executable File
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()
|