Created
December 22, 2024 23:44
-
-
Save nomer888/e77c5963a9c2c4c97a27fbbd98ad301d to your computer and use it in GitHub Desktop.
ANSI renderer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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