import Foundation /// Reine ANSI/SGR-Logik ohne UI-Abhängigkeiten (unit-testbar). /// Versteht: SGR-Farben (30-37, 90-97, 38;5;n, 38;2;r;g;b + BG-Varianten), /// Clear Screen (ESC[2J), Clear Line (ESC[K]), CR/LF/Tab/Backspace/Bell. /// Alles andere (Cursor-Moves etc.) wird ignoriert. /// Vorder-/Hintergrund als Dracula-Index 0-15, nil = Standard. struct TermStyle: Equatable { var fg: Int? = nil var bg: Int? = nil var bold: Bool = false } struct StyledText: Equatable { var text: String var style: TermStyle } enum TerminalEvent: Equatable { case text(StyledText) case newline case carriageReturn case backspace case clearScreen case clearLine case bell } struct AnsiParser { private(set) var style = TermStyle() mutating func feed(_ data: Data) -> [TerminalEvent] { var events: [TerminalEvent] = [] var chunk = "" func flush() { if !chunk.isEmpty { events.append(.text(StyledText(text: chunk, style: style))) chunk = "" } } let bytes = [UInt8](data) var i = 0 while i < bytes.count { let b = bytes[i] switch b { case 0x07: // BEL flush(); events.append(.bell); i += 1 case 0x08: // BS flush(); events.append(.backspace); i += 1 case 0x09: // TAB -> 8er-Stopp angenähert mit Spaces chunk += " "; i += 1 case 0x0A: // LF flush(); events.append(.newline); i += 1 case 0x0D: // CR (+ LF folgt -> nur Newline, sonst Overwrite) if i + 1 < bytes.count && bytes[i + 1] == 0x0A { i += 1 // CR vor LF schlucken, LF handled es } else { flush(); events.append(.carriageReturn) i += 1 } case 0x1B: // ESC if i + 1 < bytes.count && bytes[i + 1] == UInt8(ascii: "[") { flush() // pending Text gehört noch zum alten Stil let (consumed, evs) = parseCSI(bytes: bytes, from: i, chunk: &chunk) events.append(contentsOf: evs) i += consumed } else if i + 1 < bytes.count && bytes[i + 1] == UInt8(ascii: "]") { // OSC (z. B. Fenstertitel) bis BEL oder ESC\ skippen flush() i += 2 while i < bytes.count { if bytes[i] == 0x07 { i += 1; break } if bytes[i] == 0x1B && i + 1 < bytes.count && bytes[i + 1] == UInt8(ascii: "\\") { i += 2; break } i += 1 } } else { i += 2 // sonstige ESC-Sequenz ignorieren } default: if b < 0x20 { i += 1 // übrige Steuerzeichen ignorieren } else { // UTF-8: Folgebytes mitnehmen let len: Int if b & 0x80 == 0 { len = 1 } else if b & 0xE0 == 0xC0 { len = 2 } else if b & 0xF0 == 0xE0 { len = 3 } else { len = 4 } let slice = bytes[i.. (Int, [TerminalEvent]) { var i = start + 2 // hinter ESC[ var params = "" while i < bytes.count && !(bytes[i] >= 0x40 && bytes[i] <= 0x7E) { params.append(Character(UnicodeScalar(bytes[i]))) i += 1 } guard i < bytes.count else { return (bytes.count - start, []) } let final = bytes[i] i += 1 switch final { case UInt8(ascii: "m"): applySGR(params) return (i - start, []) case UInt8(ascii: "J"): if params == "2" || params == "3" { return (i - start, [.clearScreen]) } return (i - start, []) case UInt8(ascii: "K"): return (i - start, [.clearLine]) case UInt8(ascii: "H"), UInt8(ascii: "f"): return (i - start, [.carriageReturn]) default: return (i - start, []) // Cursor-Moves etc. ignorieren } } private mutating func applySGR(_ params: String) { let codes = params.isEmpty ? [0] : params.split(separator: ";").compactMap { Int($0) } var i = 0 while i < codes.count { let c = codes[i] switch c { case 0: style = TermStyle() case 1: style.bold = true case 22: style.bold = false case 30...37: style.fg = c - 30 case 39: style.fg = nil case 40...47: style.bg = c - 40 case 49: style.bg = nil case 90...97: style.fg = c - 90 + 8 case 100...107: style.bg = c - 100 + 8 case 38, 48: let isFg = (c == 38) if i + 1 < codes.count && codes[i + 1] == 5 && i + 2 < codes.count { let mapped = Self.map256(codes[i + 2]) if isFg { style.fg = mapped } else { style.bg = mapped } i += 2 } else if i + 1 < codes.count && codes[i + 1] == 2 && i + 4 < codes.count { let mapped = Self.nearestDracula(r: codes[i + 2], g: codes[i + 3], b: codes[i + 4]) if isFg { style.fg = mapped } else { style.bg = mapped } i += 4 } default: break } i += 1 } } /// 256-Farben grob auf Dracula 0-15 mappen. static func map256(_ n: Int) -> Int { if n < 16 { return n } if n >= 232 { if n < 238 { return 0 } if n < 248 { return 8 } return 15 } let x = n - 16 let r = (x / 36) % 6, g = (x / 6) % 6, b = x % 6 let bright = (r > 2 || g > 2 || b > 2) ? 8 : 0 if r >= g && r >= b { return 1 + bright } if g >= b { return 2 + bright } if b == g && b > r { return 6 + bright } return 4 + bright } /// Truecolor -> nächstliegende Dracula-Farbe (vereinfachte Distanz). static func nearestDracula(r: Int, g: Int, b: Int) -> Int { let palette = [(33, 34, 44), (255, 85, 85), (80, 250, 123), (241, 250, 140), (189, 147, 249), (255, 121, 198), (139, 233, 253), (248, 248, 242), (98, 114, 164), (255, 110, 110), (105, 255, 148), (255, 255, 165), (214, 172, 255), (255, 146, 223), (164, 255, 255), (255, 255, 255)] var best = 7, bestDist = Int.max for (idx, (pr, pg, pb)) in palette.enumerated() { let d = (r - pr) * (r - pr) + (g - pg) * (g - pg) + (b - pb) * (b - pb) if d < bestDist { bestDist = d; best = idx } } return best } }