From 437fe7ac3118f582e5a13e5c5c8bbe24704f4902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 12 Oct 2025 13:02:58 +0800 Subject: [PATCH] Add ANSI color parsing support to logs --- ApplicationLibrary/Views/Log/LogView.swift | 4 +- Library/Shared/ANSIColors.swift | 127 +++++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 Library/Shared/ANSIColors.swift diff --git a/ApplicationLibrary/Views/Log/LogView.swift b/ApplicationLibrary/Views/Log/LogView.swift index adcd7aa..31215a9 100644 --- a/ApplicationLibrary/Views/Log/LogView.swift +++ b/ApplicationLibrary/Views/Log/LogView.swift @@ -29,7 +29,7 @@ public struct LogView: View { ScrollView { VStack(alignment: .leading, spacing: 0) { ForEach(Array(logList.enumerated()), id: \.offset) { it in - Text(it.element) + Text(ANSIColors.parseAnsiString(it.element)) .font(logFont) #if os(tvOS) .focusable() @@ -59,7 +59,7 @@ public struct LogView: View { ScrollView { LazyVGrid(columns: [GridItem(.flexible())], alignment: .leading, spacing: 0) { ForEach(Array(logClient.logList.enumerated()), id: \.offset) { it in - Text(it.element) + Text(ANSIColors.parseAnsiString(it.element)) .font(logFont) #if os(tvOS) .focusable() diff --git a/Library/Shared/ANSIColors.swift b/Library/Shared/ANSIColors.swift new file mode 100644 index 0000000..5fc1f32 --- /dev/null +++ b/Library/Shared/ANSIColors.swift @@ -0,0 +1,127 @@ +import Foundation +import SwiftUI + +public enum ANSIColors { + private static let ansiRegex = try! NSRegularExpression(pattern: "\u{001B}\\[[;\\d]*m") + + private static let logRed = Color(red: 1.0, green: 0.13, blue: 0.35) + private static let logGreen = Color(red: 0.18, green: 0.8, blue: 0.44) + private static let logYellow = Color(red: 0.9, green: 0.9, blue: 0.0) + private static let logBlue = Color(red: 0.2, green: 0.6, blue: 0.86) + private static let logPurple = Color(red: 0.61, green: 0.35, blue: 0.71) + private static let logBlueLight = Color(red: 0.36, green: 0.68, blue: 0.89) + private static let logWhite = Color(red: 0.93, green: 0.94, blue: 0.95) + + public static func parseAnsiString(_ text: String) -> AttributedString { + let nsString = text as NSString + let matches = ansiRegex.matches(in: text, range: NSRange(location: 0, length: nsString.length)) + + if matches.isEmpty { + return AttributedString(text) + } + + var cleanText = text + for match in matches.reversed() { + cleanText = (cleanText as NSString).replacingCharacters(in: match.range, with: "") as String + } + + var attributedString = AttributedString(cleanText) + var currentStyle: AttributeContainer? + var currentStart = 0 + var offset = 0 + + for match in matches { + let code = nsString.substring(with: match.range) + let codeStart = match.range.location - offset + + if let style = parseAnsiCode(code) { + if let existingStyle = currentStyle, currentStart < codeStart { + let startIndex = attributedString.index(attributedString.startIndex, offsetByCharacters: currentStart) + let endIndex = attributedString.index(attributedString.startIndex, offsetByCharacters: codeStart) + attributedString[startIndex ..< endIndex].mergeAttributes(existingStyle) + } + currentStyle = style + currentStart = codeStart + } else { + if let existingStyle = currentStyle, currentStart < codeStart { + let startIndex = attributedString.index(attributedString.startIndex, offsetByCharacters: currentStart) + let endIndex = attributedString.index(attributedString.startIndex, offsetByCharacters: codeStart) + attributedString[startIndex ..< endIndex].mergeAttributes(existingStyle) + } + currentStyle = nil + currentStart = codeStart + } + + offset += match.range.length + } + + if let existingStyle = currentStyle, currentStart < cleanText.count { + let startIndex = attributedString.index(attributedString.startIndex, offsetByCharacters: currentStart) + attributedString[startIndex...].mergeAttributes(existingStyle) + } + + return attributedString + } + + private static func parseAnsiCode(_ code: String) -> AttributeContainer? { + let codeString = code.dropFirst(2).dropLast() + let colorCodes = codeString.split(separator: ";").map { String($0) } + + var container = AttributeContainer() + var hasAttribute = false + + for codeStr in colorCodes { + switch codeStr { + case "0": + return nil + case "1": + container.font = .system(.caption2, design: .monospaced).bold() + hasAttribute = true + case "3": + container.font = .system(.caption2, design: .monospaced).italic() + hasAttribute = true + case "4": + container.underlineStyle = .single + hasAttribute = true + case "30": + container.foregroundColor = .black + hasAttribute = true + case "31": + container.foregroundColor = logRed + hasAttribute = true + case "32": + container.foregroundColor = logGreen + hasAttribute = true + case "33": + container.foregroundColor = logYellow + hasAttribute = true + case "34": + container.foregroundColor = logBlue + hasAttribute = true + case "35": + container.foregroundColor = logPurple + hasAttribute = true + case "36": + container.foregroundColor = logBlueLight + hasAttribute = true + case "37": + container.foregroundColor = logWhite + hasAttribute = true + default: + if let codeInt = Int(codeStr), codeInt >= 38, codeInt <= 125 { + let adjustedCode = codeInt % 125 + let row = adjustedCode / 36 + let column = adjustedCode % 36 + container.foregroundColor = Color( + red: Double(row * 51) / 255.0, + green: Double((column / 6) * 51) / 255.0, + blue: Double((column % 6) * 51) / 255.0 + ) + hasAttribute = true + } + } + } + + return hasAttribute ? container : nil + } +}