import SwiftUI import UserNotifications import GhosttyKit extension Ghostty { /// Render a terminal for the active app in the environment. struct Terminal: View { @EnvironmentObject private var ghostty: Ghostty.App var body: some View { if let app = self.ghostty.app { SurfaceForApp(app) { surfaceView in SurfaceWrapper(surfaceView: surfaceView) } } } } /// Yields a SurfaceView for a ghostty app that can then be used however you want. struct SurfaceForApp: View { let content: ((SurfaceView) -> Content) @StateObject private var surfaceView: SurfaceView init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) { _surfaceView = StateObject(wrappedValue: SurfaceView(app)) self.content = content } var body: some View { content(surfaceView) } } struct SurfaceWrapper: View { // The surface to create a view for. This must be created upstream. As long as this // remains the same, the surface that is being rendered remains the same. @ObservedObject var surfaceView: SurfaceView // True if this surface is part of a split view. This is important to know so // we know whether to dim the surface out of focus. var isSplit: Bool = false // Maintain whether our view has focus or not @FocusState private var surfaceFocus: Bool // Maintain whether our window has focus (is key) or not @State private var windowFocus: Bool = true // True if we're hovering over the left URL view, so we can show it on the right. @State private var isHoveringURLLeft: Bool = false #if canImport(AppKit) // Observe SecureInput to detect when its enabled @ObservedObject private var secureInput = SecureInput.shared #endif @EnvironmentObject private var ghostty: Ghostty.App var body: some View { let center = NotificationCenter.default ZStack { // We use a GeometryReader to get the frame bounds so that our metal surface // is up to date. See TerminalSurfaceView for why we don't use the NSView // resize callback. GeometryReader { geo in #if canImport(AppKit) let pubBecomeKey = center.publisher(for: NSWindow.didBecomeKeyNotification) let pubResign = center.publisher(for: NSWindow.didResignKeyNotification) #endif SurfaceRepresentable(view: surfaceView, size: geo.size) .focused($surfaceFocus) .focusedValue(\.ghosttySurfacePwd, surfaceView.pwd) .focusedValue(\.ghosttySurfaceView, surfaceView) .focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize) #if canImport(AppKit) .onReceive(pubBecomeKey) { notification in guard let window = notification.object as? NSWindow else { return } guard let surfaceWindow = surfaceView.window else { return } windowFocus = surfaceWindow == window } .onReceive(pubResign) { notification in guard let window = notification.object as? NSWindow else { return } guard let surfaceWindow = surfaceView.window else { return } if (surfaceWindow == window) { windowFocus = false } } #endif // If our geo size changed then we show the resize overlay as configured. if let surfaceSize = surfaceView.surfaceSize { SurfaceResizeOverlay( geoSize: geo.size, size: surfaceSize, overlay: ghostty.config.resizeOverlay, position: ghostty.config.resizeOverlayPosition, duration: ghostty.config.resizeOverlayDuration, focusInstant: surfaceView.focusInstant) } } .ghosttySurfaceView(surfaceView) // Readonly indicator badge if surfaceView.readonly { ReadonlyBadge() } // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { SurfaceProgressBar(report: progressReport) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .allowsHitTesting(false) .transition(.opacity) } #if canImport(AppKit) // If we are in the middle of a key sequence, then we show a visual element. We only // support this on macOS currently although in theory we can support mobile with keyboards! if !surfaceView.keySequence.isEmpty { let padding: CGFloat = 5 VStack { Spacer() HStack { Text(verbatim: "Pending Key Sequence:") ForEach(0.. 0) { Rectangle() .fill(ghostty.config.unfocusedSplitFill) .allowsHitTesting(false) .opacity(overlayOpacity) } } } } } struct SurfaceRendererUnhealthyView: View { var body: some View { HStack { Image("AppIconImage") .resizable() .scaledToFit() .frame(width: 128, height: 128) VStack(alignment: .leading) { Text("Oh, no. 😭").font(.title) Text(""" The renderer has failed. This is usually due to exhausting available GPU memory. Please free up available resources. """.replacingOccurrences(of: "\n", with: " ") ) .frame(maxWidth: 350) } } .padding() } } struct SurfaceErrorView: View { var body: some View { HStack { Image("AppIconImage") .resizable() .scaledToFit() .frame(width: 128, height: 128) VStack(alignment: .leading) { Text("Oh, no. 😭").font(.title) Text(""" The terminal failed to initialize. Please check the logs for more information. This is usually a bug. """.replacingOccurrences(of: "\n", with: " ") ) .frame(maxWidth: 350) } } .padding() } } // This is the resize overlay that shows on top of a surface to show the current // size during a resize operation. struct SurfaceResizeOverlay: View { let geoSize: CGSize let size: ghostty_surface_size_s let overlay: Ghostty.Config.ResizeOverlay let position: Ghostty.Config.ResizeOverlayPosition let duration: UInt let focusInstant: ContinuousClock.Instant? // This is the last size that we processed. This is how we handle our // timer state. @State var lastSize: CGSize? = nil // Ready is set to true after a short delay. This avoids some of the // challenges of initial view sizing from SwiftUI. @State var ready: Bool = false // Fixed value set based on personal taste. private let padding: CGFloat = 5 // This computed boolean is set to true when the overlay should be hidden. private var hidden: Bool { // If we aren't ready yet then we wait... if (!ready) { return true; } // Hidden if we already processed this size. if (lastSize == geoSize) { return true; } // If we were focused recently we hide it as well. This avoids showing // the resize overlay when SwiftUI is lazily resizing. if let instant = focusInstant { let d = instant.duration(to: ContinuousClock.now) if (d < .milliseconds(500)) { // Avoid this size completely. We can't set values during // view updates so we have to defer this to another tick. DispatchQueue.main.async { lastSize = geoSize } return true; } } // Hidden depending on overlay config switch (overlay) { case .never: return true; case .always: return false; case .after_first: return lastSize == nil; } } var body: some View { VStack { if (!position.top()) { Spacer() } HStack { if (!position.left()) { Spacer() } Text(verbatim: "\(size.columns) ⨯ \(size.rows)") .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) .background( RoundedRectangle(cornerRadius: 4) .fill(.background) .shadow(radius: 3) ) .lineLimit(1) .truncationMode(.tail) if (!position.right()) { Spacer() } } if (!position.bottom()) { Spacer() } } .allowsHitTesting(false) .opacity(hidden ? 0 : 1) .task { // Sleep chosen arbitrarily... a better long term solution would be to detect // when the size stabilizes (coalesce a value) for the first time and then after // that show the resize overlay consistently. try? await Task.sleep(nanoseconds: 500 * 1_000_000) ready = true } .task(id: geoSize) { // By ID-ing the task on the geoSize, we get the task to restart if our // geoSize changes. This also ensures that future resize overlays are shown // properly. // We only sleep if we're ready. If we're not ready then we want to set // our last size right away to avoid a flash. if (ready) { try? await Task.sleep(nanoseconds: UInt64(duration) * 1_000_000) } lastSize = geoSize } } } /// Search overlay view that displays a search bar with input field and navigation buttons. struct SurfaceSearchOverlay: View { let surfaceView: SurfaceView @ObservedObject var searchState: SurfaceView.SearchState let onClose: () -> Void @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero @FocusState private var isSearchFieldFocused: Bool private let padding: CGFloat = 8 var body: some View { GeometryReader { geo in HStack(spacing: 4) { TextField("Search", text: $searchState.needle) .textFieldStyle(.plain) .frame(width: 180) .padding(.leading, 8) .padding(.trailing, 50) .padding(.vertical, 6) .background(Color.primary.opacity(0.1)) .cornerRadius(6) .focused($isSearchFieldFocused) .overlay(alignment: .trailing) { if let selected = searchState.selected { Text("\(selected + 1)/\(searchState.total, default: "?")") .font(.caption) .foregroundColor(.secondary) .monospacedDigit() .padding(.trailing, 8) } else if let total = searchState.total { Text("-/\(total)") .font(.caption) .foregroundColor(.secondary) .monospacedDigit() .padding(.trailing, 8) } } #if canImport(AppKit) .onExitCommand { Ghostty.moveFocus(to: surfaceView) } #endif .backport.onKeyPress(.return) { modifiers in guard let surface = surfaceView.surface else { return .ignored } let action = modifiers.contains(.shift) ? "navigate_search:previous" : "navigate_search:next" ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) return .handled } Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:next" ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) }) { Image(systemName: "chevron.up") } .buttonStyle(SearchButtonStyle()) Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:previous" ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) }) { Image(systemName: "chevron.down") } .buttonStyle(SearchButtonStyle()) Button(action: onClose) { Image(systemName: "xmark") } .buttonStyle(SearchButtonStyle()) } .padding(8) .background(.background) .clipShape(clipShape) .shadow(radius: 4) .onAppear { isSearchFieldFocused = true } .onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in guard notification.object as? SurfaceView === surfaceView else { return } isSearchFieldFocused = true } .background( GeometryReader { barGeo in Color.clear.onAppear { barSize = barGeo.size } } ) .padding(padding) .offset(dragOffset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment) .gesture( DragGesture() .onChanged { value in dragOffset = value.translation } .onEnded { value in let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize) let newCenter = CGPoint( x: centerPos.x + value.translation.width, y: centerPos.y + value.translation.height ) let newCorner = closestCorner(to: newCenter, in: geo.size) withAnimation(.easeOut(duration: 0.2)) { corner = newCorner dragOffset = .zero } } ) } } private var clipShape: some Shape { if #available(iOS 26.0, macOS 26.0, *) { return ConcentricRectangle(corners: .concentric(minimum: 8), isUniform: true) } else { return RoundedRectangle(cornerRadius: 8) } } enum Corner { case topLeft, topRight, bottomLeft, bottomRight var alignment: Alignment { switch self { case .topLeft: return .topLeading case .topRight: return .topTrailing case .bottomLeft: return .bottomLeading case .bottomRight: return .bottomTrailing } } } private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint { let halfWidth = barSize.width / 2 + padding let halfHeight = barSize.height / 2 + padding switch corner { case .topLeft: return CGPoint(x: halfWidth, y: halfHeight) case .topRight: return CGPoint(x: containerSize.width - halfWidth, y: halfHeight) case .bottomLeft: return CGPoint(x: halfWidth, y: containerSize.height - halfHeight) case .bottomRight: return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight) } } private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner { let midX = containerSize.width / 2 let midY = containerSize.height / 2 if point.x < midX { return point.y < midY ? .topLeft : .bottomLeft } else { return point.y < midY ? .topRight : .bottomRight } } struct SearchButtonStyle: ButtonStyle { @State private var isHovered = false func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundStyle(isHovered || configuration.isPressed ? .primary : .secondary) .padding(.horizontal, 2) .frame(height: 26) .background( RoundedRectangle(cornerRadius: 6) .fill(backgroundColor(isPressed: configuration.isPressed)) ) .onHover { hovering in isHovered = hovering } .backport.pointerStyle(.link) } private func backgroundColor(isPressed: Bool) -> Color { if isPressed { return Color.primary.opacity(0.2) } else if isHovered { return Color.primary.opacity(0.1) } else { return Color.clear } } } } /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. struct SurfaceRepresentable: OSViewRepresentable { /// The view to render for the terminal surface. let view: SurfaceView /// The size of the frame containing this view. We use this to update the the underlying /// surface. This does not actually SET the size of our frame, this only sets the size /// of our Metal surface for drawing. /// /// Note: we do NOT use the NSView.resize function because SwiftUI on macOS 12 /// does not call this callback (macOS 13+ does). /// /// The best approach is to wrap this view in a GeometryReader and pass in the geo.size. let size: CGSize #if canImport(AppKit) func makeOSView(context: Context) -> SurfaceScrollView { // On macOS, wrap the surface view in a scroll view return SurfaceScrollView(contentSize: size, surfaceView: view) } func updateOSView(_ scrollView: SurfaceScrollView, context: Context) { // Nothing to do: SwiftUI automatically updates the frame size, and // SurfaceScrollView handles the rest in response to that } #else func makeOSView(context: Context) -> SurfaceView { // On iOS, return the surface view directly return view } func updateOSView(_ view: SurfaceView, context: Context) { view.sizeDidChange(size) } #endif } /// The configuration for a surface. For any configuration not set, defaults will be chosen from /// libghostty, usually from the Ghostty configuration. struct SurfaceConfiguration { /// Explicit font size to use in points var fontSize: Float32? = nil /// Explicit working directory to set var workingDirectory: String? = nil /// Explicit command to set var command: String? = nil /// Environment variables to set for the terminal var environmentVariables: [String: String] = [:] /// Extra input to send as stdin var initialInput: String? = nil /// Wait after the command var waitAfterCommand: Bool = false init() {} init(from config: ghostty_surface_config_s) { self.fontSize = config.font_size if let workingDirectory = config.working_directory { self.workingDirectory = String.init(cString: workingDirectory, encoding: .utf8) } if let command = config.command { self.command = String.init(cString: command, encoding: .utf8) } // Convert the C env vars to Swift dictionary if config.env_var_count > 0, let envVars = config.env_vars { for i in 0..(view: SurfaceView, _ body: (inout ghostty_surface_config_s) throws -> T) rethrows -> T { var config = ghostty_surface_config_new() config.userdata = Unmanaged.passUnretained(view).toOpaque() #if os(macOS) config.platform_tag = GHOSTTY_PLATFORM_MACOS config.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( nsview: Unmanaged.passUnretained(view).toOpaque() )) config.scale_factor = NSScreen.main!.backingScaleFactor #elseif os(iOS) config.platform_tag = GHOSTTY_PLATFORM_IOS config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s( uiview: Unmanaged.passUnretained(view).toOpaque() )) // Note that UIScreen.main is deprecated and we're supposed to get the // screen through the view hierarchy instead. This means that we should // probably set this to some default, then modify the scale factor through // libghostty APIs when a UIView is attached to a window/scene. TODO. config.scale_factor = UIScreen.main.scale #else #error("unsupported target") #endif // Zero is our default value that means to inherit the font size. config.font_size = fontSize ?? 0 // Set wait after command config.wait_after_command = waitAfterCommand // Use withCString to ensure strings remain valid for the duration of the closure return try workingDirectory.withCString { cWorkingDir in config.working_directory = cWorkingDir return try command.withCString { cCommand in config.command = cCommand return try initialInput.withCString { cInput in config.initial_input = cInput // Convert dictionary to arrays for easier processing let keys = Array(environmentVariables.keys) let values = Array(environmentVariables.values) // Create C strings for all keys and values return try keys.withCStrings { keyCStrings in return try values.withCStrings { valueCStrings in // Create array of ghostty_env_var_s var envVars = Array() envVars.reserveCapacity(environmentVariables.count) for i in 0.. some View { environment(\.ghosttySurfaceView, surfaceView) } } // MARK: Surface Focus Keys extension FocusedValues { var ghosttySurfaceView: Ghostty.SurfaceView? { get { self[FocusedGhosttySurface.self] } set { self[FocusedGhosttySurface.self] = newValue } } struct FocusedGhosttySurface: FocusedValueKey { typealias Value = Ghostty.SurfaceView } var ghosttySurfacePwd: String? { get { self[FocusedGhosttySurfacePwd.self] } set { self[FocusedGhosttySurfacePwd.self] = newValue } } struct FocusedGhosttySurfacePwd: FocusedValueKey { typealias Value = String } var ghosttySurfaceCellSize: OSSize? { get { self[FocusedGhosttySurfaceCellSize.self] } set { self[FocusedGhosttySurfaceCellSize.self] = newValue } } struct FocusedGhosttySurfaceCellSize: FocusedValueKey { typealias Value = OSSize } } // MARK: Search State extension Ghostty.SurfaceView { class SearchState: ObservableObject { @Published var needle: String = "" @Published var selected: UInt? = nil @Published var total: UInt? = nil init(from startSearch: Ghostty.Action.StartSearch) { self.needle = startSearch.needle ?? "" } } }