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 @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? var body: some View { if let app = self.ghostty.app { SurfaceForApp(app) { surfaceView in SurfaceWrapper(surfaceView: surfaceView) } .navigationTitle(surfaceTitle ?? "Ghostty") } } } /// 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 Surface(view: surfaceView, size: geo.size) .focused($surfaceFocus) .focusedValue(\.ghosttySurfaceTitle, surfaceView.title) .focusedValue(\.ghosttySurfacePwd, surfaceView.pwd) .focusedValue(\.ghosttySurfaceView, surfaceView) .focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize) #if canImport(AppKit) .backport.pointerStyle(surfaceView.pointerStyle) .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) #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. 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)c ⨯ \(size.rows)r") .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 } } } /// 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. /// /// We just wrap an AppKit NSView here at the moment so that we can behave as low level as possible /// since that is what the Metal renderer in Ghostty expects. In the future, it may make more sense to /// wrap an MTKView and use that, but for legacy reasons we didn't do that to begin with. struct Surface: 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 func makeOSView(context: Context) -> SurfaceView { // We need the view as part of the state to be created previously because // the view is sent to the Ghostty API so that it can manipulate it // directly since we draw on a render thread. return view; } func updateOSView(_ view: SurfaceView, context: Context) { view.sizeDidChange(size) } } /// 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 init() {} init(from config: ghostty_surface_config_s) { self.fontSize = config.font_size self.workingDirectory = String.init(cString: config.working_directory, encoding: .utf8) self.command = String.init(cString: config.command, encoding: .utf8) } /// Returns the ghostty configuration for this surface configuration struct. The memory /// in the returned struct is only valid as long as this struct is retained. func ghosttyConfig(view: SurfaceView) -> ghostty_surface_config_s { 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 if let fontSize = fontSize { config.font_size = fontSize } if let workingDirectory = workingDirectory { config.working_directory = (workingDirectory as NSString).utf8String } if let command = command { config.command = (command as NSString).utf8String } return config } } } // MARK: Surface Environment Keys private struct GhosttySurfaceViewKey: EnvironmentKey { static let defaultValue: Ghostty.SurfaceView? = nil } extension EnvironmentValues { var ghosttySurfaceView: Ghostty.SurfaceView? { get { self[GhosttySurfaceViewKey.self] } set { self[GhosttySurfaceViewKey.self] = newValue } } } extension View { func ghosttySurfaceView(_ surfaceView: Ghostty.SurfaceView?) -> 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 ghosttySurfaceTitle: String? { get { self[FocusedGhosttySurfaceTitle.self] } set { self[FocusedGhosttySurfaceTitle.self] = newValue } } struct FocusedGhosttySurfaceTitle: FocusedValueKey { typealias Value = String } var ghosttySurfacePwd: String? { get { self[FocusedGhosttySurfacePwd.self] } set { self[FocusedGhosttySurfacePwd.self] = newValue } } struct FocusedGhosttySurfacePwd: FocusedValueKey { typealias Value = String } var ghosttySurfaceZoomed: Bool? { get { self[FocusedGhosttySurfaceZoomed.self] } set { self[FocusedGhosttySurfaceZoomed.self] = newValue } } struct FocusedGhosttySurfaceZoomed: FocusedValueKey { typealias Value = Bool } var ghosttySurfaceCellSize: OSSize? { get { self[FocusedGhosttySurfaceCellSize.self] } set { self[FocusedGhosttySurfaceCellSize.self] = newValue } } struct FocusedGhosttySurfaceCellSize: FocusedValueKey { typealias Value = OSSize } }