import AppKit import SwiftUI import CoreText import UserNotifications import GhosttyKit extension Ghostty { /// The NSView implementation for a terminal surface. class SurfaceView: OSView, ObservableObject, Codable { /// Unique ID per surface let uuid: UUID // The current title of the surface as defined by the pty. This can be // changed with escape codes. This is public because the callbacks go // to the app level and it is set from there. @Published private(set) var title: String = "" { didSet { if !title.isEmpty { titleFallbackTimer?.invalidate() titleFallbackTimer = nil } } } // The current pwd of the surface as defined by the pty. This can be // changed with escape codes. @Published var pwd: String? = nil // The cell size of this surface. This is set by the core when the // surface is first created and any time the cell size changes (i.e. // when the font size changes). This is used to allow windows to be // resized in discrete steps of a single cell. @Published var cellSize: NSSize = .zero // The health state of the surface. This currently only reflects the // renderer health. In the future we may want to make this an enum. @Published var healthy: Bool = true // Any error while initializing the surface. @Published var error: Error? = nil // The hovered URL string @Published var hoverUrl: String? = nil // The currently active key sequence. The sequence is not active if this is empty. @Published var keySequence: [KeyboardShortcut] = [] // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. @Published var focusInstant: ContinuousClock.Instant? = nil // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. @Published var surfaceSize: ghostty_surface_size_s? = nil // Whether the pointer should be visible or not @Published private(set) var pointerStyle: BackportPointerStyle = .default /// The configuration derived from the Ghostty config so we don't need to rely on references. @Published private(set) var derivedConfig: DerivedConfig /// The background color within the color palette of the surface. This is only set if it is /// dynamically updated. Otherwise, the background color is the default background color. @Published private(set) var backgroundColor: Color? = nil /// True when the bell is active. This is set inactive on focus or event. @Published private(set) var bell: Bool = false // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? = nil // Set whether the surface is currently on a password input or not. This is // detected with the set_password_input_cb on the Ghostty state. var passwordInput: Bool = false { didSet { // We need to update our state within the SecureInput manager. let input = SecureInput.shared let id = ObjectIdentifier(self) if (passwordInput) { input.setScoped(id, focused: focused) } else { input.removeScoped(id) } } } // Returns true if quit confirmation is required for this surface to // exit safely. var needsConfirmQuit: Bool { guard let surface = self.surface else { return false } return ghostty_surface_needs_confirm_quit(surface) } // Returns true if the process in this surface has exited. var processExited: Bool { guard let surface = self.surface else { return true } return ghostty_surface_process_exited(surface) } // Returns the inspector instance for this surface, or nil if the // surface has been closed. var inspector: ghostty_inspector_t? { guard let surface = self.surface else { return nil } return ghostty_surface_inspector(surface) } // True if the inspector should be visible @Published var inspectorVisible: Bool = false { didSet { if (oldValue && !inspectorVisible) { guard let surface = self.surface else { return } ghostty_inspector_free(surface) } } } /// Returns the data model for this surface. /// /// Note: eventually, all surface access will be through this, but presently its in a transition /// state so we're mixing this with direct surface access. private(set) var surfaceModel: Ghostty.Surface? /// Returns the underlying C value for the surface. See "note" on surfaceModel. var surface: ghostty_surface_t? { surfaceModel?.unsafeCValue } // Notification identifiers associated with this surface var notificationIdentifiers: Set = [] private var markedText: NSMutableAttributedString private(set) var focused: Bool = true private var prevPressureStage: Int = 0 private var appearanceObserver: NSKeyValueObservation? = nil // This is set to non-null during keyDown to accumulate insertText contents private var keyTextAccumulator: [String]? = nil // A small delay that is introduced before a title change to avoid flickers private var titleChangeTimer: Timer? // A timer to fallback to ghost emoji if no title is set within the grace period private var titleFallbackTimer: Timer? // This is the title from the terminal. This is nil if we're currently using // the terminal title as the main title property. If the title is set manually // by the user, this is set to the prior value (which may be empty, but non-nil). private var titleFromTerminal: String? // The cached contents of the screen. private(set) var cachedScreenContents: CachedValue private(set) var cachedVisibleContents: CachedValue /// Event monitor (see individual events for why) private var eventMonitor: Any? = nil // We need to support being a first responder so that we can get input events override var acceptsFirstResponder: Bool { return true } init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { self.markedText = NSMutableAttributedString() self.uuid = uuid ?? .init() // Our initial config always is our application wide config. if let appDelegate = NSApplication.shared.delegate as? AppDelegate { self.derivedConfig = DerivedConfig(appDelegate.ghostty.config) } else { self.derivedConfig = DerivedConfig() } // We need to initialize this so it does something but we want to set // it back up later so we can reference `self`. This is a hack we should // fix at some point. self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" } self.cachedVisibleContents = self.cachedScreenContents // Initialize with some default frame size. The important thing is that this // is non-zero so that our layer bounds are non-zero so that our renderer // can do SOMETHING. super.init(frame: NSMakeRect(0, 0, 800, 600)) // Our cache of screen data cachedScreenContents = .init(duration: .milliseconds(500)) { [weak self] in guard let self else { return "" } guard let surface = self.surface else { return "" } var text = ghostty_text_s() let sel = ghostty_selection_s( top_left: ghostty_point_s( tag: GHOSTTY_POINT_SCREEN, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0), bottom_right: ghostty_point_s( tag: GHOSTTY_POINT_SCREEN, coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, x: 0, y: 0), rectangle: false) guard ghostty_surface_read_text(surface, sel, &text) else { return "" } defer { ghostty_surface_free_text(surface, &text) } return String(cString: text.text) } cachedVisibleContents = .init(duration: .milliseconds(500)) { [weak self] in guard let self else { return "" } guard let surface = self.surface else { return "" } var text = ghostty_text_s() let sel = ghostty_selection_s( top_left: ghostty_point_s( tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0), bottom_right: ghostty_point_s( tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, x: 0, y: 0), rectangle: false) guard ghostty_surface_read_text(surface, sel, &text) else { return "" } defer { ghostty_surface_free_text(surface, &text) } return String(cString: text.text) } // Set a timer to show the ghost emoji after 500ms if no title is set titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in if let self = self, self.title.isEmpty { self.title = "👻" } } // Before we initialize the surface we want to register our notifications // so there is no window where we can't receive them. let center = NotificationCenter.default center.addObserver( self, selector: #selector(onUpdateRendererHealth), name: Ghostty.Notification.didUpdateRendererHealth, object: self) center.addObserver( self, selector: #selector(ghosttyDidContinueKeySequence), name: Ghostty.Notification.didContinueKeySequence, object: self) center.addObserver( self, selector: #selector(ghosttyDidEndKeySequence), name: Ghostty.Notification.didEndKeySequence, object: self) center.addObserver( self, selector: #selector(ghosttyConfigDidChange(_:)), name: .ghosttyConfigDidChange, object: self) center.addObserver( self, selector: #selector(ghosttyColorDidChange(_:)), name: .ghosttyColorDidChange, object: self) center.addObserver( self, selector: #selector(ghosttyBellDidRing(_:)), name: .ghosttyBellDidRing, object: self) center.addObserver( self, selector: #selector(windowDidChangeScreen), name: NSWindow.didChangeScreenNotification, object: nil) // Listen for local events that we need to know of outside of // single surface handlers. self.eventMonitor = NSEvent.addLocalMonitorForEvents( matching: [ // We need keyUp because command+key events don't trigger keyUp. .keyUp, // We need leftMouseDown to determine if we should focus ourselves // when the app/window isn't in focus. We do this instead of // "acceptsFirstMouse" because that forces us to also handle the // event and encode the event to the pty which we want to avoid. // (Issue 2595) .leftMouseDown, ] ) { [weak self] event in self?.localEventHandler(event) } // Setup our surface. This will also initialize all the terminal IO. let surface_cfg = baseConfig ?? SurfaceConfiguration() let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in ghostty_surface_new(app, &surface_cfg_c) } guard let surface = surface else { self.error = Ghostty.Error.apiFailed return } self.surfaceModel = Ghostty.Surface(cSurface: surface) // Setup our tracking area so we get mouse moved events updateTrackingAreas() // Observe our appearance so we can report the correct value to libghostty. // This is the best way I know of to get appearance change notifications. self.appearanceObserver = observe(\.effectiveAppearance, options: [.new, .initial]) { view, change in guard let appearance = change.newValue else { return } guard let surface = view.surface else { return } let scheme: ghostty_color_scheme_e switch (appearance.name) { case .aqua, .vibrantLight: scheme = GHOSTTY_COLOR_SCHEME_LIGHT case .darkAqua, .vibrantDark: scheme = GHOSTTY_COLOR_SCHEME_DARK default: return } ghostty_surface_set_color_scheme(surface, scheme) } // The UTTypes that can be dragged onto this view. registerForDraggedTypes(Array(Self.dropTypes)) } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported for this view") } deinit { // Remove all of our notificationcenter subscriptions let center = NotificationCenter.default center.removeObserver(self) // Remove our event monitor if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } // Whenever the surface is removed, we need to note that our restorable // state is invalid to prevent the surface from being restored. invalidateRestorableState() trackingAreas.forEach { removeTrackingArea($0) } // Remove ourselves from secure input if we have to SecureInput.shared.removeScoped(ObjectIdentifier(self)) // Remove any notifications associated with this surface let identifiers = Array(self.notificationIdentifiers) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) } func focusDidChange(_ focused: Bool) { guard let surface = self.surface else { return } guard self.focused != focused else { return } self.focused = focused ghostty_surface_set_focus(surface, focused) // Update our secure input state if we are a password input if (passwordInput) { SecureInput.shared.setScoped(ObjectIdentifier(self), focused: focused) } if (focused) { // On macOS 13+ we can store our continuous clock... focusInstant = ContinuousClock.now // We unset our bell state if we gained focus bell = false // Remove any notifications for this surface once we gain focus. if !notificationIdentifiers.isEmpty { UNUserNotificationCenter.current() .removeDeliveredNotifications( withIdentifiers: Array(notificationIdentifiers)) self.notificationIdentifiers = [] } } } func sizeDidChange(_ size: CGSize) { // Ghostty wants to know the actual framebuffer size... It is very important // here that we use "size" and NOT the view frame. If we're in the middle of // an animation (i.e. a fullscreen animation), the frame will not yet be updated. // The size represents our final size we're going for. let scaledSize = self.convertToBacking(size) setSurfaceSize(width: UInt32(scaledSize.width), height: UInt32(scaledSize.height)) } private func setSurfaceSize(width: UInt32, height: UInt32) { guard let surface = self.surface else { return } // Update our core surface ghostty_surface_set_size(surface, width, height) // Update our cached size metrics let size = ghostty_surface_size(surface) DispatchQueue.main.async { // DispatchQueue required since this may be called by SwiftUI off // the main thread and Published changes need to be on the main // thread. This caused a crash on macOS <= 14. self.surfaceSize = size } } func setCursorShape(_ shape: ghostty_action_mouse_shape_e) { switch (shape) { case GHOSTTY_MOUSE_SHAPE_DEFAULT: pointerStyle = .default case GHOSTTY_MOUSE_SHAPE_TEXT: pointerStyle = .horizontalText case GHOSTTY_MOUSE_SHAPE_GRAB: pointerStyle = .grabIdle case GHOSTTY_MOUSE_SHAPE_GRABBING: pointerStyle = .grabActive case GHOSTTY_MOUSE_SHAPE_POINTER: pointerStyle = .link case GHOSTTY_MOUSE_SHAPE_W_RESIZE: pointerStyle = .resizeLeft case GHOSTTY_MOUSE_SHAPE_E_RESIZE: pointerStyle = .resizeRight case GHOSTTY_MOUSE_SHAPE_N_RESIZE: pointerStyle = .resizeUp case GHOSTTY_MOUSE_SHAPE_S_RESIZE: pointerStyle = .resizeDown case GHOSTTY_MOUSE_SHAPE_NS_RESIZE: pointerStyle = .resizeUpDown case GHOSTTY_MOUSE_SHAPE_EW_RESIZE: pointerStyle = .resizeLeftRight case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: pointerStyle = .default // These are not yet supported. We should support them by constructing a // PointerStyle from an NSCursor. case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: fallthrough case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: fallthrough case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: pointerStyle = .default default: // We ignore unknown shapes. return } } func setCursorVisibility(_ visible: Bool) { // Technically this action could be called anytime we want to // change the mouse visibility but at the time of writing this // mouse-hide-while-typing is the only use case so this is the // preferred method. NSCursor.setHiddenUntilMouseMoves(!visible) } /// Set the title by prompting the user. func promptTitle() { // Create an alert dialog let alert = NSAlert() alert.messageText = "Change Terminal Title" alert.informativeText = "Leave blank to restore the default." alert.alertStyle = .informational // Add a text field to the alert let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24)) textField.stringValue = title alert.accessoryView = textField // Add buttons alert.addButton(withTitle: "OK") alert.addButton(withTitle: "Cancel") let response = alert.runModal() // Check if the user clicked "OK" if response == .alertFirstButtonReturn { // Get the input text let newTitle = textField.stringValue if newTitle.isEmpty { // Empty means that user wants the title to be set automatically // We also need to reload the config for the "title" property to be // used again by this tab. let prevTitle = titleFromTerminal ?? "👻" titleFromTerminal = nil setTitle(prevTitle) } else { // Set the title and prevent it from being changed automatically titleFromTerminal = title title = newTitle } } } func setTitle(_ title: String) { // This fixes an issue where very quick changes to the title could // cause an unpleasant flickering. We set a timer so that we can // coalesce rapid changes. The timer is short enough that it still // feels "instant". titleChangeTimer?.invalidate() titleChangeTimer = Timer.scheduledTimer( withTimeInterval: 0.075, repeats: false ) { [weak self] _ in // Set the title if it wasn't manually set. guard self?.titleFromTerminal == nil else { self?.titleFromTerminal = title return } self?.title = title } } // MARK: Local Events private func localEventHandler(_ event: NSEvent) -> NSEvent? { return switch event.type { case .keyUp: localEventKeyUp(event) case .leftMouseDown: localEventLeftMouseDown(event) default: event } } private func localEventLeftMouseDown(_ event: NSEvent) -> NSEvent? { // We only want to process events that are on this window. guard let window, event.window != nil, window == event.window else { return event } // The clicked location in this window should be this view. let location = convert(event.locationInWindow, from: nil) guard hitTest(location) == self else { return event } // We only want to grab focus if either our app or window was // not focused. guard !NSApp.isActive || !window.isKeyWindow else { return event } // If we're already focused we do nothing guard !focused else { return event } // Make ourselves the first responder window.makeFirstResponder(self) // We have to keep processing the event so that AppKit can properly // focus the window and dispatch events. If you return nil here then // nobody gets a windowDidBecomeKey event and so on. return event } private func localEventKeyUp(_ event: NSEvent) -> NSEvent? { // We only care about events with "command" because all others will // trigger the normal responder chain. if (!event.modifierFlags.contains(.command)) { return event } // Command keyUp events are never sent to the normal responder chain // so we send them here. guard focused else { return event } self.keyUp(with: event) return nil } // MARK: - Notifications @objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) { guard let healthAny = notification.userInfo?["health"] else { return } guard let health = healthAny as? ghostty_action_renderer_health_e else { return } DispatchQueue.main.async { [weak self] in self?.healthy = health == GHOSTTY_RENDERER_HEALTH_OK } } @objc private func ghosttyDidContinueKeySequence(notification: SwiftUI.Notification) { guard let keyAny = notification.userInfo?[Ghostty.Notification.KeySequenceKey] else { return } guard let key = keyAny as? KeyboardShortcut else { return } DispatchQueue.main.async { [weak self] in self?.keySequence.append(key) } } @objc private func ghosttyDidEndKeySequence(notification: SwiftUI.Notification) { DispatchQueue.main.async { [weak self] in self?.keySequence = [] } } @objc private func ghosttyConfigDidChange(_ notification: SwiftUI.Notification) { // Get our managed configuration object out guard let config = notification.userInfo?[ SwiftUI.Notification.Name.GhosttyConfigChangeKey ] as? Ghostty.Config else { return } // Update our derived config DispatchQueue.main.async { [weak self] in self?.derivedConfig = DerivedConfig(config) } } @objc private func ghosttyColorDidChange(_ notification: SwiftUI.Notification) { guard let change = notification.userInfo?[ SwiftUI.Notification.Name.GhosttyColorChangeKey ] as? Ghostty.Action.ColorChange else { return } switch (change.kind) { case .background: DispatchQueue.main.async { [weak self] in self?.backgroundColor = change.color } default: // We don't do anything for the other colors yet. break } } @objc private func ghosttyBellDidRing(_ notification: SwiftUI.Notification) { // Bell state goes to true bell = true } @objc private func windowDidChangeScreen(notification: SwiftUI.Notification) { guard let window = self.window else { return } guard let object = notification.object as? NSWindow, window == object else { return } guard let screen = window.screen else { return } guard let surface = self.surface else { return } // When the window changes screens, we need to update libghostty with the screen // ID. If vsync is enabled, this will be used with the CVDisplayLink to ensure // the proper refresh rate is going. ghostty_surface_set_display_id(surface, screen.displayID ?? 0) // We also just trigger a backing property change. Just in case the screen has // a different scaling factor, this ensures that we update our content scale. // Issue: https://github.com/ghostty-org/ghostty/issues/2731 DispatchQueue.main.async { [weak self] in self?.viewDidChangeBackingProperties() } } // MARK: - NSView override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() if (result) { focusDidChange(true) } return result } override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() // We sometimes call this manually (see SplitView) as a way to force us to // yield our focus state. if (result) { focusDidChange(false) } return result } override func updateTrackingAreas() { // To update our tracking area we just recreate it all. trackingAreas.forEach { removeTrackingArea($0) } // This tracking area is across the entire frame to notify us of mouse movements. addTrackingArea(NSTrackingArea( rect: frame, options: [ .mouseEnteredAndExited, .mouseMoved, // Only send mouse events that happen in our visible (not obscured) rect .inVisibleRect, // We want active always because we want to still send mouse reports // even if we're not focused or key. .activeAlways, ], owner: self, userInfo: nil)) } override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() // The Core Animation compositing engine uses the layer's contentsScale property // to determine whether to scale its contents during compositing. When the window // moves between a high DPI display and a low DPI display, or the user modifies // the DPI scaling for a display in the system settings, this can result in the // layer being scaled inappropriately. Since we handle the adjustment of scale // and resolution ourselves below, we update the layer's contentsScale property // to match the window's backingScaleFactor, so as to ensure it is not scaled by // the compositor. // // Ref: High Resolution Guidelines for OS X // https://developer.apple.com/library/archive/documentation/GraphicsAnimation/Conceptual/HighResolutionOSX/CapturingScreenContents/CapturingScreenContents.html#//apple_ref/doc/uid/TP40012302-CH10-SW27 if let window = window { CATransaction.begin() // Disable the implicit transition animation that Core Animation applies to // property changes. Otherwise it will apply a scale animation to the layer // contents which looks pretty janky. CATransaction.setDisableActions(true) layer?.contentsScale = window.backingScaleFactor CATransaction.commit() } guard let surface = self.surface else { return } // Detect our X/Y scale factor so we can update our surface let fbFrame = self.convertToBacking(self.frame) let xScale = fbFrame.size.width / self.frame.size.width let yScale = fbFrame.size.height / self.frame.size.height ghostty_surface_set_content_scale(surface, xScale, yScale) // When our scale factor changes, so does our fb size so we send that too setSurfaceSize(width: UInt32(fbFrame.size.width), height: UInt32(fbFrame.size.height)) } override func mouseDown(with event: NSEvent) { guard let surface = self.surface else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) } override func mouseUp(with event: NSEvent) { // Always reset our pressure when the mouse goes up prevPressureStage = 0 // If we have an active surface, report the event guard let surface = self.surface else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) // Release pressure ghostty_surface_mouse_pressure(surface, 0, 0) } override func otherMouseDown(with event: NSEvent) { guard let surface = self.surface else { return } guard event.buttonNumber == 2 else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mods) } override func otherMouseUp(with event: NSEvent) { guard let surface = self.surface else { return } guard event.buttonNumber == 2 else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, mods) } override func rightMouseDown(with event: NSEvent) { guard let surface = self.surface else { return super.rightMouseDown(with: event) } let mods = Ghostty.ghosttyMods(event.modifierFlags) if (ghostty_surface_mouse_button( surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods )) { // Consumed return } // Mouse event not consumed super.rightMouseDown(with: event) } override func rightMouseUp(with event: NSEvent) { guard let surface = self.surface else { return super.rightMouseUp(with: event) } let mods = Ghostty.ghosttyMods(event.modifierFlags) if (ghostty_surface_mouse_button( surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods )) { // Handled return } // Mouse event not consumed super.rightMouseUp(with: event) } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) guard let surfaceModel else { return } // On mouse enter we need to reset our cursor position. This is // super important because we set it to -1/-1 on mouseExit and // lots of mouse logic (i.e. whether to send mouse reports) depend // on the position being in the viewport if it is. let pos = self.convert(event.locationInWindow, from: nil) let mouseEvent = Ghostty.Input.MousePosEvent( x: pos.x, y: frame.height - pos.y, mods: .init(nsFlags: event.modifierFlags) ) surfaceModel.sendMousePos(mouseEvent) } override func mouseExited(with event: NSEvent) { guard let surfaceModel else { return } // If the mouse is being dragged then we don't have to emit // this because we get mouse drag events even if we've already // exited the viewport (i.e. mouseDragged) if NSEvent.pressedMouseButtons != 0 { return } // Negative values indicate cursor has left the viewport let mouseEvent = Ghostty.Input.MousePosEvent( x: -1, y: -1, mods: .init(nsFlags: event.modifierFlags) ) surfaceModel.sendMousePos(mouseEvent) } override func mouseMoved(with event: NSEvent) { guard let surfaceModel else { return } // Convert window position to view position. Note (0, 0) is bottom left. let pos = self.convert(event.locationInWindow, from: nil) let mouseEvent = Ghostty.Input.MousePosEvent( x: pos.x, y: frame.height - pos.y, mods: .init(nsFlags: event.modifierFlags) ) surfaceModel.sendMousePos(mouseEvent) // Handle focus-follows-mouse if let window, let controller = window.windowController as? BaseTerminalController, (window.isKeyWindow && !self.focused && controller.focusFollowsMouse) { Ghostty.moveFocus(to: self) } } override func mouseDragged(with event: NSEvent) { self.mouseMoved(with: event) } override func rightMouseDragged(with event: NSEvent) { self.mouseMoved(with: event) } override func otherMouseDragged(with event: NSEvent) { self.mouseMoved(with: event) } override func scrollWheel(with event: NSEvent) { guard let surfaceModel else { return } var x = event.scrollingDeltaX var y = event.scrollingDeltaY let precision = event.hasPreciseScrollingDeltas if precision { // We do a 2x speed multiplier. This is subjective, it "feels" better to me. x *= 2; y *= 2; // TODO(mitchellh): do we have to scale the x/y here by window scale factor? } let scrollEvent = Ghostty.Input.MouseScrollEvent( x: x, y: y, mods: .init(precision: precision, momentum: .init(event.momentumPhase)) ) surfaceModel.sendMouseScroll(scrollEvent) } override func pressureChange(with event: NSEvent) { guard let surface = self.surface else { return } // Notify Ghostty first. We do this because this will let Ghostty handle // state setup that we'll need for later pressure handling (such as // QuickLook) ghostty_surface_mouse_pressure(surface, UInt32(event.stage), Double(event.pressure)) // Pressure stage 2 is force click. We only want to execute this on the // initial transition to stage 2, and not for any repeated events. guard self.prevPressureStage < 2 else { return } prevPressureStage = event.stage guard event.stage == 2 else { return } // If the user has force click enabled then we do a quick look. There // is no public API for this as far as I can tell. guard UserDefaults.standard.bool(forKey: "com.apple.trackpad.forceClick") else { return } quickLook(with: event) } override func keyDown(with event: NSEvent) { guard let surface = self.surface else { self.interpretKeyEvents([event]) return } // On any keyDown event we unset our bell state bell = false // We need to translate the mods (maybe) to handle configs such as option-as-alt let translationModsGhostty = Ghostty.eventModifierFlags( mods: ghostty_surface_key_translation_mods( surface, Ghostty.ghosttyMods(event.modifierFlags) ) ) // There are hidden bits set in our event that matter for certain dead keys // so we can't use translationModsGhostty directly. Instead, we just check // for exact states and set them. var translationMods = event.modifierFlags for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { if (translationModsGhostty.contains(flag)) { translationMods.insert(flag) } else { translationMods.remove(flag) } } // If the translation modifiers are not equal to our original modifiers // then we need to construct a new NSEvent. If they are equal we reuse the // old one. IMPORTANT: we MUST reuse the old event if they're equal because // this keeps things like Korean input working. There must be some object // equality happening in AppKit somewhere because this is required. let translationEvent: NSEvent if (translationMods == event.modifierFlags) { translationEvent = event } else { translationEvent = NSEvent.keyEvent( with: event.type, location: event.locationInWindow, modifierFlags: translationMods, timestamp: event.timestamp, windowNumber: event.windowNumber, context: nil, characters: event.characters(byApplyingModifiers: translationMods) ?? "", charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "", isARepeat: event.isARepeat, keyCode: event.keyCode ) ?? event } let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS // By setting this to non-nil, we note that we're in a keyDown event. From here, // we call interpretKeyEvents so that we can handle complex input such as Korean // language. keyTextAccumulator = [] defer { keyTextAccumulator = nil } // We need to know what the length of marked text was before this event to // know if these events cleared it. let markedTextBefore = markedText.length > 0 // We need to know the keyboard layout before below because some keyboard // input events will change our keyboard layout and we don't want those // going to the terminal. let keyboardIdBefore: String? = if (!markedTextBefore) { KeyboardLayout.id } else { nil } // If we are in a keyDown then we don't need to redispatch a command-modded // key event (see docs for this field) so reset this to nil because // `interpretKeyEvents` may dispach it. self.lastPerformKeyEvent = nil self.interpretKeyEvents([translationEvent]) // If our keyboard changed from this we just assume an input method // grabbed it and do nothing. if (!markedTextBefore && keyboardIdBefore != KeyboardLayout.id) { return } // If we have marked text, we're in a preedit state. The order we // do this and the key event callbacks below doesn't matter since // we control the preedit state only through the preedit API. syncPreedit(clearIfNeeded: markedTextBefore) if let list = keyTextAccumulator, list.count > 0 { // If we have text, then we've composed a character, send that down. // These never have "composing" set to true because these are the // result of a composition. for text in list { _ = keyAction( action, event: event, translationEvent: translationEvent, text: text ) } } else { // We have no accumulated text so this is a normal key event. _ = keyAction( action, event: event, translationEvent: translationEvent, text: translationEvent.ghosttyCharacters, // We're composing if we have preedit (the obvious case). But we're also // composing if we don't have preedit and we had marked text before, // because this input probably just reset the preedit state. It shouldn't // be encoded. Example: Japanese begin composing, the press backspace. // This should only cancel the composing state but not actually delete // the prior input characters (prior to the composing). composing: markedText.length > 0 || markedTextBefore ) } } override func keyUp(with event: NSEvent) { _ = keyAction(GHOSTTY_ACTION_RELEASE, event: event) } /// Records the timestamp of the last event to performKeyEquivalent that we need to save. /// We currently save all commands with command or control set. /// /// For command+key inputs, the AppKit input stack calls performKeyEquivalent to give us a chance /// to handle them first. If we return "false" then it goes through the standard AppKit responder chain. /// For an NSTextInputClient, that may redirect some commands _before_ our keyDown gets called. /// Concretely: Command+Period will do: performKeyEquivalent, doCommand ("cancel:"). In doCommand, /// we need to know that we actually want to handle that in keyDown, so we send it back through the /// event dispatch system and use this timestamp as an identity to know to actually send it to keyDown. /// /// Why not send it to keyDown always? Because if the user rebinds a command to something we /// actually handle then we do want the standard response chain to handle the key input. Unfortunately, /// we can't know what a command is bound to at a system level until we let it flow through the system. /// That's the crux of the problem. /// /// So, we have to send it back through if we didn't handle it. /// /// The next part of the problem is comparing NSEvent identity seems pretty nasty. I couldn't /// find a good way to do it. I originally stored a weak ref and did identity comparison but that /// doesn't work and for reasons I couldn't figure out the value gets mangled (fields don't match /// before/after the assignment). I suspect it has something to do with the fact an NSEvent is wrapping /// a lower level event pointer and its just not surviving the Swift runtime somehow. I don't know. /// /// The best thing I could find was to store the event timestamp which has decent granularity /// and compare that. To further complicate things, some events are synthetic and have a zero /// timestamp so we have to protect against that. Fun! var lastPerformKeyEvent: TimeInterval? /// Special case handling for some control keys override func performKeyEquivalent(with event: NSEvent) -> Bool { switch (event.type) { case .keyDown: // Continue, we care about key down events break default: // Any other key event we don't care about. I don't think its even // possible to receive any other event type. return false } // Only process events if we're focused. Some key events like C-/ macOS // appears to send to the first view in the hierarchy rather than the // the first responder (I don't know why). This prevents us from handling it. // Besides C-/, its important we don't process key equivalents if unfocused // because there are other event listeners for that (i.e. AppDelegate's // local event handler). if (!focused) { return false } // If this event as-is would result in a key binding then we send it. if let surface { var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) let match = (event.characters ?? "").withCString { ptr in ghosttyEvent.text = ptr return ghostty_surface_key_is_binding(surface, ghosttyEvent) } if match { self.keyDown(with: event) return true } } let equivalent: String switch (event.charactersIgnoringModifiers) { case "\r": // Pass C- through verbatim // (prevent the default context menu equivalent) if (!event.modifierFlags.contains(.control)) { return false } equivalent = "\r" case "/": // Treat C-/ as C-_. We do this because C-/ makes macOS make a beep // sound and we don't like the beep sound. if (!event.modifierFlags.contains(.control) || !event.modifierFlags.isDisjoint(with: [.shift, .command, .option])) { return false } equivalent = "_" default: // It looks like some part of AppKit sometimes generates synthetic NSEvents // with a zero timestamp. We never process these at this point. Concretely, // this happens for me when pressing Cmd+period with default bindings. This // binds to "cancel" which goes through AppKit to produce a synthetic "escape". // // Question: should we be ignoring all synthetic events? Should we be finding // synthetic escape and ignoring it? I feel like Cmd+period could map to a // escape binding by accident, but it hasn't happened yet... if event.timestamp == 0 { return false } // All of this logic here re: lastCommandEvent is to workaround some // nasty behavior. See the docs for lastCommandEvent for more info. // Ignore all other non-command events. This lets the event continue // through the AppKit event systems. if (!event.modifierFlags.contains(.command) && !event.modifierFlags.contains(.control)) { // Reset since we got a non-command event. lastPerformKeyEvent = nil return false } // If we have a prior command binding and the timestamp matches exactly // then we pass it through to keyDown for encoding. if let lastPerformKeyEvent { self.lastPerformKeyEvent = nil if lastPerformKeyEvent == event.timestamp { equivalent = event.characters ?? "" break } } lastPerformKeyEvent = event.timestamp return false } let finalEvent = NSEvent.keyEvent( with: .keyDown, location: event.locationInWindow, modifierFlags: event.modifierFlags, timestamp: event.timestamp, windowNumber: event.windowNumber, context: nil, characters: equivalent, charactersIgnoringModifiers: equivalent, isARepeat: event.isARepeat, keyCode: event.keyCode ) self.keyDown(with: finalEvent!) return true } override func flagsChanged(with event: NSEvent) { let mod: UInt32; switch (event.keyCode) { case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue default: return } // If we're in the middle of a preedit, don't do anything with mods. if hasMarkedText() { return } // The keyAction function will do this AGAIN below which sucks to repeat // but this is super cheap and flagsChanged isn't that common. let mods = Ghostty.ghosttyMods(event.modifierFlags) // If the key that pressed this is active, its a press, else release. var action = GHOSTTY_ACTION_RELEASE if (mods.rawValue & mod != 0) { // If the key is pressed, its slightly more complicated, because we // want to check if the pressed modifier is the correct side. If the // correct side is pressed then its a press event otherwise its a release // event with the opposite modifier still held. let sidePressed: Bool switch (event.keyCode) { case 0x3C: sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERSHIFTKEYMASK) != 0; case 0x3E: sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCTLKEYMASK) != 0; case 0x3D: sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERALTKEYMASK) != 0; case 0x36: sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCMDKEYMASK) != 0; default: sidePressed = true } if (sidePressed) { action = GHOSTTY_ACTION_PRESS } } _ = keyAction(action, event: event) } private func keyAction( _ action: ghostty_input_action_e, event: NSEvent, translationEvent: NSEvent? = nil, text: String? = nil, composing: Bool = false ) -> Bool { guard let surface = self.surface else { return false } var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags) key_ev.composing = composing // For text, we only encode UTF8 if we don't have a single control // character. Control characters are encoded by Ghostty itself. // Without this, `ctrl+enter` does the wrong thing. if let text, text.count > 0, let codepoint = text.utf8.first, codepoint >= 0x20 { return text.withCString { ptr in key_ev.text = ptr return ghostty_surface_key(surface, key_ev) } } else { return ghostty_surface_key(surface, key_ev) } } override func quickLook(with event: NSEvent) { guard let surface = self.surface else { return super.quickLook(with: event) } // Grab the text under the cursor var text = ghostty_text_s() guard ghostty_surface_quicklook_word(surface, &text) else { return super.quickLook(with: event) } defer { ghostty_surface_free_text(surface, &text) } guard text.text_len > 0 else { return super.quickLook(with: event) } // If we can get a font then we use the font. This should always work // since we always have a primary font. The only scenario this doesn't // work is if someone is using a non-CoreText build which would be // unofficial. var attributes: [ NSAttributedString.Key : Any ] = [:]; if let fontRaw = ghostty_surface_quicklook_font(surface) { // Memory management here is wonky: ghostty_surface_quicklook_font // will create a copy of a CTFont, Swift will auto-retain the // unretained value passed into the dict, so we release the original. let font = Unmanaged.fromOpaque(fontRaw) attributes[.font] = font.takeUnretainedValue() font.release() } // Ghostty coordinate system is top-left, convert to bottom-left for AppKit let pt = NSMakePoint(text.tl_px_x, frame.size.height - text.tl_px_y) let str = NSAttributedString.init(string: String(cString: text.text), attributes: attributes) self.showDefinition(for: str, at: pt); } override func menu(for event: NSEvent) -> NSMenu? { // We only support right-click menus switch event.type { case .rightMouseDown: // Good break case .leftMouseDown: if !event.modifierFlags.contains(.control) { return nil } // In this case, AppKit calls menu BEFORE calling any mouse events. // If mouse capturing is enabled then we never show the context menu // so that we can handle ctrl+left-click in the terminal app. guard let surfaceModel else { return nil } if surfaceModel.mouseCaptured { return nil } // If we return a non-nil menu then mouse events will never be // processed by the core, so we need to manually send a right // mouse down event. // // Note this never sounds a right mouse up event but that's the // same as normal right-click with capturing disabled from AppKit. surfaceModel.sendMouseButton(.init( action: .press, button: .right, mods: .init(nsFlags: event.modifierFlags))) default: return nil } let menu = NSMenu() // We just use a floating var so we can easily setup metadata on each item // in a row without storing it all. var item: NSMenuItem // If we have a selection, add copy if self.selectedRange().length > 0 { menu.addItem(withTitle: "Copy", action: #selector(copy(_:)), keyEquivalent: "") } menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "") menu.addItem(.separator()) item = menu.addItem(withTitle: "Split Right", action: #selector(splitRight(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled") item = menu.addItem(withTitle: "Split Left", action: #selector(splitLeft(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled") item = menu.addItem(withTitle: "Split Down", action: #selector(splitDown(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled") item = menu.addItem(withTitle: "Split Up", action: #selector(splitUp(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled") menu.addItem(.separator()) item = menu.addItem(withTitle: "Reset Terminal", action: #selector(resetTerminal(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise") item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "scope") menu.addItem(.separator()) item = menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "pencil.line") return menu } // MARK: Menu Handlers @IBAction func copy(_ sender: Any?) { guard let surface = self.surface else { return } let action = "copy_to_clipboard" if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { AppDelegate.logger.warning("action failed action=\(action)") } } @IBAction func paste(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { AppDelegate.logger.warning("action failed action=\(action)") } } @IBAction func pasteAsPlainText(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { AppDelegate.logger.warning("action failed action=\(action)") } } @IBAction func pasteSelection(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_selection" if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { AppDelegate.logger.warning("action failed action=\(action)") } } @IBAction override func selectAll(_ sender: Any?) { guard let surface = self.surface else { return } let action = "select_all" if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { AppDelegate.logger.warning("action failed action=\(action)") } } @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT) } @IBAction func splitLeft(_ sender: Any) { guard let surface = self.surface else { return } ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_LEFT) } @IBAction func splitDown(_ sender: Any) { guard let surface = self.surface else { return } ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_DOWN) } @IBAction func splitUp(_ sender: Any) { guard let surface = self.surface else { return } ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_UP) } @objc func resetTerminal(_ sender: Any) { guard let surface = self.surface else { return } let action = "reset" if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { AppDelegate.logger.warning("action failed action=\(action)") } } @objc func toggleTerminalInspector(_ sender: Any) { guard let surface = self.surface else { return } let action = "inspector:toggle" if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { AppDelegate.logger.warning("action failed action=\(action)") } } @IBAction func changeTitle(_ sender: Any) { promptTitle() } /// Show a user notification and associate it with this surface func showUserNotification(title: String, body: String) { let content = UNMutableNotificationContent() content.title = title content.subtitle = self.title content.body = body content.sound = UNNotificationSound.default content.categoryIdentifier = Ghostty.userNotificationCategory content.userInfo = ["surface": self.uuid.uuidString] let uuid = UUID().uuidString let request = UNNotificationRequest( identifier: uuid, content: content, trigger: nil ) // Note the callback may be executed on a background thread as documented // so we need @MainActor since we're reading/writing view state. UNUserNotificationCenter.current().add(request) { @MainActor error in if let error = error { AppDelegate.logger.error("Error scheduling user notification: \(error)") return } // We need to keep track of this notification so we can remove it // under certain circumstances self.notificationIdentifiers.insert(uuid) // If we're focused then we schedule to remove the notification // after a few seconds. If we gain focus we automatically remove it // in focusDidChange. if (self.focused) { Task { @MainActor [weak self] in try await Task.sleep(for: .seconds(3)) self?.notificationIdentifiers.remove(uuid) UNUserNotificationCenter.current() .removeDeliveredNotifications(withIdentifiers: [uuid]) } } } } /// Handle a user notification click func handleUserNotification(notification: UNNotification, focus: Bool) { let id = notification.request.identifier guard self.notificationIdentifiers.remove(id) != nil else { return } if focus { self.window?.makeKeyAndOrderFront(self) Ghostty.moveFocus(to: self) } } struct DerivedConfig { let backgroundColor: Color let backgroundOpacity: Double let macosWindowShadow: Bool let windowTitleFontFamily: String? let windowAppearance: NSAppearance? init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.backgroundOpacity = 1 self.macosWindowShadow = true self.windowTitleFontFamily = nil self.windowAppearance = nil } init(_ config: Ghostty.Config) { self.backgroundColor = config.backgroundColor self.backgroundOpacity = config.backgroundOpacity self.macosWindowShadow = config.macosWindowShadow self.windowTitleFontFamily = config.windowTitleFontFamily self.windowAppearance = .init(ghosttyConfig: config) } } // MARK: - Codable enum CodingKeys: String, CodingKey { case pwd case uuid } required convenience init(from decoder: Decoder) throws { // Decoding uses the global Ghostty app guard let del = NSApplication.shared.delegate, let appDel = del as? AppDelegate, let app = appDel.ghostty.app else { throw TerminalRestoreError.delegateInvalid } let container = try decoder.container(keyedBy: CodingKeys.self) let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid)) var config = Ghostty.SurfaceConfiguration() config.workingDirectory = try container.decode(String?.self, forKey: .pwd) self.init(app, baseConfig: config, uuid: uuid) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(pwd, forKey: .pwd) try container.encode(uuid.uuidString, forKey: .uuid) } } } // MARK: - NSTextInputClient extension Ghostty.SurfaceView: NSTextInputClient { func hasMarkedText() -> Bool { return markedText.length > 0 } func markedRange() -> NSRange { guard markedText.length > 0 else { return NSRange() } return NSRange(0...(markedText.length-1)) } func selectedRange() -> NSRange { guard let surface = self.surface else { return NSRange() } // Get our range from the Ghostty API. There is a race condition between getting the // range and actually using it since our selection may change but there isn't a good // way I can think of to solve this for AppKit. var text = ghostty_text_s() guard ghostty_surface_read_selection(surface, &text) else { return NSRange() } defer { ghostty_surface_free_text(surface, &text) } return NSRange(location: Int(text.offset_start), length: Int(text.offset_len)) } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { switch string { case let v as NSAttributedString: self.markedText = NSMutableAttributedString(attributedString: v) case let v as String: self.markedText = NSMutableAttributedString(string: v) default: print("unknown marked text: \(string)") } // If we're not in a keyDown event, then we want to update our preedit // text immediately. This can happen due to external events, for example // changing keyboard layouts while composing: (1) set US intl (2) type ' // to enter dead key state (3) if keyTextAccumulator == nil { syncPreedit() } } func unmarkText() { if self.markedText.length > 0 { self.markedText.mutableString.setString("") syncPreedit() } } func validAttributesForMarkedText() -> [NSAttributedString.Key] { return [] } func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { // Ghostty.logger.warning("pressure substring range=\(range) selectedRange=\(self.selectedRange())") guard let surface = self.surface else { return nil } // If the range is empty then we don't need to return anything guard range.length > 0 else { return nil } // I used to do a bunch of testing here that the range requested matches the // selection range or contains it but a lot of macOS system behaviors request // bogus ranges I truly don't understand so we just always return the // attributed string containing our selection which is... weird but works? // Get our selection text var text = ghostty_text_s() guard ghostty_surface_read_selection(surface, &text) else { return nil } defer { ghostty_surface_free_text(surface, &text) } // If we can get a font then we use the font. This should always work // since we always have a primary font. The only scenario this doesn't // work is if someone is using a non-CoreText build which would be // unofficial. var attributes: [ NSAttributedString.Key : Any ] = [:]; if let fontRaw = ghostty_surface_quicklook_font(surface) { // Memory management here is wonky: ghostty_surface_quicklook_font // will create a copy of a CTFont, Swift will auto-retain the // unretained value passed into the dict, so we release the original. let font = Unmanaged.fromOpaque(fontRaw) attributes[.font] = font.takeUnretainedValue() font.release() } return .init(string: String(cString: text.text), attributes: attributes) } func characterIndex(for point: NSPoint) -> Int { return 0 } func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { guard let surface = self.surface else { return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0) } // Ghostty will tell us where it thinks an IME keyboard should render. var x: Double = 0; var y: Double = 0; // QuickLook never gives us a matching range to our selection so if we detect // this then we return the top-left selection point rather than the cursor point. // This is hacky but I can't think of a better way to get the right IME vs. QuickLook // point right now. I'm sure I'm missing something fundamental... if range.length > 0 && range != self.selectedRange() { // QuickLook var text = ghostty_text_s() if ghostty_surface_read_selection(surface, &text) { // The -2/+2 here is subjective. QuickLook seems to offset the rectangle // a bit and I think these small adjustments make it look more natural. x = text.tl_px_x - 2; y = text.tl_px_y + 2; // Free our text ghostty_surface_free_text(surface, &text) } else { ghostty_surface_ime_point(surface, &x, &y) } } else { ghostty_surface_ime_point(surface, &x, &y) } // Ghostty coordinates are in top-left (0, 0) so we have to convert to // bottom-left since that is what UIKit expects let viewRect = NSMakeRect(x, frame.size.height - y, 0, 0) // Convert the point to the window coordinates let winRect = self.convert(viewRect, to: nil) // Convert from view to screen coordinates guard let window = self.window else { return winRect } return window.convertToScreen(winRect) } func insertText(_ string: Any, replacementRange: NSRange) { // We must have an associated event guard NSApp.currentEvent != nil else { return } guard let surfaceModel else { return } // We want the string view of the any value var chars = "" switch (string) { case let v as NSAttributedString: chars = v.string case let v as String: chars = v default: return } // If insertText is called, our preedit must be over. unmarkText() // If we have an accumulator we're in another key event so we just // accumulate and return. if var acc = keyTextAccumulator { acc.append(chars) keyTextAccumulator = acc return } surfaceModel.sendText(chars) } /// This function needs to exist for two reasons: /// 1. Prevents an audible NSBeep for unimplemented actions. /// 2. Allows us to properly encode super+key input events that we don't handle override func doCommand(by selector: Selector) { // If we are being processed by performKeyEquivalent with a command binding, // we send it back through the event system so it can be encoded. if let lastPerformKeyEvent, let current = NSApp.currentEvent, lastPerformKeyEvent == current.timestamp { NSApp.sendEvent(current) return } print("SEL: \(selector)") } /// Sync the preedit state based on the markedText value to libghostty private func syncPreedit(clearIfNeeded: Bool = true) { guard let surface else { return } if markedText.length > 0 { let str = markedText.string let len = str.utf8CString.count if len > 0 { markedText.string.withCString { ptr in // Subtract 1 for the null terminator ghostty_surface_preedit(surface, ptr, UInt(len - 1)) } } } else if clearIfNeeded { // If we had marked text before but don't now, we're no longer // in a preedit state so we can clear it. ghostty_surface_preedit(surface, nil, 0) } } } // MARK: Services // https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/SysServices/Articles/using.html extension Ghostty.SurfaceView: NSServicesMenuRequestor { override func validRequestor( forSendType sendType: NSPasteboard.PasteboardType?, returnType: NSPasteboard.PasteboardType? ) -> Any? { // Types that we accept sent to us let accepted: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")] // We can always receive the accepted types if (returnType == nil || accepted.contains(returnType!)) { return self } // If we have a selection we can send the accepted types too if ((self.surface != nil && ghostty_surface_has_selection(self.surface)) && (sendType == nil || accepted.contains(sendType!)) ) { return self } return super.validRequestor(forSendType: sendType, returnType: returnType) } func writeSelection( to pboard: NSPasteboard, types: [NSPasteboard.PasteboardType] ) -> Bool { guard let surface = self.surface else { return false } // Read the selection var text = ghostty_text_s() guard ghostty_surface_read_selection(surface, &text) else { return false } defer { ghostty_surface_free_text(surface, &text) } pboard.declareTypes([.string], owner: nil) pboard.setString(String(cString: text.text), forType: .string) return true } func readSelection(from pboard: NSPasteboard) -> Bool { guard let str = pboard.getOpinionatedStringContents() else { return false } let len = str.utf8CString.count if (len == 0) { return true } str.withCString { ptr in // len includes the null terminator so we do len - 1 ghostty_surface_text(surface, ptr, UInt(len - 1)) } return true } } // MARK: NSMenuItemValidation extension Ghostty.SurfaceView: NSMenuItemValidation { func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { case #selector(pasteSelection): let pb = NSPasteboard.ghosttySelection guard let str = pb.getOpinionatedStringContents() else { return false } return !str.isEmpty default: return true } } } // MARK: NSDraggingDestination extension Ghostty.SurfaceView { static let dropTypes: Set = [ .string, .fileURL, .URL ] override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { guard let types = sender.draggingPasteboard.types else { return [] } // If the dragging object contains none of our types then we return none. // This shouldn't happen because AppKit should guarantee that we only // receive types we registered for but its good to check. if Set(types).isDisjoint(with: Self.dropTypes) { return [] } // We use copy to get the proper icon return .copy } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { let pb = sender.draggingPasteboard let content: String? if let url = pb.string(forType: .URL) { // URLs first, they get escaped as-is. content = Ghostty.Shell.escape(url) } else if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL], urls.count > 0 { // File URLs next. They get escaped individually and then joined by a // space if there are multiple. content = urls .map { Ghostty.Shell.escape($0.path) } .joined(separator: " ") } else if let str = pb.string(forType: .string) { // Strings are not escaped because they may be copy/pasting a // command they want to execute. content = str } else { content = nil } if let content { DispatchQueue.main.async { self.insertText( content, replacementRange: NSMakeRange(0, 0) ) } return true } return false } } // MARK: Accessibility extension Ghostty.SurfaceView { /// Indicates that this view should be exposed to accessibility tools like VoiceOver. /// By returning true, we make the terminal surface accessible to screen readers /// and other assistive technologies. override func isAccessibilityElement() -> Bool { return true } /// Defines the accessibility role for this view, which helps assistive technologies /// understand what kind of content this view contains and how users can interact with it. override func accessibilityRole() -> NSAccessibility.Role? { /// We use .textArea because the terminal surface is essentially an editable text area /// where users can input commands and view output. return .textArea } override func accessibilityHelp() -> String? { return "Terminal content area" } override func accessibilityValue() -> Any? { return cachedScreenContents.get() } /// Returns the range of text that is currently selected in the terminal. /// This allows VoiceOver and other assistive technologies to understand /// what text the user has selected. override func accessibilitySelectedTextRange() -> NSRange { return selectedRange() } /// Returns the currently selected text as a string. /// This allows assistive technologies to read the selected content. override func accessibilitySelectedText() -> String? { guard let surface = self.surface else { return nil } // Attempt to read the selection var text = ghostty_text_s() guard ghostty_surface_read_selection(surface, &text) else { return nil } defer { ghostty_surface_free_text(surface, &text) } let str = String(cString: text.text) return str.isEmpty ? nil : str } /// Returns the number of characters in the terminal content. /// This helps assistive technologies understand the size of the content. override func accessibilityNumberOfCharacters() -> Int { let content = cachedScreenContents.get() return content.count } /// Returns the visible character range for the terminal. /// For terminals, we typically show all content as visible. override func accessibilityVisibleCharacterRange() -> NSRange { let content = cachedScreenContents.get() return NSRange(location: 0, length: content.count) } /// Returns the line number for a given character index. /// This helps assistive technologies navigate by line. override func accessibilityLine(for index: Int) -> Int { let content = cachedScreenContents.get() let substring = String(content.prefix(index)) return substring.components(separatedBy: .newlines).count - 1 } /// Returns a substring for the given range. /// This allows assistive technologies to read specific portions of the content. override func accessibilityString(for range: NSRange) -> String? { let content = cachedScreenContents.get() guard let swiftRange = Range(range, in: content) else { return nil } return String(content[swiftRange]) } /// Returns an attributed string for the given range. /// /// Note: right now this only applies font information. One day it'd be nice to extend /// this to copy styling information as well but we need to augment Ghostty core to /// expose that. /// /// This provides styling information to assistive technologies. override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? { guard let surface = self.surface else { return nil } guard let plainString = accessibilityString(for: range) else { return nil } var attributes: [NSAttributedString.Key: Any] = [:] // Try to get the font from the surface if let fontRaw = ghostty_surface_quicklook_font(surface) { let font = Unmanaged.fromOpaque(fontRaw) attributes[.font] = font.takeUnretainedValue() font.release() } return NSAttributedString(string: plainString, attributes: attributes) } } /// Caches a value for some period of time, evicting it automatically when that time expires. /// We use this to cache our surface content. This probably should be extracted some day /// to a more generic helper. class CachedValue { private var value: T? private let fetch: () -> T private let duration: Duration private var expiryTask: Task? init(duration: Duration, fetch: @escaping () -> T) { self.duration = duration self.fetch = fetch } deinit { expiryTask?.cancel() } func get() -> T { if let value { return value } // We don't have a value (or it expired). Fetch and store. let result = fetch() let now = ContinuousClock.now let expires = now + duration self.value = result // Schedule a task to clear the value expiryTask = Task { [weak self] in do { try await Task.sleep(until: expires) self?.value = nil self?.expiryTask = nil } catch { // Task was cancelled, do nothing } } return result } }