239 lines
8.4 KiB
Swift
239 lines
8.4 KiB
Swift
// 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<UInt8>) 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<UInt8>) {
|
|
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<UInt8>) {
|
|
// 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
|
|
)
|
|
}
|
|
}
|