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,157 @@
import SwiftUI
import AppKit
/// In-App-Terminal im Dracula-Look: Ausgabe (NSTextView, read-only) + Eingabezeile.
/// Zeilenumbruch ist an, Autoscroll ans Ende, Passwort-Modus via Auge-Icon.
struct EmbeddedTerminalView: View {
@ObservedObject var term: PTYSession
var canConnect: Bool
@State private var input = ""
@State private var secureInput = false
var body: some View {
VStack(spacing: 0) {
// Statuszeile
HStack(spacing: 8) {
statusDot
Text(statusText).font(.caption).foregroundStyle(.secondary)
Spacer()
if term.isRunning {
Button("Trennen") { term.stop() }.buttonStyle(.link).font(.caption)
} else if canConnect {
Button("Verbinden") { term.start() }.buttonStyle(.link).font(.caption)
}
Button("Leeren") { term.clear() }.buttonStyle(.link).font(.caption)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Dracula.currentLine.opacity(0.35))
// Ausgabe im Dracula-Theme
DraculaTextView(lines: term.lines)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Eingabezeile
HStack(spacing: 6) {
Text("❯").font(.system(.body, design: .monospaced)).foregroundStyle(Dracula.green)
if secureInput {
SecureField("Eingabe (versteckt, z. B. Passwort) …", text: $input, onCommit: submit)
.textFieldStyle(.plain)
.font(.system(.body, design: .monospaced))
.foregroundStyle(Dracula.foreground)
} else {
TextField("Eingabe …", text: $input, onCommit: submit)
.textFieldStyle(.plain)
.font(.system(.body, design: .monospaced))
.foregroundStyle(Dracula.foreground)
}
Button(action: { secureInput.toggle() }) {
Image(systemName: secureInput ? "eye.slash" : "eye")
}
.buttonStyle(.plain).foregroundStyle(Dracula.comment)
.help("Passwort-Modus (versteckte Eingabe)")
Button("Senden") { submit() }
.disabled(input.isEmpty || !term.isRunning)
ControlButton(label: "^C", tip: "SIGINT (Strg+C)") { term.send("\u{03}") }
ControlButton(label: "^D", tip: "EOF (Strg+D)") { term.send("\u{04}") }
}
.padding(8)
.background(Dracula.background)
}
.background(Dracula.background)
.cornerRadius(10)
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Dracula.currentLine, lineWidth: 1))
.onAppear {
if case .never = term.state, canConnect {
term.start() // Auto-Connect beim Öffnen des Tabs (Bugfix: kein externes Terminal mehr nötig)
}
}
}
func submit() {
guard term.isRunning, !input.isEmpty else { return }
term.send(input + "\n")
input = ""
}
var statusDot: some View {
Group {
if term.isRunning { Image(systemName: "circle.fill").foregroundStyle(Dracula.green) }
else if case .done = term.state { Image(systemName: "circle.fill").foregroundStyle(Dracula.red) }
else { Image(systemName: "circle.fill").foregroundStyle(Dracula.comment) }
}.font(.caption2)
}
var statusText: String {
switch term.state {
case .never: return canConnect ? "Bereit — verbindet beim Öffnen." : "Host/Benutzer fehlt."
case .running: return "Verbunden (PTY, TERM=xterm-256color)."
case .done(_, let msg): return msg
}
}
}
struct ControlButton: View {
var label: String
var tip: String
var action: () -> Void
var body: some View {
Button(label, action: action)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Dracula.orange)
.buttonStyle(.plain)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Dracula.currentLine.opacity(0.6), in: RoundedRectangle(cornerRadius: 5))
.help(tip)
}
}
/// Read-only NSTextView mit Dracula-Farben, baut NSAttributedString aus StyledText-Zeilen.
struct DraculaTextView: NSViewRepresentable {
var lines: [[StyledText]]
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSScrollView()
scroll.hasVerticalScroller = true
scroll.drawsBackground = true
scroll.backgroundColor = Dracula.nsBackground
let tv = NSTextView()
tv.isEditable = false
tv.isSelectable = true
tv.backgroundColor = Dracula.nsBackground
tv.textColor = Dracula.nsColor(for: nil)
tv.font = .monospacedSystemFont(ofSize: 12, weight: .regular)
tv.textContainerInset = NSSize(width: 8, height: 8)
tv.isAutomaticQuoteSubstitutionEnabled = false
scroll.documentView = tv
return scroll
}
func updateNSView(_ scroll: NSScrollView, context: Context) {
guard let tv = scroll.documentView as? NSTextView else { return }
let attr = NSMutableAttributedString()
let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
let boldFont = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask)
for (idx, row) in lines.enumerated() {
if row.isEmpty {
attr.append(NSAttributedString(string: idx < lines.count - 1 ? "\n" : ""))
continue
}
for seg in row {
let attrs: [NSAttributedString.Key: Any] = [
.font: seg.style.bold ? boldFont : font,
.foregroundColor: Dracula.nsColor(for: seg.style.fg),
.backgroundColor: seg.style.bg.map { Dracula.nsColor(for: $0) } ?? Dracula.nsBackground
]
attr.append(NSAttributedString(string: seg.text, attributes: attrs))
}
if idx < lines.count - 1 { attr.append(NSAttributedString(string: "\n")) }
}
// Nur neu setzen wenn geändert (Performance bei viel Output)
if tv.textStorage?.string != attr.string {
tv.textStorage?.setAttributedString(attr)
}
tv.scrollToEndOfDocument(nil)
}
}