feat(T-2.3): TerminalView UIKit wrapper, theme store, font store

This commit is contained in:
jay 2026-05-15 18:26:12 +02:00
parent f6396bc70e
commit 49667667eb
6 changed files with 616 additions and 0 deletions

View File

@ -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 832 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)
}
}

View File

@ -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)
}()
}

View File

@ -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 00 and 25565535.
// 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 015) representing
/// the standard ANSI palette: 07 normal, 815 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 07 are the standard colors, 815 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 (065535). Multiplying each 8-bit
/// component by 257 (0x101) gives an exact linear mapping: 00, 25565535.
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),
]
)
}

View File

@ -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<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)?
// 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<UInt8>) {
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<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
)
}
}

View File

@ -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:).
}
}

View File

@ -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)
}
}