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) } }