Skip to content

Instantly share code, notes, and snippets.

@nomer888
Created December 22, 2024 23:44
Show Gist options
  • Select an option

  • Save nomer888/e77c5963a9c2c4c97a27fbbd98ad301d to your computer and use it in GitHub Desktop.

Select an option

Save nomer888/e77c5963a9c2c4c97a27fbbd98ad301d to your computer and use it in GitHub Desktop.
ANSI renderer
local ANSI_COLOR_MAP = {
["[30m"] = "rgb(0, 0, 0)",
["[31m"] = "rgb(254, 91, 86)",
["[32m"] = "rgb(89, 246, 141)",
["[33m"] = "rgb(255, 255, 164)",
["[34m"] = "rgb(87, 199, 255)",
["[35m"] = "rgb(254, 105, 192)",
["[36m"] = "rgb(153, 236, 254)",
["[37m"] = "rgb(240, 240, 239)",
}
local BACKGROUND_COLOR_MAP = {
["[40m"] = Color3.fromRGB(0, 0, 0),
["[41m"] = Color3.fromRGB(254, 91, 86),
["[42m"] = Color3.fromRGB(89, 246, 141),
["[43m"] = Color3.fromRGB(255, 255, 164),
["[44m"] = Color3.fromRGB(87, 199, 255),
["[45m"] = Color3.fromRGB(254, 105, 192),
["[46m"] = Color3.fromRGB(153, 236, 254),
["[47m"] = Color3.fromRGB(240, 240, 239),
}
local function defaultState()
return {
stack = {}, -- stack of styles (font/color/bold/etc)
visibleCharacterIndex = 0,
-- Background runs
backgroundRuns = {},
currentBackgroundColor = nil,
backgroundStartIndex = nil,
}
end
local function styleOpeningTag(style)
if style.type == "bold" then
return "<b>"
elseif style.type == "italic" then
return "<i>"
elseif style.type == "underline" then
return "<u>"
elseif style.type == "strikethrough" then
return "<s>"
elseif style.type == "color" then
return string.format('<font color="%s">', style.color)
end
return ""
end
local function styleClosingTag(style)
if style.type == "bold" then
return "</b>"
elseif style.type == "italic" then
return "</i>"
elseif style.type == "underline" then
return "</u>"
elseif style.type == "strikethrough" then
return "</s>"
elseif style.type == "color" then
return "</font>"
end
return ""
end
local function isStyleActive(state, styleType)
for _, s in ipairs(state.stack) do
if s.type == styleType then
return true
end
end
return false
end
local function pushStyle(state, output, style)
table.insert(state.stack, style)
output[#output + 1] = styleOpeningTag(style)
end
local function popStylesUntil(state, output, targetType)
local popped = {}
local foundTarget = false
while #state.stack > 0 do
local top = state.stack[#state.stack]
table.remove(state.stack)
output[#output + 1] = styleClosingTag(top)
if top.type == targetType then
foundTarget = true
break
else
table.insert(popped, 1, top)
end
end
return foundTarget, popped
end
local function restoreStyles(state, output, styles)
for _, st in ipairs(styles) do
pushStyle(state, output, st)
end
end
local function disableStyle(state, output, styleType)
if not isStyleActive(state, styleType) then
return
end
local foundTarget, popped = popStylesUntil(state, output, styleType)
if foundTarget then
restoreStyles(state, output, popped)
end
end
local function resetAll(state, output)
-- Reset all foreground styles
while #state.stack > 0 do
local top = state.stack[#state.stack]
table.remove(state.stack)
output[#output + 1] = styleClosingTag(top)
end
-- Also reset background if active
if state.currentBackgroundColor then
local run = {
startIndex = state.backgroundStartIndex,
endIndex = state.visibleCharacterIndex - 1,
color = state.currentBackgroundColor,
}
table.insert(state.backgroundRuns, run)
state.currentBackgroundColor = nil
state.backgroundStartIndex = nil
end
end
local function enableStyle(state, output, styleType, color)
if styleType == "color" then
disableStyle(state, output, "color")
pushStyle(state, output, { type = "color", color = color })
else
if not isStyleActive(state, styleType) then
pushStyle(state, output, { type = styleType })
end
end
end
local function enableBackground(state, bgColor)
if state.currentBackgroundColor == bgColor then
return
end
-- If there's already a background active, close it first
if state.currentBackgroundColor then
local run = {
startIndex = state.backgroundStartIndex,
endIndex = state.visibleCharacterIndex - 1,
color = state.currentBackgroundColor,
}
table.insert(state.backgroundRuns, run)
state.currentBackgroundColor = nil
state.backgroundStartIndex = nil
end
state.currentBackgroundColor = bgColor
state.backgroundStartIndex = state.visibleCharacterIndex
end
local function disableBackground(state)
if state.currentBackgroundColor then
local run = {
startIndex = state.backgroundStartIndex,
endIndex = state.visibleCharacterIndex - 1,
color = state.currentBackgroundColor,
}
table.insert(state.backgroundRuns, run)
state.currentBackgroundColor = nil
state.backgroundStartIndex = nil
end
end
local function applyCode(code, output, state)
if code == "[0m" then
resetAll(state, output)
return
end
local codes = {}
for c in code:gmatch("%[(.-)m") do
for sc in c:gmatch("([^;]+)") do
table.insert(codes, sc)
end
end
for _, c in ipairs(codes) do
local num = tonumber(c)
if num == 0 then
resetAll(state, output)
elseif num == 1 then
enableStyle(state, output, "bold")
elseif num == 22 then
disableStyle(state, output, "bold")
elseif num == 3 then
enableStyle(state, output, "italic")
elseif num == 23 then
disableStyle(state, output, "italic")
elseif num == 4 then
enableStyle(state, output, "underline")
elseif num == 24 then
disableStyle(state, output, "underline")
elseif num == 9 then
enableStyle(state, output, "strikethrough")
elseif num == 29 then
disableStyle(state, output, "strikethrough")
elseif num >= 30 and num <= 37 then
local ansiCode = "[" .. tostring(num) .. "m"
local color = ANSI_COLOR_MAP[ansiCode]
if color then
enableStyle(state, output, "color", color)
end
elseif num == 39 then
-- Reset foreground color
disableStyle(state, output, "color")
elseif num >= 40 and num <= 47 then
local ansiCode = "[" .. tostring(num) .. "m"
local bgColor = BACKGROUND_COLOR_MAP[ansiCode]
if bgColor then
enableBackground(state, bgColor)
end
elseif num == 49 then
-- Reset background
disableBackground(state)
end
end
end
local function ansiToRichTextChunk(text, currentState)
text = text:gsub("✕", "×")
if not currentState then
currentState = defaultState()
end
local output = {}
local last_end = 1
-- Find all ANSI sequences: \27 followed by [ and then zero or more digits/semicolons, ending with 'm'.
for start, code, finish in text:gmatch("()(\27%[[%d;]*m)()") do
-- Add all plain text before this code
if last_end < start then
local chunk = text:sub(last_end, start - 1)
output[#output + 1] = chunk
currentState.visibleCharacterIndex = currentState.visibleCharacterIndex + #chunk
end
-- Apply ANSI code
applyCode(code:sub(2), output, currentState) -- code:sub(2) to remove the initial ESC character
last_end = finish
end
-- Add remaining text after last ANSI code
if last_end <= #text then
local chunk = text:sub(last_end)
output[#output + 1] = chunk
currentState.visibleCharacterIndex = currentState.visibleCharacterIndex + #chunk
end
return table.concat(output), currentState
end
local function finalizeRichText(currentState)
local output = {}
resetAll(currentState, output)
return table.concat(output)
end
local function getTemporaryClosingString(currentState)
local savedStack = table.clone(currentState.stack)
local preview = {}
while #savedStack > 0 do
local top = savedStack[#savedStack]
table.remove(savedStack)
preview[#preview + 1] = styleClosingTag(top)
end
if currentState.currentBackgroundColor then
local run = {
startIndex = currentState.backgroundStartIndex,
endIndex = currentState.visibleCharacterIndex - 1,
color = currentState.currentBackgroundColor,
}
table.insert(currentState.backgroundRuns, run)
currentState.currentBackgroundColor = nil
currentState.backgroundStartIndex = nil
end
return table.concat(preview)
end
-- Helper function to split text into words and newlines
local function splitIntoWords(text)
local words = {}
local currentWord = ""
for i = 1, #text do
local char = text:sub(i, i)
if char == "\n" then
if #currentWord > 0 then
table.insert(words, currentWord)
currentWord = ""
end
table.insert(words, "\n")
elseif char == " " then
if #currentWord > 0 then
table.insert(words, currentWord)
currentWord = ""
end
table.insert(words, " ")
else
currentWord = currentWord .. char
end
end
if #currentWord > 0 then
table.insert(words, currentWord)
end
return words
end
local function measureTextWidth(text, charSize)
return #text * charSize.X
end
local function updateBackgroundFrames(TextLabel, charSize, backgroundRunFrames, backgroundRuns)
if #backgroundRuns == 0 then
for _, frame in ipairs(backgroundRunFrames) do
frame.Visible = false
end
return
end
local text = TextLabel.ContentText
local words = splitIntoWords(text)
local maxWidth = TextLabel.AbsoluteSize.X
-- Calculate word positions and line breaks
local lines = {}
local currentLine = { words = {}, startIndex = 1 }
local currentLineWidth = 0
local currentIndex = 0
for _, word in ipairs(words) do
local wordWidth = measureTextWidth(word, charSize)
if word == "\n" then
-- Force a line break
table.insert(lines, currentLine)
currentLine = { words = {}, startIndex = currentIndex + 1 }
currentLineWidth = 0
elseif currentLineWidth + wordWidth <= maxWidth then
-- Add word to current line
table.insert(currentLine.words, { text = word, index = currentIndex })
currentLineWidth = currentLineWidth + wordWidth
else
-- Start new line
table.insert(lines, currentLine)
currentLine = { words = { { text = word, index = currentIndex } }, startIndex = currentIndex }
currentLineWidth = wordWidth
end
currentIndex = currentIndex + #word
end
if #currentLine.words > 0 then
table.insert(lines, currentLine)
end
-- Create/update background frames
local frameIndex = 1
for _, run in ipairs(backgroundRuns) do
local runStart = run.startIndex
local runEnd = run.endIndex
-- Find lines that intersect with this run
for lineIndex, line in ipairs(lines) do
local lineStartIndex = line.startIndex
local lineEndIndex = lineStartIndex
for _, word in ipairs(line.words) do
lineEndIndex = word.index + #word.text - 1
end
-- Check if run intersects with this line
if not (runEnd < lineStartIndex or runStart > lineEndIndex) then
-- Calculate intersecting region
local intersectStart = math.max(runStart, lineStartIndex)
local intersectEnd = math.min(runEnd, lineEndIndex)
-- Find position and width of intersection
local xOffset = 0
local intersectWidth = 0
local foundStart = false
for _, word in ipairs(line.words) do
local wordStart = word.index
local wordEnd = wordStart + #word.text - 1
if wordEnd < intersectStart then
-- Word is before the highlight, add to offset
xOffset = xOffset + measureTextWidth(word.text, charSize)
elseif wordStart <= intersectEnd then
if not foundStart then
-- Adjust xOffset for partial word start
-- Subtract 1 from intersectStart to fix the off-by-one
xOffset = xOffset
+ measureTextWidth(word.text:sub(1, math.max(0, intersectStart - wordStart)), charSize)
foundStart = true
end
-- Calculate the width of the highlighted portion
local highlightStart = math.max(1, intersectStart - wordStart + 1)
local highlightEnd = intersectEnd - wordStart + 1
intersectWidth = intersectWidth
+ measureTextWidth(word.text:sub(highlightStart, highlightEnd), charSize)
end
end
-- Create or reuse frame
local frame = backgroundRunFrames[frameIndex]
if not frame then
frame = Instance.new("Frame")
frame.BorderSizePixel = 0
frame.Parent = TextLabel
backgroundRunFrames[frameIndex] = frame
end
-- Position and size frame
frame.Position = UDim2.new(0, xOffset, 0, (lineIndex - 1) * charSize.Y)
frame.Size = UDim2.new(0, intersectWidth, 0, charSize.Y)
frame.BackgroundColor3 = run.color
frame.Visible = true
frameIndex = frameIndex + 1
end
end
end
for i = frameIndex, #backgroundRunFrames do
backgroundRunFrames[i].Visible = false
end
end
return {
defaultState = defaultState,
ansiToRichTextChunk = ansiToRichTextChunk,
finalizeRichText = finalizeRichText,
getTemporaryClosingString = getTemporaryClosingString,
updateBackgroundFrames = updateBackgroundFrames,
}
local LogService = game:GetService("LogService")
local AnsiHelper = require(script.Parent.AnsiHelper)
local MESSAGE_TYPE_COLORS = {
[Enum.MessageType.MessageInfo] = "rgb(0, 139, 219)", -- blue
[Enum.MessageType.MessageWarning] = "rgb(255, 115, 21)", -- orange
[Enum.MessageType.MessageError] = "rgb(255,0,0)", -- red
}
local function colorMessageByType(message: string, messageType: Enum.MessageType): string
local color = MESSAGE_TYPE_COLORS[messageType]
if color then
return `<font color="{color}">{message}</font>`
end
return message
end
local function throttle(fn: () -> (), delay: number): () -> ()
local lastCall = 0
local scheduled = false
local function throttled(...)
local args = table.pack(...)
if not scheduled then
scheduled = true
task.defer(function()
local now = tick()
if now - lastCall < delay then
task.wait(delay - (now - lastCall))
end
fn(table.unpack(args))
lastCall = tick()
scheduled = false
end)
end
end
return throttled
end
return function(target: Frame)
local container
local conn
local function cleanup()
if container then
container:Destroy()
end
if conn then
conn:Disconnect()
end
end
local renderOutputOk, renderOutputErr = pcall(function()
container = Instance.new("Frame")
container.Size = UDim2.fromScale(0.7, 0.98)
container.Position = UDim2.fromScale(0.5, 0.5)
container.AnchorPoint = Vector2.new(0.5, 0.5)
container.BackgroundColor3 = Color3.fromRGB(40, 42, 54)
container.BorderSizePixel = 0
container.Parent = target
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 16)
corner.Parent = container
local pad = Instance.new("UIPadding")
pad.PaddingTop = UDim.new(0, 16)
pad.PaddingRight = UDim.new(0, 16)
pad.PaddingBottom = UDim.new(0, 16)
pad.PaddingLeft = UDim.new(0, 16)
pad.Parent = container
local frame = Instance.new("ScrollingFrame")
frame.AutomaticCanvasSize = Enum.AutomaticSize.Y
frame.Size = UDim2.fromScale(1, 1)
frame.Position = UDim2.fromScale(0.5, 0.5)
frame.AnchorPoint = Vector2.new(0.5, 0.5)
frame.BackgroundTransparency = 1
frame.BorderSizePixel = 0
frame.CanvasSize = UDim2.fromScale(0, 0)
frame.BottomImage = "rbxasset://textures/ui/Scroll/scroll-middle.png"
frame.TopImage = "rbxasset://textures/ui/Scroll/scroll-middle.png"
frame.ScrollBarThickness = 8
frame.ScrollBarImageTransparency = 0.5
frame.ScrollBarImageColor3 = Color3.fromRGB(255, 255, 255)
frame.Parent = container
local labelFrame = Instance.new("Frame")
labelFrame.Size = UDim2.fromScale(1, 1)
labelFrame.BackgroundTransparency = 1
labelFrame.Parent = frame
local list = Instance.new("UIListLayout")
list.SortOrder = Enum.SortOrder.LayoutOrder
list.Parent = labelFrame
local textLabels = {}
local function makeTextLabel()
local label = Instance.new("TextLabel")
label.Size = UDim2.fromScale(0.95, 0)
label.AutomaticSize = Enum.AutomaticSize.Y
label.Position = UDim2.fromScale(0.5, 0)
label.AnchorPoint = Vector2.new(0.5, 0)
label.BackgroundTransparency = 1
label.RichText = true
label.TextYAlignment = Enum.TextYAlignment.Bottom
label.TextXAlignment = Enum.TextXAlignment.Left
label.Text = ""
label.TextSize = 16
label.TextWrapped = true
label.ZIndex = 2
label.FontFace = Font.new("rbxasset://fonts/families/RobotoMono.json")
-- Set the default text color explicitly (Chalk defaults to white if no color code is applied)
label.TextColor3 = Color3.fromRGB(240, 240, 239)
return label
end
local outputs = {}
local currentState = AnsiHelper.defaultState()
local _label = makeTextLabel()
_label.Parent = labelFrame
table.insert(textLabels, _label)
table.insert(outputs, "")
local messageQueue = {}
local function render()
for i, item in messageQueue do
messageQueue[i] = colorMessageByType(item.message, item.messageType)
end
table.insert(messageQueue, "")
local msg = table.concat(messageQueue, "\n")
messageQueue = {}
local wasAtBottom = frame.CanvasPosition.Y >= frame.AbsoluteCanvasSize.Y - frame.AbsoluteSize.Y - 16
local renderOk, renderErr = pcall(function()
local segment, newState = AnsiHelper.ansiToRichTextChunk(msg, currentState)
currentState = newState
local displayText = segment .. AnsiHelper.getTemporaryClosingString(currentState)
local label
local output = outputs[#outputs]
local prevLabel = textLabels[#textLabels]
prevLabel.Text = output .. segment
if utf8.len(prevLabel.ContentText) > 16384 then
output = string.sub(output, 1, -2) -- remove trailing newlines
outputs[#outputs] = output
prevLabel.Text = output
label = makeTextLabel()
label.LayoutOrder = #textLabels + 1
label.Parent = labelFrame
table.insert(textLabels, label)
table.insert(outputs, "")
output = ""
else
label = textLabels[#textLabels]
end
label.Text = output .. displayText
output ..= segment
outputs[#outputs] = output
if wasAtBottom then
frame.CanvasPosition = Vector2.new(0, frame.AbsoluteCanvasSize.Y)
end
end)
if not renderOk then
conn:Disconnect()
warn("Error rendering message:", renderErr)
end
end
local renderThrottled = throttle(render, 1 / 60)
conn = LogService.MessageOut:Connect(function(message, messageType)
table.insert(messageQueue, { message = message, messageType = messageType })
renderThrottled()
end)
end)
if not renderOutputOk then
if conn then
conn:Disconnect()
end
warn(renderOutputErr)
return cleanup
end
task.spawn(function()
local runJestTests = require(script.Parent.runJestTests)
runJestTests()
end)
return cleanup
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment