Full interactive terminal via SwiftTerm, About sheet, English docs
This commit is contained in:
@@ -33,6 +33,9 @@ final class PTYSession: ObservableObject {
|
||||
private var readSource: DispatchSourceRead?
|
||||
private var errPipe: Pipe?
|
||||
private let ioQueue = DispatchQueue(label: "RemminaMac.pty")
|
||||
/// Raw output hook for the terminal emulator (SwiftTerm feeds on this).
|
||||
/// Called on ioQueue for every chunk from pty master and stderr pipe.
|
||||
var onData: ((Data) -> Void)?
|
||||
private var parser = AnsiParser()
|
||||
private var finished = false
|
||||
private var epoch = 0 // nur Main-Thread
|
||||
@@ -132,6 +135,7 @@ final class PTYSession: ObservableObject {
|
||||
h.readabilityHandler = nil // EOF
|
||||
return
|
||||
}
|
||||
self.onData?(data)
|
||||
self.ioQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let events = self.parser.feed(data)
|
||||
@@ -152,7 +156,9 @@ final class PTYSession: ObservableObject {
|
||||
var buf = [UInt8](repeating: 0, count: 8192)
|
||||
let n = read(fd, &buf, buf.count)
|
||||
if n > 0 {
|
||||
let events = parser.feed(Data(buf[..<n]))
|
||||
let data = Data(buf[..<n])
|
||||
onData?(data)
|
||||
let events = parser.feed(data)
|
||||
DispatchQueue.main.async { [weak self] in self?.apply(events) }
|
||||
} else {
|
||||
readSource?.cancel() // EOF/EIO — Rest holt finalFlush bzw. ist verworfen
|
||||
@@ -217,9 +223,14 @@ final class PTYSession: ObservableObject {
|
||||
}
|
||||
|
||||
func send(_ string: String) {
|
||||
guard let data = string.data(using: .utf8) else { return }
|
||||
sendBytes(data)
|
||||
}
|
||||
|
||||
/// Raw bytes from the terminal emulator (keyboard) to the pty master.
|
||||
func sendBytes(_ data: Data) {
|
||||
let fd = masterFD
|
||||
guard fd >= 0 else { return }
|
||||
guard let data = string.data(using: .utf8), !data.isEmpty else { return }
|
||||
guard fd >= 0, !data.isEmpty else { return }
|
||||
ioQueue.async {
|
||||
data.withUnsafeBytes { ptr in
|
||||
guard let base = ptr.baseAddress else { return }
|
||||
@@ -233,6 +244,16 @@ final class PTYSession: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminal size to the pty (TIOCSWINSZ) so vim etc. use the full area.
|
||||
func setWinsize(cols: Int, rows: Int) {
|
||||
let fd = masterFD
|
||||
guard fd >= 0, cols > 0, rows > 0 else { return }
|
||||
var ws = winsize(ws_row: UInt16(min(rows, 65535)),
|
||||
ws_col: UInt16(min(cols, 65535)),
|
||||
ws_xpixel: 0, ws_ypixel: 0)
|
||||
_ = ioctl(fd, TIOCSWINSZ, &ws)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
lines = [[]]
|
||||
}
|
||||
@@ -324,27 +345,35 @@ final class TerminalManager: ObservableObject {
|
||||
private var sessions: [UUID: PTYSession] = [:]
|
||||
|
||||
func session(for profile: ConnectionProfile, key: StoredSSHKey?) -> PTYSession {
|
||||
// Fingerprint aus allem, was die Verbindung bestimmt (inkl. Secret-Wert!):
|
||||
// Ändert der User Host/Passwort/Key, wird neu konfiguriert statt stale weiterzuwählen.
|
||||
// Fingerprint aus allem, was die Verbindung bestimmt (inkl. Secret-Wert
|
||||
// und sshpass-Verfügbarkeit): Ändert der User Host/Passwort/Key oder wird
|
||||
// sshpass (nach-)installiert, wird neu konfiguriert statt stale weiterzuwählen.
|
||||
let secret = SSHService.loginSecret(for: profile, key: key)
|
||||
let tag = [profile.proto.rawValue, profile.host, "\(profile.port)", profile.username,
|
||||
profile.extraArgs, key?.privateKeyPath ?? "", secret ?? ""].joined(separator: "|")
|
||||
let sshpass = SSHService.findSshpass()
|
||||
let tag = Self.configTag(profile: profile, keyPath: key?.privateKeyPath,
|
||||
secret: secret, sshpass: sshpass)
|
||||
if let existing = sessions[profile.id] {
|
||||
// Laufende Session niemals unter den Füßen wegkonfigurieren — erst Trennen.
|
||||
if existing.isRunning || existing.configTag == tag { return existing }
|
||||
existing.stop()
|
||||
configure(existing, profile: profile, key: key, secret: secret)
|
||||
configure(existing, profile: profile, key: key, secret: secret, sshpass: sshpass)
|
||||
existing.configTag = tag
|
||||
return existing
|
||||
}
|
||||
let s = PTYSession()
|
||||
configure(s, profile: profile, key: key, secret: secret)
|
||||
configure(s, profile: profile, key: key, secret: secret, sshpass: sshpass)
|
||||
s.configTag = tag
|
||||
sessions[profile.id] = s
|
||||
return s
|
||||
}
|
||||
|
||||
private func configure(_ s: PTYSession, profile: ConnectionProfile, key: StoredSSHKey?, secret: String?) {
|
||||
/// Pure tag builder (testable): any change here must reconfigure.
|
||||
nonisolated static func configTag(profile: ConnectionProfile, keyPath: String?, secret: String?, sshpass: String?) -> String {
|
||||
[profile.proto.rawValue, profile.host, "\(profile.port)", profile.username,
|
||||
profile.extraArgs, keyPath ?? "", secret ?? "", sshpass ?? ""].joined(separator: "|")
|
||||
}
|
||||
|
||||
private func configure(_ s: PTYSession, profile: ConnectionProfile, key: StoredSSHKey?, secret: String?, sshpass: String?) {
|
||||
let baseExe: String
|
||||
let baseArgs: [String]
|
||||
if profile.proto == .sftp {
|
||||
@@ -358,7 +387,7 @@ final class TerminalManager: ObservableObject {
|
||||
// SSH-Agent durchreichen (falls vorhanden): gleiche Keys wie in Terminal.app.
|
||||
let cfg = SSHService.sessionConfig(executable: baseExe, args: baseArgs,
|
||||
password: secret,
|
||||
sshpassPath: SSHService.findSshpass())
|
||||
sshpassPath: sshpass)
|
||||
s.configure(executable: cfg.executable, arguments: cfg.arguments)
|
||||
var env = cfg.extraEnv
|
||||
if let sock = SSHService.agentSocket() {
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import SwiftTerm
|
||||
|
||||
/// Offizielle Dracula-Palette (https://draculatheme.com/contribute).
|
||||
enum Dracula {
|
||||
static let background = Color(hex: "282A36")
|
||||
static let currentLine = Color(hex: "44475A")
|
||||
static let foreground = Color(hex: "F8F8F2")
|
||||
static let comment = Color(hex: "6272A4")
|
||||
static let cyan = Color(hex: "8BE9FD")
|
||||
static let green = Color(hex: "50FA7B")
|
||||
static let orange = Color(hex: "FFB86C")
|
||||
static let pink = Color(hex: "FF79C6")
|
||||
static let purple = Color(hex: "BD93F9")
|
||||
static let red = Color(hex: "FF5555")
|
||||
static let yellow = Color(hex: "F1FA8C")
|
||||
static let background = SwiftUI.Color(hex: "282A36")
|
||||
static let currentLine = SwiftUI.Color(hex: "44475A")
|
||||
static let foreground = SwiftUI.Color(hex: "F8F8F2")
|
||||
static let comment = SwiftUI.Color(hex: "6272A4")
|
||||
static let cyan = SwiftUI.Color(hex: "8BE9FD")
|
||||
static let green = SwiftUI.Color(hex: "50FA7B")
|
||||
static let orange = SwiftUI.Color(hex: "FFB86C")
|
||||
static let pink = SwiftUI.Color(hex: "FF79C6")
|
||||
static let purple = SwiftUI.Color(hex: "BD93F9")
|
||||
static let red = SwiftUI.Color(hex: "FF5555")
|
||||
static let yellow = SwiftUI.Color(hex: "F1FA8C")
|
||||
|
||||
/// ANSI-Index 0-15 -> Dracula-Farbe.
|
||||
static func color(for index: Int?) -> Color {
|
||||
static func color(for index: Int?) -> SwiftUI.Color {
|
||||
guard let index else { return foreground }
|
||||
switch index {
|
||||
case 0: return Color(hex: "21222C")
|
||||
case 0: return SwiftUI.Color(hex: "21222C")
|
||||
case 1: return red
|
||||
case 2: return green
|
||||
case 3: return yellow
|
||||
@@ -28,13 +29,13 @@ enum Dracula {
|
||||
case 6: return cyan
|
||||
case 7: return foreground
|
||||
case 8: return comment
|
||||
case 9: return Color(hex: "FF6E6E")
|
||||
case 10: return Color(hex: "69FF94")
|
||||
case 11: return Color(hex: "FFFFA5")
|
||||
case 12: return Color(hex: "D6ACFF")
|
||||
case 13: return Color(hex: "FF92DF")
|
||||
case 14: return Color(hex: "A4FFFF")
|
||||
default: return Color(hex: "FFFFFF")
|
||||
case 9: return SwiftUI.Color(hex: "FF6E6E")
|
||||
case 10: return SwiftUI.Color(hex: "69FF94")
|
||||
case 11: return SwiftUI.Color(hex: "FFFFA5")
|
||||
case 12: return SwiftUI.Color(hex: "D6ACFF")
|
||||
case 13: return SwiftUI.Color(hex: "FF92DF")
|
||||
case 14: return SwiftUI.Color(hex: "A4FFFF")
|
||||
default: return SwiftUI.Color(hex: "FFFFFF")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +43,18 @@ enum Dracula {
|
||||
NSColor(color(for: index))
|
||||
}
|
||||
|
||||
static let nsBackground = NSColor(Color(hex: "282A36"))
|
||||
static let nsSelection = NSColor(Color(hex: "44475A"))
|
||||
static let nsBackground = NSColor(SwiftUI.Color(hex: "282A36"))
|
||||
static let nsSelection = NSColor(SwiftUI.Color(hex: "44475A"))
|
||||
|
||||
/// 16 ANSI colors for the terminal emulator (SwiftTerm palette).
|
||||
static var ansiPalette: [SwiftTerm.Color] {
|
||||
["21222C", "FF5555", "50FA7B", "F1FA8C", "BD93F9", "FF79C6", "8BE9FD", "F8F8F2",
|
||||
"6272A4", "FF6E6E", "69FF94", "FFFFA5", "D6ACFF", "FF92DF", "A4FFFF", "FFFFFF"]
|
||||
.compactMap { SwiftTerm.Color.parse("#\($0)") }
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
extension SwiftUI.Color {
|
||||
init(hex: String) {
|
||||
let clean = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var value: UInt64 = 0
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppInfo {
|
||||
static let version = "0.7.0"
|
||||
static let repoURL = URL(string: "https://git.sysdaemon.xyz/x3/remmina-mac")!
|
||||
}
|
||||
|
||||
/// About sheet: contributor, repository link, license, credits.
|
||||
struct AboutView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "terminal.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(Dracula.green)
|
||||
.padding(.top, 8)
|
||||
Text("RemminaMac")
|
||||
.font(.largeTitle).bold()
|
||||
Text("Version \(AppInfo.version)")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
Text("Remmina-style connection manager for macOS — SSH, SFTP, RDP and VNC profiles with an integrated Dracula terminal.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text("Contributor")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Text("x3")
|
||||
.font(.headline)
|
||||
Link(destination: AppInfo.repoURL) {
|
||||
Label("git.sysdaemon.xyz/x3/remmina-mac", systemImage: "link")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text("MIT License · Built with SwiftUI, SwiftTerm and a custom PTY layer.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(28)
|
||||
.frame(width: 420)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ struct ContentView: View {
|
||||
@State private var showingEditor = false
|
||||
@State private var editingProfile: ConnectionProfile?
|
||||
@State private var showingKeys = false
|
||||
@State private var showingAbout = false
|
||||
@State private var statusMessage = "Bereit."
|
||||
|
||||
var body: some View {
|
||||
@@ -59,6 +60,8 @@ struct ContentView: View {
|
||||
.help("Duplizieren").disabled(selection == nil)
|
||||
Button(action: { showingKeys = true }) { Image(systemName: "key") }
|
||||
.help("SSH-Keys verwalten")
|
||||
Button(action: { showingAbout = true }) { Image(systemName: "info.circle") }
|
||||
.help("Info")
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
@@ -116,6 +119,9 @@ struct ContentView: View {
|
||||
.sheet(isPresented: $showingKeys) {
|
||||
SSHKeysView().environmentObject(store)
|
||||
}
|
||||
.sheet(isPresented: $showingAbout) {
|
||||
AboutView()
|
||||
}
|
||||
}
|
||||
|
||||
var selectedProfile: ConnectionProfile? {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// In-App-Terminal im Dracula-Look: Ausgabe (NSTextView, read-only) + Eingabezeile.
|
||||
/// Verbindet erst nach TCP-Vorab-Check (falscher Port/Host -> sofort klare Meldung).
|
||||
/// Zeilenumbruch ist an, Autoscroll ans Ende, Passwort-Modus via Auge-Icon.
|
||||
/// Full interactive terminal (SwiftTerm emulator, Dracula themed):
|
||||
/// cursor addressing, colors, scrollback, Tab completion, arrows, Ctrl+C,
|
||||
/// full-screen apps (vim/htop). Keyboard goes straight to the pty.
|
||||
/// Connects after TCP pre-check + host-key approval (popup).
|
||||
struct EmbeddedTerminalView: View {
|
||||
@ObservedObject var term: PTYSession
|
||||
let profile: ConnectionProfile
|
||||
var canConnect: Bool
|
||||
@State private var input = ""
|
||||
@State private var secureInput = false
|
||||
@State private var checking = false
|
||||
@State private var hostKeyRequest: HostKeyRequest?
|
||||
|
||||
@@ -59,37 +58,9 @@ struct EmbeddedTerminalView: View {
|
||||
.overlay(Rectangle().fill(Dracula.red.opacity(0.4)).frame(height: 1), alignment: .bottom)
|
||||
}
|
||||
|
||||
// Ausgabe im Dracula-Theme
|
||||
DraculaTextView(lines: term.lines)
|
||||
// Full terminal emulator (click to focus, type directly).
|
||||
SwiftTermView(term: term)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
// Eingabezeile
|
||||
HStack(spacing: 6) {
|
||||
Text("❯").font(.system(.body, design: .monospaced)).foregroundStyle(Dracula.green)
|
||||
// Gehärtetes Feld: keine macOS-Text-Ersetzungen (Passwort-Sonderzeichen!)
|
||||
ZStack(alignment: .leading) {
|
||||
if input.isEmpty {
|
||||
Text(secureInput ? "Eingabe (versteckt, z. B. Passwort) …" : "Eingabe …")
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundStyle(Dracula.comment.opacity(0.7))
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
TerminalInputField(text: $input, isSecure: secureInput, onSubmit: submit)
|
||||
.id(secureInput)
|
||||
.frame(height: 22)
|
||||
}
|
||||
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)
|
||||
@@ -173,15 +144,7 @@ struct EmbeddedTerminalView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func submit() {
|
||||
guard term.isRunning, !input.isEmpty else { return }
|
||||
term.send(input + "\n")
|
||||
input = ""
|
||||
}
|
||||
|
||||
/// Fehlerbericht ohne Secrets (Passwort steht weder im Profil noch im Befehl).
|
||||
/// Hinweis: Wer das Passwort sichtbar eingetippt hat (nicht Auge-Modus),
|
||||
/// löscht die Ausgabe vorher per „Leeren".
|
||||
func copyReport() {
|
||||
var r = "RemminaMac Fehlerbericht\n"
|
||||
r += "Profil: \(profile.name) | \(profile.proto.rawValue) | \(profile.username)@\(profile.host):\(profile.port)\n"
|
||||
@@ -234,67 +197,3 @@ struct EmbeddedTerminalView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import SwiftTerm
|
||||
|
||||
/// Full terminal emulator view (SwiftTerm): cursor addressing, colors,
|
||||
/// scrollback, selection, and full-screen apps like vim/htop.
|
||||
/// Keyboard goes straight to the pty (Tab completion, arrows, Ctrl+C, Esc);
|
||||
/// pty output is fed in via PTYSession.onData. Dracula themed.
|
||||
struct SwiftTermView: NSViewRepresentable {
|
||||
@ObservedObject var term: PTYSession
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(term: term)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> TerminalView {
|
||||
let view = TerminalView(frame: .zero,
|
||||
font: .monospacedSystemFont(ofSize: 13, weight: .regular))
|
||||
view.terminalDelegate = context.coordinator
|
||||
view.nativeForegroundColor = Dracula.nsColor(for: nil)
|
||||
view.nativeBackgroundColor = Dracula.nsBackground
|
||||
let palette = Dracula.ansiPalette
|
||||
if palette.count == 16 {
|
||||
view.installColors(palette)
|
||||
}
|
||||
context.coordinator.view = view
|
||||
term.onData = { [weak view] data in
|
||||
let bytes = Array(data)
|
||||
DispatchQueue.main.async {
|
||||
view?.feed(byteArray: bytes[...])
|
||||
}
|
||||
}
|
||||
// Sync current size once the view exists (later resizes via delegate).
|
||||
DispatchQueue.main.async {
|
||||
term.setWinsize(cols: view.terminal.cols, rows: view.terminal.rows)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: TerminalView, context: Context) {
|
||||
context.coordinator.term = term
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, TerminalViewDelegate {
|
||||
var term: PTYSession?
|
||||
weak var view: TerminalView?
|
||||
|
||||
init(term: PTYSession) {
|
||||
self.term = term
|
||||
}
|
||||
|
||||
func send(source: TerminalView, data: ArraySlice<UInt8>) {
|
||||
term?.sendBytes(Data(data))
|
||||
}
|
||||
|
||||
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
|
||||
term?.setWinsize(cols: newCols, rows: newRows)
|
||||
}
|
||||
|
||||
func setTerminalTitle(source: TerminalView, title: String) {}
|
||||
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
|
||||
func scrolled(source: TerminalView, position: Double) {}
|
||||
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Eingabefeld ohne macOS-Text-Ersetzungen (Anführungszeichen, Gedankenstriche,
|
||||
/// Auto-Korrektur, Punkt-Verdopplung, Vervollständigung, Links/Daten-Erkennung).
|
||||
/// Hintergrund: Normale Textfelder ersetzen still z. B. `"` durch „“ und `--`
|
||||
/// durch — (je nach Tastatur-Einstellung). Im Terminal fällt das nicht auf, aber
|
||||
/// ein Passwort mit Sonderzeichen kommt falsch bei ssh an → „Permission denied",
|
||||
/// obwohl das Passwort „stimmt" (docs/ERRORS.md #13).
|
||||
/// NSTextField kann das nicht alles abschalten, daher NSTextView-Basis.
|
||||
/// Secure-Modus nutzt NSSecureTextField (per Design ohne Ersetzungen).
|
||||
struct TerminalInputField: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
var isSecure: Bool
|
||||
var onSubmit: () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(text: $text, onSubmit: onSubmit)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
if isSecure {
|
||||
let f = NSSecureTextField()
|
||||
f.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
|
||||
f.textColor = Dracula.nsColor(for: nil)
|
||||
f.backgroundColor = .clear
|
||||
f.isBordered = false
|
||||
f.focusRingType = .none
|
||||
f.delegate = context.coordinator
|
||||
return f
|
||||
} else {
|
||||
let tv = NSTextView()
|
||||
Self.configure(tv)
|
||||
tv.delegate = context.coordinator
|
||||
return tv
|
||||
}
|
||||
}
|
||||
|
||||
func updateNSView(_ view: NSView, context: Context) {
|
||||
context.coordinator.onSubmit = onSubmit
|
||||
// Delegat während Programm-Sets abhängen: sonst feuert textDidChange synchron
|
||||
// zurück ins SwiftUI-State — mitten im Render -> Crash ("Modifying state
|
||||
// during view update"). User-Tippen läuft weiter über den Delegaten.
|
||||
if let f = view as? NSTextField {
|
||||
if f.stringValue != text {
|
||||
f.delegate = nil
|
||||
f.stringValue = text
|
||||
f.delegate = context.coordinator
|
||||
}
|
||||
} else if let tv = view as? NSTextView, tv.string != text {
|
||||
tv.delegate = nil
|
||||
tv.string = text
|
||||
tv.delegate = context.coordinator
|
||||
}
|
||||
}
|
||||
|
||||
/// Alle Ersetzungen aus. Auch als Harness-Test nutzbar (reine Flags, headless).
|
||||
static func configure(_ tv: NSTextView) {
|
||||
tv.isRichText = false
|
||||
tv.importsGraphics = false
|
||||
tv.isAutomaticQuoteSubstitutionEnabled = false
|
||||
tv.isAutomaticDashSubstitutionEnabled = false
|
||||
tv.isAutomaticTextCompletionEnabled = false
|
||||
tv.isAutomaticSpellingCorrectionEnabled = false
|
||||
tv.isContinuousSpellCheckingEnabled = false
|
||||
tv.isAutomaticDataDetectionEnabled = false
|
||||
tv.isAutomaticLinkDetectionEnabled = false
|
||||
tv.isAutomaticTextReplacementEnabled = false
|
||||
tv.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
|
||||
tv.textColor = Dracula.nsColor(for: nil)
|
||||
tv.backgroundColor = .clear
|
||||
tv.drawsBackground = false
|
||||
tv.isVerticallyResizable = false
|
||||
tv.isHorizontallyResizable = true
|
||||
tv.textContainer?.widthTracksTextView = false
|
||||
tv.textContainer?.containerSize = NSSize(width: 100000, height: 24)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, NSTextFieldDelegate, NSTextViewDelegate {
|
||||
var text: Binding<String>
|
||||
var onSubmit: () -> Void
|
||||
|
||||
init(text: Binding<String>, onSubmit: @escaping () -> Void) {
|
||||
self.text = text
|
||||
self.onSubmit = onSubmit
|
||||
}
|
||||
|
||||
func controlTextDidChange(_ obj: Notification) {
|
||||
if let f = obj.object as? NSTextField {
|
||||
text.wrappedValue = f.stringValue
|
||||
}
|
||||
}
|
||||
|
||||
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
|
||||
onSubmit()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
if let tv = notification.object as? NSTextView {
|
||||
// Einzeiler: harte Umbrüche sofort entfernen (Paste mit \n)
|
||||
let single = tv.string.replacingOccurrences(of: "\n", with: "")
|
||||
if single != tv.string { tv.string = single }
|
||||
text.wrappedValue = single
|
||||
}
|
||||
}
|
||||
|
||||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
|
||||
onSubmit()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user