From 49667667eb898d920fdbc44edea7ef146cf65396 Mon Sep 17 00:00:00 2001 From: jay Date: Fri, 15 May 2026 18:26:12 +0200 Subject: [PATCH] feat(T-2.3): TerminalView UIKit wrapper, theme store, font store --- Sources/UI/Terminal/FontStore.swift | 67 +++++ Sources/UI/Terminal/TerminalFont.swift | 66 +++++ Sources/UI/Terminal/TerminalTheme.swift | 169 +++++++++++++ .../UI/Terminal/TerminalViewController.swift | 230 ++++++++++++++++++ .../Terminal/TerminalViewRepresentable.swift | 40 +++ Sources/UI/Terminal/ThemeStore.swift | 44 ++++ 6 files changed, 616 insertions(+) create mode 100644 Sources/UI/Terminal/FontStore.swift create mode 100644 Sources/UI/Terminal/TerminalFont.swift create mode 100644 Sources/UI/Terminal/TerminalTheme.swift create mode 100644 Sources/UI/Terminal/TerminalViewController.swift create mode 100644 Sources/UI/Terminal/TerminalViewRepresentable.swift create mode 100644 Sources/UI/Terminal/ThemeStore.swift diff --git a/Sources/UI/Terminal/FontStore.swift b/Sources/UI/Terminal/FontStore.swift new file mode 100644 index 0000000..172e555 --- /dev/null +++ b/Sources/UI/Terminal/FontStore.swift @@ -0,0 +1,67 @@ +// FontStore.swift +// Observable store that tracks the active terminal font and point size, and +// persists both values across launches via UserDefaults. + +import Foundation +import UIKit +import Combine + +private let kFontIdKey = "terminal.font" +private let kFontSizeKey = "terminal.fontSize" +private let kDefaultSize: CGFloat = 13 + +@MainActor +public final class FontStore: ObservableObject { + + // MARK: Singleton + + public static let shared = FontStore() + + // MARK: Published state + + @Published public private(set) var current: TerminalFont + @Published public private(set) var size: CGFloat + + // MARK: Available fonts (ordered: default first) + + public let available: [TerminalFont] = [.sfMono, .menlo, .jetBrainsMono] + + // MARK: Init + + private init() { + // Restore point size (clamped to a sane range). + let storedSize = UserDefaults.standard.object(forKey: kFontSizeKey) as? CGFloat + size = storedSize.map { max(8, min(32, $0)) } ?? kDefaultSize + + // Restore selected font id. + let all: [TerminalFont] = [.sfMono, .menlo, .jetBrainsMono] + if let savedId = UserDefaults.standard.string(forKey: kFontIdKey), + let saved = all.first(where: { $0.id == savedId }) { + current = saved + } else { + current = .sfMono + } + } + + // MARK: Public API + + /// Makes `font` the active font and persists the choice. + public func select(_ font: TerminalFont) { + current = font + UserDefaults.standard.set(font.id, forKey: kFontIdKey) + } + + /// Sets the point size (clamped to 8–32 pt) and persists it. + public func setSize(_ newSize: CGFloat) { + let clamped = max(8, min(32, newSize)) + size = clamped + UserDefaults.standard.set(clamped, forKey: kFontSizeKey) + } + + // MARK: Derived helpers + + /// Returns the current font scaled to the current point size. + public var scaledFont: UIFont { + current.uiFont.withSize(size) + } +} diff --git a/Sources/UI/Terminal/TerminalFont.swift b/Sources/UI/Terminal/TerminalFont.swift new file mode 100644 index 0000000..676e9cb --- /dev/null +++ b/Sources/UI/Terminal/TerminalFont.swift @@ -0,0 +1,66 @@ +// TerminalFont.swift +// Represents a named monospace font that can be applied to the terminal view. +// +// T-2.3 note: JetBrains Mono font file is NOT bundled yet — that is deferred +// to T-2.12. Until then, `jetBrainsMono` falls back to the system monospaced +// font so the struct is usable without crashing. + +import UIKit + +public struct TerminalFont: Identifiable, Sendable { + + // MARK: Properties + + public let id: String + public let displayName: String + + /// The `UIFont` at whatever point size was requested when the static + /// preset was created. `FontStore` applies its own `size` override via + /// `uiFont.withSize(_:)` before passing the font to `TerminalView`. + public let uiFont: UIFont + + // MARK: Init + + public init(id: String, displayName: String, uiFont: UIFont) { + self.id = id + self.displayName = displayName + self.uiFont = uiFont + } +} + +// MARK: - Static presets + +public extension TerminalFont { + + // MARK: JetBrains Mono + // Falls back to the system monospaced font when the font file is absent + // (i.e. before T-2.12 bundles it). Never crashes — safe for all builds. + + static let jetBrainsMono: TerminalFont = { + let baseSize: CGFloat = 13 + // Attempt to load the named font; fall back to the system mono font. + let font = UIFont(name: "JetBrainsMono-Regular", size: baseSize) + ?? UIFont.monospacedSystemFont(ofSize: baseSize, weight: .regular) + return TerminalFont(id: "jetbrains-mono", displayName: "JetBrains Mono", uiFont: font) + }() + + // MARK: Menlo — system monospaced, always available + + static let menlo: TerminalFont = { + let baseSize: CGFloat = 13 + // Menlo ships with iOS/macOS; fall back to the system mono if absent. + let font = UIFont(name: "Menlo-Regular", size: baseSize) + ?? UIFont.monospacedSystemFont(ofSize: baseSize, weight: .regular) + return TerminalFont(id: "menlo", displayName: "Menlo", uiFont: font) + }() + + // MARK: SF Mono — always available on iOS 13+ + + static let sfMono: TerminalFont = { + let baseSize: CGFloat = 13 + // "SFMono-Regular" is available as a named font on iOS 13+. + let font = UIFont(name: "SFMono-Regular", size: baseSize) + ?? UIFont.monospacedSystemFont(ofSize: baseSize, weight: .regular) + return TerminalFont(id: "sf-mono", displayName: "SF Mono", uiFont: font) + }() +} diff --git a/Sources/UI/Terminal/TerminalTheme.swift b/Sources/UI/Terminal/TerminalTheme.swift new file mode 100644 index 0000000..9f23379 --- /dev/null +++ b/Sources/UI/Terminal/TerminalTheme.swift @@ -0,0 +1,169 @@ +// TerminalTheme.swift +// Defines color themes for the terminal view. +// +// SwiftTerm API notes (discovered during T-2.3): +// • SwiftTerm.Color uses UInt16 components (0...65535). +// Conversion from 8-bit: multiply by 257 (= 0x101), which maps 0→0 and 255→65535. +// • installColors([SwiftTerm.Color]) installs the 16-color ANSI palette. +// • nativeForegroundColor / nativeBackgroundColor accept UIColor. + +import Foundation +import SwiftTerm + +// MARK: - ThemeColor + +/// A simple 8-bit RGB color that is Codable and Sendable. +public struct ThemeColor: Codable, Sendable, Equatable { + public let r: UInt8 + public let g: UInt8 + public let b: UInt8 + + public init(r: UInt8, g: UInt8, b: UInt8) { + self.r = r; self.g = g; self.b = b + } +} + +// MARK: - TerminalTheme + +/// A complete color theme for the terminal view. +/// +/// `ansiColors` must contain exactly 16 entries (indices 0–15) representing +/// the standard ANSI palette: 0–7 normal, 8–15 bright. +public struct TerminalTheme: Codable, Identifiable, Sendable, Equatable { + public let id: String + public let name: String + public let foreground: ThemeColor + public let background: ThemeColor + public let cursor: ThemeColor + /// 16 ANSI colors: indices 0–7 are the standard colors, 8–15 are the bright variants. + public let ansiColors: [ThemeColor] + + public init( + id: String, + name: String, + foreground: ThemeColor, + background: ThemeColor, + cursor: ThemeColor, + ansiColors: [ThemeColor] + ) { + precondition(ansiColors.count == 16, "ansiColors must contain exactly 16 entries") + self.id = id + self.name = name + self.foreground = foreground + self.background = background + self.cursor = cursor + self.ansiColors = ansiColors + } + + // MARK: SwiftTerm conversion + + /// Converts a `ThemeColor` to a `SwiftTerm.Color`. + /// + /// SwiftTerm.Color uses UInt16 components (0–65535). Multiplying each 8-bit + /// component by 257 (0x101) gives an exact linear mapping: 0→0, 255→65535. + public func toSwiftTermColor(_ c: ThemeColor) -> SwiftTerm.Color { + SwiftTerm.Color( + red: UInt16(c.r) * 257, + green: UInt16(c.g) * 257, + blue: UInt16(c.b) * 257 + ) + } + + /// Returns all 16 ANSI entries as `SwiftTerm.Color` values. + public var swiftTermAnsiColors: [SwiftTerm.Color] { + ansiColors.map { toSwiftTermColor($0) } + } +} + +// MARK: - Built-in themes + +public extension TerminalTheme { + + // MARK: dark — hacker/Matrix style: black bg, green fg + + static let dark = TerminalTheme( + id: "dark", + name: "Dark (Hacker)", + foreground: ThemeColor(r: 0x33, g: 0xFF, b: 0x33), // bright green + background: ThemeColor(r: 0x00, g: 0x00, b: 0x00), // pure black + cursor: ThemeColor(r: 0x33, g: 0xFF, b: 0x33), + ansiColors: [ + // 0 – black (normal) + ThemeColor(r: 0x00, g: 0x00, b: 0x00), + // 1 – red + ThemeColor(r: 0xCC, g: 0x00, b: 0x00), + // 2 – green + ThemeColor(r: 0x00, g: 0xCC, b: 0x00), + // 3 – yellow + ThemeColor(r: 0xCC, g: 0xCC, b: 0x00), + // 4 – blue + ThemeColor(r: 0x00, g: 0x00, b: 0xCC), + // 5 – magenta + ThemeColor(r: 0xCC, g: 0x00, b: 0xCC), + // 6 – cyan + ThemeColor(r: 0x00, g: 0xCC, b: 0xCC), + // 7 – white (normal) + ThemeColor(r: 0xCC, g: 0xCC, b: 0xCC), + // 8 – bright black (dark gray) + ThemeColor(r: 0x33, g: 0x33, b: 0x33), + // 9 – bright red + ThemeColor(r: 0xFF, g: 0x33, b: 0x33), + // 10 – bright green + ThemeColor(r: 0x33, g: 0xFF, b: 0x33), + // 11 – bright yellow + ThemeColor(r: 0xFF, g: 0xFF, b: 0x33), + // 12 – bright blue + ThemeColor(r: 0x33, g: 0x33, b: 0xFF), + // 13 – bright magenta + ThemeColor(r: 0xFF, g: 0x33, b: 0xFF), + // 14 – bright cyan + ThemeColor(r: 0x33, g: 0xFF, b: 0xFF), + // 15 – bright white + ThemeColor(r: 0xFF, g: 0xFF, b: 0xFF), + ] + ) + + // MARK: github — GitHub Dark theme + + static let github = TerminalTheme( + id: "github", + name: "GitHub Dark", + foreground: ThemeColor(r: 0xE6, g: 0xED, b: 0xF3), // #e6edf3 + background: ThemeColor(r: 0x0D, g: 0x11, b: 0x17), // #0d1117 + cursor: ThemeColor(r: 0xE6, g: 0xED, b: 0xF3), + ansiColors: [ + // 0 – black + ThemeColor(r: 0x48, g: 0x4F, b: 0x58), + // 1 – red + ThemeColor(r: 0xFF, g: 0x77, b: 0x77), + // 2 – green + ThemeColor(r: 0x56, g: 0xD3, b: 0x64), + // 3 – yellow + ThemeColor(r: 0xE3, g: 0xB3, b: 0x41), + // 4 – blue + ThemeColor(r: 0x6C, g: 0xA4, b: 0xF8), + // 5 – magenta + ThemeColor(r: 0xBC, g: 0x8C, b: 0xFF), + // 6 – cyan + ThemeColor(r: 0x2B, g: 0x73, b: 0x89), // #2b7389 + // 7 – white + ThemeColor(r: 0xE6, g: 0xED, b: 0xF3), + // 8 – bright black + ThemeColor(r: 0x6E, g: 0x76, b: 0x81), + // 9 – bright red + ThemeColor(r: 0xFF, g: 0xA1, b: 0x98), + // 10 – bright green + ThemeColor(r: 0x3F, g: 0xB9, b: 0x50), + // 11 – bright yellow + ThemeColor(r: 0xD2, g: 0x9C, b: 0x22), + // 12 – bright blue + ThemeColor(r: 0x79, g: 0xC0, b: 0xFF), + // 13 – bright magenta + ThemeColor(r: 0xD2, g: 0xA8, b: 0xFF), + // 14 – bright cyan + ThemeColor(r: 0x39, g: 0xC5, b: 0xCF), + // 15 – bright white + ThemeColor(r: 0xFF, g: 0xFF, b: 0xFF), + ] + ) +} diff --git a/Sources/UI/Terminal/TerminalViewController.swift b/Sources/UI/Terminal/TerminalViewController.swift new file mode 100644 index 0000000..d23adad --- /dev/null +++ b/Sources/UI/Terminal/TerminalViewController.swift @@ -0,0 +1,230 @@ +// 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)? + + // 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) { + let bytes = [UInt8](data) + terminalView.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) { + // No-op in T-2.3; the stream layer (T-2.5) will negotiate PTY size + // with the sidecar when it subscribes to this controller. + } + + // 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 + ) + } +} diff --git a/Sources/UI/Terminal/TerminalViewRepresentable.swift b/Sources/UI/Terminal/TerminalViewRepresentable.swift new file mode 100644 index 0000000..3d4d844 --- /dev/null +++ b/Sources/UI/Terminal/TerminalViewRepresentable.swift @@ -0,0 +1,40 @@ +// TerminalViewRepresentable.swift +// SwiftUI bridge that wraps TerminalViewController for use inside SwiftUI view trees. +// +// Usage: +// let controller = TerminalViewController() +// TerminalViewRepresentable(controller: controller) +// .ignoresSafeArea() + +import SwiftUI + +/// A SwiftUI-compatible wrapper around `TerminalViewController`. +/// +/// The `controller` is created and owned externally (typically by a +/// parent view or view-model) so that its `feed(data:)` and +/// `apply(theme:)` APIs remain accessible outside the SwiftUI tree. +public struct TerminalViewRepresentable: UIViewControllerRepresentable { + + // MARK: Properties + + /// The controller to host. Must be created before this representable is inserted. + public let controller: TerminalViewController + + // MARK: Init + + public init(controller: TerminalViewController) { + self.controller = controller + } + + // MARK: UIViewControllerRepresentable + + public func makeUIViewController(context: Context) -> TerminalViewController { + controller + } + + public func updateUIViewController(_ uiViewController: TerminalViewController, + context: Context) { + // No-op for now: theme and font updates are applied imperatively via + // controller.apply(theme:) and controller.apply(font:). + } +} diff --git a/Sources/UI/Terminal/ThemeStore.swift b/Sources/UI/Terminal/ThemeStore.swift new file mode 100644 index 0000000..fe6955d --- /dev/null +++ b/Sources/UI/Terminal/ThemeStore.swift @@ -0,0 +1,44 @@ +// ThemeStore.swift +// Observable store that tracks the active terminal theme and persists the +// selection across launches via UserDefaults. + +import Foundation +import Combine + +private let kThemeKey = "terminal.theme" + +@MainActor +public final class ThemeStore: ObservableObject { + + // MARK: Singleton + + public static let shared = ThemeStore() + + // MARK: Published state + + @Published public private(set) var current: TerminalTheme + + // MARK: Available themes (ordered: default first) + + public let available: [TerminalTheme] = [.dark, .github] + + // MARK: Init + + private init() { + // Restore previously selected theme id from UserDefaults. + if let savedId = UserDefaults.standard.string(forKey: kThemeKey), + let saved = [TerminalTheme.dark, .github].first(where: { $0.id == savedId }) { + current = saved + } else { + current = .dark + } + } + + // MARK: Public API + + /// Makes `theme` the active theme and persists the choice. + public func select(_ theme: TerminalTheme) { + current = theme + UserDefaults.standard.set(theme.id, forKey: kThemeKey) + } +}