1import ExpoModulesCore
2import Foundation
3import GhosttyKit
4import QuartzCore
5import UIKit
6
7private enum GhosttyRuntime {
8 private static let lock = NSLock()
9 private static var initialized = false
10
11 static func ensureInitialized() -> Bool {
12 lock.lock()
13 defer { lock.unlock() }
14
15 if initialized {
16 return true
17 }
18
19 let result = ghostty_init(0, nil)
20 initialized = result == GHOSTTY_SUCCESS
21 return initialized
22 }
23}
24
25/// Encodes hardware-keyboard combos that UITextField never surfaces through its
26/// text-editing delegate (control combos, Escape, Tab, arrow keys) into the byte
27/// sequences a terminal expects.
28///
29/// Capture uses UIKeyCommand with `wantsPriorityOverSystemBehavior` rather than
30/// `pressesBegan`: while a text field is first responder, iPadOS routes hardware key
31/// events through the text-input system, which can consume presses before they reach
32/// responder press callbacks. Registered key commands are matched deterministically
33/// before that happens.
34private enum TerminalHardwareKeyEncoder {
35 /// Characters that produce a control byte when combined with Ctrl.
36 private static let controlInputs = "abcdefghijklmnopqrstuvwxyz@[\\]^_-? "
37
38 static func makeKeyCommands(action: Selector) -> [UIKeyCommand] {
39 var commands: [UIKeyCommand] = []
40
41 let specialInputs = [
42 UIKeyCommand.inputEscape,
43 UIKeyCommand.inputUpArrow,
44 UIKeyCommand.inputDownArrow,
45 UIKeyCommand.inputLeftArrow,
46 UIKeyCommand.inputRightArrow,
47 "\t",
48 ]
49 for input in specialInputs {
50 commands.append(makeCommand(input: input, modifierFlags: [], action: action))
51 }
52 commands.append(makeCommand(input: "\t", modifierFlags: .shift, action: action))
53
54 for character in controlInputs {
55 commands.append(makeCommand(input: String(character), modifierFlags: .control, action: action))
56 commands.append(
57 makeCommand(input: String(character), modifierFlags: [.control, .shift], action: action)
58 )
59 }
60
61 return commands
62 }
63
64 private static func makeCommand(
65 input: String,
66 modifierFlags: UIKeyModifierFlags,
67 action: Selector
68 ) -> UIKeyCommand {
69 let command = UIKeyCommand(input: input, modifierFlags: modifierFlags, action: action)
70 command.wantsPriorityOverSystemBehavior = true
71 return command
72 }
73
74 static func sequence(input: String, modifiers: UIKeyModifierFlags) -> String? {
75 switch input {
76 case UIKeyCommand.inputEscape:
77 return "\u{1B}"
78 case UIKeyCommand.inputUpArrow:
79 return "\u{1B}[A"
80 case UIKeyCommand.inputDownArrow:
81 return "\u{1B}[B"
82 case UIKeyCommand.inputRightArrow:
83 return "\u{1B}[C"
84 case UIKeyCommand.inputLeftArrow:
85 return "\u{1B}[D"
86 case "\t":
87 return modifiers.contains(.shift) ? "\u{1B}[Z" : "\t"
88 default:
89 break
90 }
91
92 guard modifiers.contains(.control) else { return nil }
93 guard let scalar = input.lowercased().unicodeScalars.first else { return nil }
94 return controlSequence(for: scalar)
95 }
96
97 private static func controlSequence(for scalar: Unicode.Scalar) -> String? {
98 switch scalar {
99 case "a"..."z":
100 // Ctrl+A..Z -> 0x01..0x1A (Ctrl+C = ETX, Ctrl+Z = SUB, ...).
101 return UnicodeScalar(scalar.value - 96).map(String.init)
102 case " ", "@":
103 return "\u{00}"
104 case "[":
105 return "\u{1B}"
106 case "\\":
107 return "\u{1C}"
108 case "]":
109 return "\u{1D}"
110 case "^":
111 return "\u{1E}"
112 case "_", "-":
113 return "\u{1F}"
114 case "?":
115 return "\u{7F}"
116 default:
117 return nil
118 }
119 }
120}
121
122private enum TerminalInputSequence {
123 /// Terminal Enter is carriage return. Sending line feed instead is Ctrl+J,
124 /// which raw-mode TUIs may interpret as the literal J key.
125 static let carriageReturn = "\r"
126
127 static func normalizingReturn(_ input: String) -> String {
128 switch input {
129 case "\n", "\r\n":
130 return carriageReturn
131 default:
132 return input
133 }
134 }
135}
136
137private final class TerminalInputField: UITextField {
138 var onDeleteBackward: (() -> Void)?
139 var onInsert: ((String) -> Void)?
140
141 private static let hardwareKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands(
142 action: #selector(handleHardwareKeyCommand(_:))
143 )
144
145 override var keyCommands: [UIKeyCommand]? {
146 Self.hardwareKeyCommands
147 }
148
149 override func deleteBackward() {
150 onDeleteBackward?()
151 super.deleteBackward()
152 }
153
154 @objc
155 private func handleHardwareKeyCommand(_ command: UIKeyCommand) {
156 guard let input = command.input else { return }
157 guard let sequence = TerminalHardwareKeyEncoder.sequence(
158 input: input,
159 modifiers: command.modifierFlags
160 ) else { return }
161 onInsert?(sequence)
162 }
163}
164
165private enum TerminalAppearanceScheme: String {
166 case light
167 case dark
168
169 init(value: String) {
170 self = TerminalAppearanceScheme(rawValue: value) ?? .dark
171 }
172
173 var ghosttyColorScheme: ghostty_color_scheme_e {
174 switch self {
175 case .light:
176 return GHOSTTY_COLOR_SCHEME_LIGHT
177 case .dark:
178 return GHOSTTY_COLOR_SCHEME_DARK
179 }
180 }
181}
182
183private extension UIColor {
184 convenience init(hexString: String) {
185 let sanitized = hexString.replacingOccurrences(of: "#", with: "")
186 let value = Int(sanitized, radix: 16) ?? 0
187 self.init(
188 red: CGFloat((value >> 16) & 0xFF) / 255,
189 green: CGFloat((value >> 8) & 0xFF) / 255,
190 blue: CGFloat(value & 0xFF) / 255,
191 alpha: 1
192 )
193 }
194}
195
196public final class T3TerminalView: ExpoView, UITextFieldDelegate {
197 private static let minimumVerticalScrollStepPoints: CGFloat = 18
198 private static let verticalScrollStepMultiplier: CGFloat = 1.15
199
200 private let terminalViewport = UIView()
201 private let inputField = TerminalInputField()
202 private let focusTapGesture = UITapGestureRecognizer()
203 private let scrollPanGesture = UIPanGestureRecognizer()
204 private var lastViewportSize: CGSize = .zero
205 private var lastContentScale: CGFloat = 0
206 private var lastReportedGrid: (cols: Int, rows: Int)?
207 private var lastAppliedBuffer = ""
208 private var pendingVerticalScrollPoints: CGFloat = 0
209 private var app: ghostty_app_t?
210 private var surface: ghostty_surface_t?
211 private var isCreatingSurface = false
212 private var surfaceCreationFailed = false
213 private var appearance = TerminalAppearanceScheme.dark
214 private var backgroundColorValue = UIColor(hexString: "#24292e")
215
216 let onInput = EventDispatcher()
217 let onResize = EventDispatcher()
218 let onCapture = EventDispatcher()
219 var captureRequest: Double = 0 {
220 didSet {
221 guard captureRequest > 0, captureRequest != oldValue else { return }
222 guard let surface else { onCapture(["text": ""]); return }
223 let selection = ghostty_selection_s(
224 top_left: ghostty_point_s(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0),
225 bottom_right: ghostty_point_s(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, x: 0, y: 0),
226 rectangle: false)
227 var captured = ghostty_text_s()
228 guard ghostty_surface_read_text(surface, selection, &captured) else { onCapture(["text": ""]); return }
229 defer { ghostty_surface_free_text(surface, &captured) }
230 let text = captured.text.flatMap { String(bytes: UnsafeBufferPointer(start: UnsafeRawPointer($0).assumingMemoryBound(to: UInt8.self), count: Int(captured.text_len)), encoding: .utf8) } ?? ""
231 // Android joins snapshot rows with "\n" and trims each row's trailing whitespace, so do
232 // the same here: identical terminal content must capture identically on both platforms.
233 let normalized = text.split(separator: "\n", omittingEmptySubsequences: false)
234 .map { row -> String in
235 var line = String(row)
236 // Kotlin's trimEnd only strips ASCII whitespace; match it exactly.
237 while let last = line.last, last.isASCII && last.isWhitespace { line.removeLast() }
238 return line
239 }
240 .joined(separator: "\n")
241 onCapture(["text": normalized])
242 }
243 }
244
245 var terminalKey: String = "" {
246 didSet {
247 accessibilityIdentifier = "t3-terminal-\(terminalKey)"
248 if oldValue != terminalKey {
249 resetSurface()
250 }
251 }
252 }
253
254 var initialBuffer: String = "" {
255 didSet {
256 applyRemoteBuffer(initialBuffer)
257 }
258 }
259
260 var fontSize: CGFloat = 10 {
261 didSet {
262 guard oldValue != fontSize else { return }
263 inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular)
264 refreshSurface()
265 }
266 }
267
268 var focusRequest: Double = 0 {
269 didSet {
270 guard oldValue != focusRequest else { return }
271 DispatchQueue.main.async { [weak self] in
272 self?.requestKeyboardFocus()
273 }
274 }
275 }
276
277 var readOnly = false {
278 didSet {
279 if readOnly {
280 inputField.resignFirstResponder()
281 }
282 }
283 }
284
285 var autoFocus = true {
286 didSet {
287 guard oldValue != autoFocus else { return }
288 if autoFocus {
289 requestKeyboardFocus()
290 } else {
291 inputField.resignFirstResponder()
292 }
293 }
294 }
295
296 var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {
297 didSet {
298 guard oldValue != appearanceScheme else { return }
299 appearance = TerminalAppearanceScheme(value: appearanceScheme)
300 refreshSurface()
301 }
302 }
303
304 var themeConfig: String = "" {
305 didSet {
306 guard oldValue != themeConfig else { return }
307 refreshSurface()
308 }
309 }
310
311 var backgroundColorHex: String = "#24292e" {
312 didSet {
313 backgroundColorValue = UIColor(hexString: backgroundColorHex)
314 applyTheme()
315 }
316 }
317
318 var foregroundColorHex: String = "#d1d5da"
319 var mutedForegroundColorHex: String = "#959da5"
320
321 public required init(appContext: AppContext? = nil) {
322 super.init(appContext: appContext)
323
324 applyTheme()
325 clipsToBounds = true
326 // The real scale arrives with the window; layoutSubviews corrects it.
327 contentScaleFactor = traitCollection.displayScale
328
329 terminalViewport.clipsToBounds = true
330 terminalViewport.contentScaleFactor = contentScaleFactor
331 terminalViewport.translatesAutoresizingMaskIntoConstraints = false
332 terminalViewport.isUserInteractionEnabled = true
333
334 inputField.delegate = self
335 inputField.backgroundColor = UIColor.clear
336 inputField.textColor = UIColor.clear
337 inputField.tintColor = UIColor.clear
338 inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular)
339 inputField.placeholder = ""
340 inputField.autocorrectionType = .no
341 inputField.autocapitalizationType = .none
342 inputField.spellCheckingType = .no
343 inputField.smartDashesType = .no
344 inputField.smartQuotesType = .no
345 inputField.returnKeyType = .send
346 inputField.keyboardType = .asciiCapable
347 inputField.enablesReturnKeyAutomatically = false
348 inputField.translatesAutoresizingMaskIntoConstraints = false
349 inputField.alpha = 0.02
350 inputField.isAccessibilityElement = false
351 inputField.accessibilityElementsHidden = true
352 inputField.addTarget(self, action: #selector(handleInputEditingDidBegin), for: .editingDidBegin)
353 inputField.onDeleteBackward = { [weak self] in
354 self?.emitInput("\u{7F}")
355 }
356 inputField.onInsert = { [weak self] data in
357 self?.emitInput(data)
358 }
359
360 focusTapGesture.addTarget(self, action: #selector(handleViewportTap))
361 terminalViewport.addGestureRecognizer(focusTapGesture)
362 scrollPanGesture.addTarget(self, action: #selector(handleViewportPan(_:)))
363 scrollPanGesture.maximumNumberOfTouches = 1
364 scrollPanGesture.cancelsTouchesInView = false
365 terminalViewport.addGestureRecognizer(scrollPanGesture)
366
367 addSubview(terminalViewport)
368 addSubview(inputField)
369
370 NSLayoutConstraint.activate([
371 terminalViewport.leadingAnchor.constraint(equalTo: leadingAnchor),
372 terminalViewport.trailingAnchor.constraint(equalTo: trailingAnchor),
373 terminalViewport.topAnchor.constraint(equalTo: topAnchor),
374 terminalViewport.bottomAnchor.constraint(equalTo: bottomAnchor),
375
376 inputField.trailingAnchor.constraint(equalTo: trailingAnchor),
377 inputField.topAnchor.constraint(equalTo: bottomAnchor, constant: 8),
378 inputField.widthAnchor.constraint(equalToConstant: 1),
379 inputField.heightAnchor.constraint(equalToConstant: 1),
380 ])
381 }
382
383 deinit {
384 destroySurface()
385 }
386
387 public override func layoutSubviews() {
388 super.layoutSubviews()
389 updateContentScale()
390
391 let viewportSize = terminalViewport.bounds.size
392 if surface == nil {
393 createSurfaceIfPossible()
394 }
395
396 guard viewportSize != lastViewportSize || contentScaleFactor != lastContentScale else {
397 return
398 }
399
400 lastViewportSize = viewportSize
401 lastContentScale = contentScaleFactor
402 resizeSurface()
403 }
404
405 public override func didMoveToWindow() {
406 super.didMoveToWindow()
407
408 guard window != nil, autoFocus else { return }
409 DispatchQueue.main.async { [weak self] in
410 self?.requestKeyboardFocus()
411 }
412 }
413
414 public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
415 if !string.isEmpty {
416 // Some software keyboards deliver Return through this delegate instead of
417 // textFieldShouldReturn, so normalize that path too.
418 emitInput(TerminalInputSequence.normalizingReturn(string))
419 return false
420 }
421
422 emitInput("\u{7F}")
423 return false
424 }
425
426 public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
427 emitInput(TerminalInputSequence.carriageReturn)
428 textField.text = ""
429 return false
430 }
431
432 @objc
433 private func handleViewportTap() {
434 requestKeyboardFocus()
435 }
436
437 @objc
438 private func handleViewportPan(_ gesture: UIPanGestureRecognizer) {
439 guard let surface else { return }
440
441 let location = gesture.location(in: terminalViewport)
442 ghostty_surface_mouse_pos(
443 surface,
444 Double(location.x * contentScaleFactor),
445 Double(location.y * contentScaleFactor),
446 GHOSTTY_MODS_NONE
447 )
448
449 switch gesture.state {
450 case .began:
451 pendingVerticalScrollPoints = 0
452 gesture.setTranslation(.zero, in: terminalViewport)
453 case .changed:
454 let translation = gesture.translation(in: terminalViewport)
455 let stepSize = max(
456 fontSize * Self.verticalScrollStepMultiplier,
457 Self.minimumVerticalScrollStepPoints
458 )
459 let totalVerticalPoints = pendingVerticalScrollPoints + translation.y
460 let verticalSteps = Int(totalVerticalPoints / stepSize)
461 pendingVerticalScrollPoints = totalVerticalPoints - (CGFloat(verticalSteps) * stepSize)
462
463 guard verticalSteps != 0 else {
464 gesture.setTranslation(.zero, in: terminalViewport)
465 return
466 }
467
468 ghostty_surface_mouse_scroll(surface, 0, Double(verticalSteps), 0)
469 redrawSurface()
470 gesture.setTranslation(.zero, in: terminalViewport)
471 default:
472 pendingVerticalScrollPoints = 0
473 gesture.setTranslation(.zero, in: terminalViewport)
474 }
475 }
476
477 @objc
478 private func handleInputEditingDidBegin() {
479 textInputModeDidChange()
480 }
481
482 private func createSurfaceIfPossible() {
483 guard surface == nil, app == nil, !isCreatingSurface, !surfaceCreationFailed else { return }
484 guard terminalViewport.bounds.width > 0, terminalViewport.bounds.height > 0 else { return }
485 guard GhosttyRuntime.ensureInitialized() else {
486 surfaceCreationFailed = true
487 return
488 }
489
490 isCreatingSurface = true
491 defer { isCreatingSurface = false }
492
493 var runtimeConfig = ghostty_runtime_config_s(
494 userdata: Unmanaged.passUnretained(self).toOpaque(),
495 supports_selection_clipboard: false,
496 wakeup_cb: { _ in },
497 action_cb: { _, _, _ in false },
498 read_clipboard_cb: { _, _, _, _, _, _ in GHOSTTY_CLIPBOARD_READ_UNSUPPORTED },
499 confirm_read_clipboard_cb: { _, _, _, _ in },
500 write_clipboard_cb: { _, _, _, _, _ in },
501 close_surface_cb: { _, _ in }
502 )
503
504 guard let config = ghostty_config_new() else {
505 surfaceCreationFailed = true
506 return
507 }
508 loadThemeConfig(into: config)
509 ghostty_config_finalize(config)
510 defer { ghostty_config_free(config) }
511
512 guard let createdApp = ghostty_app_new(&runtimeConfig, config) else {
513 surfaceCreationFailed = true
514 return
515 }
516
517 var surfaceConfig = ghostty_surface_config_new()
518 surfaceConfig.platform_tag = GHOSTTY_PLATFORM_IOS
519 surfaceConfig.platform.ios.uiview = Unmanaged.passUnretained(terminalViewport).toOpaque()
520 surfaceConfig.userdata = Unmanaged.passUnretained(self).toOpaque()
521 surfaceConfig.scale_factor = Double(contentScaleFactor)
522 surfaceConfig.font_size = Float(fontSize)
523 surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_WINDOW
524 surfaceConfig.use_custom_io = true
525
526 guard let createdSurface = ghostty_surface_new(createdApp, &surfaceConfig) else {
527 ghostty_app_free(createdApp)
528 surfaceCreationFailed = true
529 return
530 }
531
532 app = createdApp
533 surface = createdSurface
534 ghostty_app_set_color_scheme(createdApp, appearance.ghosttyColorScheme)
535 ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme)
536 setupWriteCallback()
537 resizeSurface()
538 feedBuffer(initialBuffer)
539 }
540
541 private func resetSurface() {
542 destroySurface()
543 lastAppliedBuffer = ""
544 lastViewportSize = .zero
545 lastContentScale = 0
546 lastReportedGrid = nil
547 surfaceCreationFailed = false
548 setNeedsLayout()
549 }
550
551 private func refreshSurface() {
552 resetSurface()
553 createSurfaceIfPossible()
554 }
555
556 private func destroySurface() {
557 if let surface {
558 ghostty_surface_set_write_callback(surface, nil, nil)
559 ghostty_surface_free(surface)
560 }
561 if let app {
562 ghostty_app_free(app)
563 }
564 surface = nil
565 app = nil
566 }
567
568 private func applyRemoteBuffer(_ buffer: String) {
569 guard surface != nil else {
570 createSurfaceIfPossible()
571 return
572 }
573
574 if buffer.isEmpty {
575 feedData(Data("\u{1B}[3J\u{1B}[H\u{1B}[2J".utf8))
576 lastAppliedBuffer = ""
577 return
578 }
579
580 if buffer.hasPrefix(lastAppliedBuffer) {
581 let suffix = String(buffer.dropFirst(lastAppliedBuffer.count))
582 feedData(Data(suffix.utf8))
583 lastAppliedBuffer = buffer
584 return
585 }
586
587 resetSurface()
588 createSurfaceIfPossible()
589 }
590
591 private func feedBuffer(_ buffer: String) {
592 guard !buffer.isEmpty else { return }
593 feedData(Data(buffer.utf8))
594 lastAppliedBuffer = buffer
595 }
596
597 private func feedData(_ data: Data) {
598 guard let surface, !data.isEmpty else { return }
599
600 data.withUnsafeBytes { buffer in
601 guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
602 return
603 }
604 ghostty_surface_feed_data(surface, pointer, buffer.count)
605 }
606
607 redrawSurface()
608 }
609
610 private func setupWriteCallback() {
611 guard let surface else { return }
612
613 let userdata = Unmanaged.passUnretained(self).toOpaque()
614 ghostty_surface_set_write_callback(surface, { userdata, data, len in
615 guard let userdata, let data, len > 0 else { return }
616 let view = Unmanaged<T3TerminalView>.fromOpaque(userdata).takeUnretainedValue()
617 let bytes = Data(bytes: data, count: len)
618 guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return }
619
620 DispatchQueue.main.async {
621 view.emitInput(input)
622 }
623 }, userdata)
624 }
625
626 private func resizeSurface() {
627 guard let surface else {
628 emitEstimatedResize()
629 return
630 }
631
632 let scale = contentScaleFactor
633 let width = UInt32(max(floor(terminalViewport.bounds.width * scale), 1))
634 let height = UInt32(max(floor(terminalViewport.bounds.height * scale), 1))
635
636 terminalViewport.contentScaleFactor = scale
637 ghostty_surface_set_content_scale(surface, Double(scale), Double(scale))
638 ghostty_surface_set_size(surface, width, height)
639 ghostty_surface_set_occlusion(surface, window != nil)
640 configureIOSurfaceLayers()
641 redrawSurface()
642 emitGhosttyResize()
643 }
644
645 private func redrawSurface() {
646 guard let surface else { return }
647 ghostty_surface_refresh(surface)
648 ghostty_surface_draw(surface)
649 markIOSurfaceLayersForDisplay()
650 emitGhosttyResize()
651 }
652
653 private func emitGhosttyResize() {
654 guard let surface else {
655 emitEstimatedResize()
656 return
657 }
658
659 let size = ghostty_surface_size(surface)
660 let cols = max(1, Int(size.columns))
661 let rows = max(1, Int(size.rows))
662 emitResize(cols: cols, rows: rows)
663 }
664
665 private func emitEstimatedResize() {
666 guard bounds.width > 0, bounds.height > 0 else { return }
667
668 let cellWidth = max(fontSize * 0.62, 1)
669 let cellHeight = max(fontSize * 1.35, 1)
670 let cols = max(20, min(400, Int(bounds.width / cellWidth)))
671 let terminalHeight = max(bounds.height, 0)
672 let rows = max(5, min(200, Int(terminalHeight / cellHeight)))
673 emitResize(cols: cols, rows: rows)
674 }
675
676 private func emitResize(cols: Int, rows: Int) {
677 guard lastReportedGrid?.cols != cols || lastReportedGrid?.rows != rows else {
678 return
679 }
680
681 lastReportedGrid = (cols, rows)
682 onResize([
683 "cols": cols,
684 "rows": rows,
685 ])
686 }
687
688 private func updateContentScale() {
689 // The trait collection follows the scene the view is in, which matters once
690 // iPhone apps run resizable and on external displays; UIScreen.main does not.
691 let scale = traitCollection.displayScale
692 if contentScaleFactor != scale {
693 contentScaleFactor = scale
694 }
695 }
696
697 private func requestKeyboardFocus() {
698 guard window != nil, !readOnly else { return }
699 inputField.becomeFirstResponder()
700 textInputModeDidChange()
701 }
702
703 private func emitInput(_ data: String) {
704 guard !readOnly, !data.isEmpty else { return }
705 onInput(["data": data])
706 }
707
708 private func textInputModeDidChange() {
709 guard let app else { return }
710 ghostty_app_keyboard_changed(app)
711 }
712
713 private func configureIOSurfaceLayers() {
714 let targetBounds = CGRect(origin: .zero, size: terminalViewport.bounds.size)
715 CATransaction.begin()
716 CATransaction.setDisableActions(true)
717 terminalViewport.layer.sublayers?.forEach { sublayer in
718 sublayer.frame = targetBounds
719 sublayer.contentsScale = contentScaleFactor
720 }
721 CATransaction.commit()
722 }
723
724 private func markIOSurfaceLayersForDisplay() {
725 terminalViewport.layer.setNeedsDisplay()
726 terminalViewport.layer.sublayers?.forEach { layer in
727 layer.setNeedsDisplay()
728 }
729 }
730
731 private func applyTheme() {
732 backgroundColor = backgroundColorValue
733 terminalViewport.backgroundColor = backgroundColorValue
734 }
735
736 private func loadThemeConfig(into config: ghostty_config_t) {
737 guard let path = writeThemeConfigFile() else { return }
738 path.withCString { cString in
739 ghostty_config_load_file(config, cString)
740 }
741 }
742
743 private func writeThemeConfigFile() -> String? {
744 guard !themeConfig.isEmpty else { return nil }
745 let configContents = themeConfig
746 let url = URL(fileURLWithPath: NSTemporaryDirectory())
747 .appendingPathComponent("t3-terminal-theme-\(appearance.rawValue).ghostty")
748
749 do {
750 if let existing = try? String(contentsOf: url, encoding: .utf8), existing == configContents {
751 return url.path
752 }
753
754 try configContents.write(to: url, atomically: true, encoding: .utf8)
755 return url.path
756 } catch {
757 return nil
758 }
759 }
760}