Terminal-Bugfix: In-App-PTY-Terminal im Dracula-Theme, .command-Fallback, ANSI-Parser-Fix, Doku
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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..<min(i + len, bytes.count)]
|
||||
chunk += String(decoding: slice, as: UTF8.self)
|
||||
i += slice.count
|
||||
}
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return events
|
||||
}
|
||||
|
||||
private mutating func parseCSI(bytes: [UInt8], from start: Int, chunk: inout String) -> (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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user