// TerminalViewController.swift // UIKit view-controller hosting a SwiftTerm TerminalView. // // SwiftTerm API discoveries (T-2.3): // • TerminalView is UIScrollView (not UIView) — autolayout fill works the same way. // • feed(byteArray: ArraySlice) ingests ANSI bytes; can be called from any thread. // • feed(text: String) is the string variant. // • installColors([SwiftTerm.Color]) sets the 16-color ANSI palette. // • nativeForegroundColor / nativeBackgroundColor are UIColor properties on TerminalView. // • font (UIFont) property drives the rendered glyph size and triggers a resize. // • getTerminal() → Terminal gives access to .cols / .rows. // • Keyboard input flows OUT through TerminalViewDelegate.send(source:data:). // • TerminalViewDelegate is not @MainActor; delegate methods are called on the main // thread by SwiftTerm's UIKit layer. We use MainActor.assumeIsolated to bridge // the isolation gap in Swift 6 strict-concurrency mode. import UIKit import SwiftTerm @MainActor public final class TerminalViewController: UIViewController { // MARK: Terminal view /// The underlying SwiftTerm view. Available after `viewDidLoad`. public private(set) var terminalView: TerminalView! // MARK: Callbacks /// Called whenever the user types into the terminal — byte data to be /// forwarded to the remote PTY (wired by T-2.5). public var onInput: ((Data) -> Void)? /// Called when SwiftTerm recalculates its column/row count (layout, /// font change, rotation). Forward this to the sidecar so tmux resizes. public var onResize: ((Int, Int) -> Void)? // MARK: View lifecycle public override func viewDidLoad() { super.viewDidLoad() let activeTheme = ThemeStore.shared.current let activeFont = FontStore.shared.scaledFont // Create the terminal view with the current font. // TerminalView(frame:font:) is the designated initialiser on iOS. let tv = TerminalView(frame: .zero, font: activeFont) tv.terminalDelegate = self tv.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tv) NSLayoutConstraint.activate([ tv.topAnchor.constraint(equalTo: view.topAnchor), tv.leadingAnchor.constraint(equalTo: view.leadingAnchor), tv.trailingAnchor.constraint(equalTo: view.trailingAnchor), tv.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) terminalView = tv // Apply the active theme so the view renders with correct colours from // the very first frame, before any data arrives. apply(theme: activeTheme) } // MARK: Data ingestion /// Feed raw ANSI bytes into the terminal emulator. /// SwiftTerm's `feed(byteArray:)` is thread-safe; this wrapper is /// intentionally @MainActor so callers remain on the main actor. public func feed(data: Data) { guard let tv = terminalView else { return } let bytes = [UInt8](data) tv.feed(byteArray: bytes[...]) } /// Feed a base64-encoded ANSI snapshot (e.g. from the sidecar's /// snapshot frame) into the terminal emulator. public func feedSnapshot(base64: String) { guard let data = Data(base64Encoded: base64, options: .ignoreUnknownCharacters) else { return // malformed input — ignore silently } feed(data: data) } // MARK: Theme /// Apply a `TerminalTheme`, updating foreground/background colours and /// the full 16-colour ANSI palette. public func apply(theme: TerminalTheme) { guard let tv = terminalView else { return } // Foreground / background via UIColor properties. tv.nativeForegroundColor = UIColor(themeColor: theme.foreground) tv.nativeBackgroundColor = UIColor(themeColor: theme.background) // 16-colour ANSI palette via SwiftTerm's installColors API. tv.installColors(theme.swiftTermAnsiColors) } // MARK: Font /// Apply a `TerminalFont` at the current point size from `FontStore`. public func apply(font: TerminalFont) { guard let tv = terminalView else { return } tv.font = font.uiFont.withSize(FontStore.shared.size) } /// Apply a `TerminalFont` at an explicit point size. public func apply(font: TerminalFont, size: CGFloat) { guard let tv = terminalView else { return } tv.font = font.uiFont.withSize(size) } // MARK: Terminal dimensions /// Current terminal size in columns and rows. public var terminalSize: (cols: Int, rows: Int) { guard let tv = terminalView else { return (80, 24) } let t = tv.getTerminal() return (t.cols, t.rows) } } // MARK: - TerminalViewDelegate // TerminalViewDelegate is not @MainActor, but SwiftTerm always calls its // methods on the main thread (they're UIKit callbacks). We use // `MainActor.assumeIsolated` — a zero-cost annotation that asserts we're // already on the main actor — to satisfy the Swift 6 isolation checker. extension TerminalViewController: TerminalViewDelegate { // MARK: Required — keyboard input (terminal → host) public nonisolated func send(source: TerminalView, data: ArraySlice) { MainActor.assumeIsolated { onInput?(Data(data)) } } // MARK: Required — size change notification public nonisolated func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) { // Propagate to whoever manages the sidecar connection so they can // call `tmux resize-window` and keep line-wrapping in sync. MainActor.assumeIsolated { onResize?(newCols, newRows) } } // MARK: Required — title change public nonisolated func setTerminalTitle(source: TerminalView, title: String) { // No-op: future tasks may expose this via a published property. } // MARK: Required — working directory update public nonisolated func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) { // No-op for now. } // MARK: Required — scroll position public nonisolated func scrolled(source: TerminalView, position: Double) { // No-op for now. } // MARK: Required — hyperlink activation public nonisolated func requestOpenLink(source: TerminalView, link: String, params: [String: String]) { // Open URL on the main actor. MainActor.assumeIsolated { guard let url = URL(string: link) else { return } UIApplication.shared.open(url) } } // MARK: Required — bell public nonisolated func bell(source: TerminalView) { // Haptic feedback on bell — UIKit APIs require main thread. MainActor.assumeIsolated { let generator = UIImpactFeedbackGenerator(style: .light) generator.impactOccurred() } } // MARK: Optional — clipboard write (OSC 52) public nonisolated func clipboardCopy(source: TerminalView, content: Data) { MainActor.assumeIsolated { UIPasteboard.general.setData(content, forPasteboardType: "public.utf8-plain-text") } } // MARK: Optional — clipboard read (OSC 52 query) public nonisolated func clipboardRead(source: TerminalView) -> Data? { // Return nil to deny clipboard read requests for security. nil } // MARK: Optional — iTerm2 OSC 1337 unhandled content public nonisolated func iTermContent(source: TerminalView, content: ArraySlice) { // No-op: iTerm2 extensions are not supported in this client. } // MARK: Optional — range update notification public nonisolated func rangeChanged(source: TerminalView, startY: Int, endY: Int) { // No-op: notifyUpdateChanges is false (default); this won't be called. } } // MARK: - UIColor convenience private extension UIColor { /// Constructs a UIColor from an 8-bit `ThemeColor`. convenience init(themeColor c: ThemeColor) { self.init( red: CGFloat(c.r) / 255, green: CGFloat(c.g) / 255, blue: CGFloat(c.b) / 255, alpha: 1 ) } }