Terminal-Bugfix: In-App-PTY-Terminal im Dracula-Theme, .command-Fallback, ANSI-Parser-Fix, Doku

This commit is contained in:
x3
2026-09-21 22:59:17 +02:00
parent 8fe8c4925e
commit 1828a96a2b
9 changed files with 699 additions and 18 deletions
@@ -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
}
}