pi-remote-ios/Sources/UI/Terminal/TerminalViewController.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
)
}
}