Files
ghostty/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
Akinori Musha d60a16c146 macos: avoid replaying keys that commit preedit
Refs #10460
Related: #12518

When an input method commits all or part of marked text during keyDown,
AppKit returns the committed text through insertText. Treat that as
text committed by the input method instead of replaying the original key
event to the terminal.

Previously this path only handled arrow-key commits specially. A
control-key shortcut that commits preedit text could still be encoded as
the original control input after composition, such as ctrl+j becoming LF.

Send committed preedit text as a text-only event for any key that causes
the commit. Only replay arrow navigation keys that the existing Korean
IME handling expects, and keep plain left-arrow suppressed because AppKit
already leaves the caret in place.

AI usage: OpenAI Codex helped investigate, implement, test, and refine
this change. I reviewed and tested the resulting code.
2026-05-01 23:18:42 +09:00

2385 lines
100 KiB
Swift

import AppKit
import Combine
import SwiftUI
import CoreText
import UserNotifications
import GhosttyKit
extension Ghostty {
/// The NSView implementation for a terminal surface.
class SurfaceView: OSSurfaceView, Codable, Identifiable {
// The current title of the surface as defined by the pty. This can be
// changed with escape codes.
@Published private(set) var title: String = "" {
didSet {
if !title.isEmpty {
titleFallbackTimer?.invalidate()
titleFallbackTimer = nil
}
}
}
// The progress report (if any)
override var progressReport: Action.ProgressReport? {
didSet {
// Cancel any existing timer
progressReportTimer?.invalidate()
progressReportTimer = nil
// If we have a new progress report, start a timer to remove it after 15 seconds
if progressReport != nil {
progressReportTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in
self?.progressReport = nil
self?.progressReportTimer = nil
}
}
}
}
// The currently active key sequence. The sequence is not active if this is empty.
@Published var keySequence: [KeyboardShortcut] = []
// The current search state. When non-nil, the search overlay should be shown.
override var searchState: SearchState? {
didSet {
if let searchState {
// I'm not a Combine expert so if there is a better way to do this I'm
// all ears. What we're doing here is grabbing the latest needle. If the
// needle is less than 3 chars, we debounce it for a few hundred ms to
// avoid kicking off expensive searches.
searchNeedleCancellable = searchState.$needle
.removeDuplicates()
.map { needle -> AnyPublisher<String, Never> in
if needle.isEmpty || needle.count >= 3 {
return Just(needle).eraseToAnyPublisher()
} else {
return Just(needle)
.delay(for: .milliseconds(300), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
.switchToLatest()
.sink { [weak self] needle in
guard let surface = self?.surface else { return }
let action = "search:\(needle)"
ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))
}
} else if oldValue != nil {
searchNeedleCancellable = nil
guard let surface = self.surface else { return }
let action = "end_search"
ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))
}
}
}
// Cancellable for search state needle changes
private var searchNeedleCancellable: AnyCancellable?
// Whether the pointer should be visible or not
@Published private(set) var pointerStyle: CursorStyle = .horizontalText
// Whether the mouse is currently over this surface
@Published private(set) var mouseOverSurface: Bool = false
// The last known mouse location in the surface's local coordinate space,
// used by overlays such as the split drag handle reveal region.
@Published private(set) var mouseLocationInSurface: CGPoint?
// Whether the cursor is currently visible (not hidden by typing, etc.)
@Published private(set) var cursorVisible: Bool = true
/// 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?
/// 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?
// A content size received through sizeDidChange that may in some cases
// be different from the frame size.
private var contentSizeBacking: NSSize?
private var contentSize: NSSize {
get { return contentSizeBacking ?? frame.size }
set { contentSizeBacking = newValue }
}
// 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 or no inspector is active.
var inspector: Ghostty.Inspector? {
guard let surface = self.surface else { return nil }
guard let cInspector = ghostty_surface_inspector(surface) else { return nil }
return Ghostty.Inspector(cInspector: cInspector)
}
// 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.
override var surface: ghostty_surface_t? {
surfaceModel?.unsafeCValue
}
/// Current scrollbar state, cached here for persistence across rebuilds
/// of the SwiftUI view hierarchy, for example when changing splits
var scrollbar: Ghostty.Action.Scrollbar?
// Notification identifiers associated with this surface
var notificationIdentifiers: Set<String> = []
private var markedText: NSMutableAttributedString
private(set) var focused: Bool = true
private var prevPressureStage: Int = 0
private var appearanceObserver: NSKeyValueObservation?
// This is set to non-null during keyDown to accumulate insertText contents
private var keyTextAccumulator: [String]?
// True when we've consumed a left mouse-down only to move focus and
// should suppress the matching mouse-up from being reported.
private var suppressNextLeftMouseUp: Bool = false
// 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?
// Timer to remove progress report after 15 seconds
private var progressReportTimer: 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<String>
private(set) var cachedVisibleContents: CachedValue<String>
/// Event monitor (see individual events for why)
private var eventMonitor: Any?
// 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()
// 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(id: uuid, frame: NSRect(x: 0, y: 0, width: 800, height: 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(ghosttyDidChangeKeyTable),
name: Ghostty.Notification.didChangeKeyTable,
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()
// 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)
// Cancel progress report timer
progressReportTimer?.invalidate()
}
override func endSearch() {
Ghostty.moveFocus(to: self)
super.endSearch()
}
override func focusDidChange(_ focused: Bool) {
guard let surface = self.surface else { return }
guard self.focused != focused else { return }
self.focused = focused
// If we lost our focus then remove the mouse event suppression so
// our mouse release event leaving the surface can properly be
// sent to stop things like mouse selection.
if !focused {
suppressNextLeftMouseUp = false
}
// Notify libghostty
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 = []
}
}
}
override 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))
// Store this size so we can reuse it when backing properties change
contentSize = size
}
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 = .verticalText
case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU:
pointerStyle = .contextMenu
case GHOSTTY_MOUSE_SHAPE_CROSSHAIR:
pointerStyle = .crosshair
case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED:
pointerStyle = .operationNotAllowed
default:
// We ignore unknown shapes.
return
}
}
func setCursorVisibility(_ visible: Bool) {
cursorVisible = visible
// 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")
// Make the text field the first responder so it gets focus
alert.window.initialFirstResponder = textField
let completionHandler: (NSApplication.ModalResponse) -> Void = { [weak self] response in
guard let self else { return }
// Check if the user clicked "OK"
guard response == .alertFirstButtonReturn else { return }
// 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
}
}
// We prefer to run our alert in a sheet modal if we have a window.
if let window {
alert.beginSheetModal(for: window, completionHandler: completionHandler)
} else {
// On macOS 26 RC, this codepath results in the "OK" button not being
// visible. The above codepath should be taken most times but I'm just
// noting this as something I noticed consistently.
completionHandler(alert.runModal())
}
}
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? {
let isCommandPaletteVisible = (event.window?.windowController as? BaseTerminalController)?
.commandPaletteIsShowing == true
guard !isCommandPaletteVisible else {
// We don't want to process events that
// are supposed to be handled by CommandPaletteView
return event
}
// 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.
guard
let location = window.contentView?.convert(event.locationInWindow, from: nil)
else {
return event
}
// We should use window to perform hitTest here,
// because there could be some other overlays on top, like search bar
guard window.contentView?.hitTest(location) == self else { return event }
// We always assume that we're resetting our mouse suppression
// unless we see the specific scenario below to set it.
suppressNextLeftMouseUp = false
// If we're already the first responder then no focus transfer is
// happening, so the click should continue as normal.
guard window.firstResponder !== self else {
return event
}
// If our window/app is already focused, then this click is only
// being used to transfer split focus. Consume it so it does not
// get forwarded to the terminal as a mouse click.
if NSApp.isActive && window.isKeyWindow {
window.makeFirstResponder(self)
suppressNextLeftMouseUp = true
return nil
}
// 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_HEALTHY
}
}
@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 ghosttyDidChangeKeyTable(notification: SwiftUI.Notification) {
guard let action = notification.userInfo?[Ghostty.Notification.KeyTableKey] as? Ghostty.Action.KeyTable else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
switch action {
case .activate(let name):
self.keyTables.append(name)
case .deactivate:
_ = self.keyTables.popLast()
case .deactivateAll:
self.keyTables.removeAll()
}
}
}
@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
let scaledSize = self.convertToBacking(contentSize)
setSurfaceSize(width: UInt32(scaledSize.width), height: UInt32(scaledSize.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) {
// If this mouse-up corresponds to a focus-only click transfer,
// suppress it so we don't emit a release without a press.
if suppressNextLeftMouseUp {
suppressNextLeftMouseUp = false
return
}
// 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 }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
let button = Ghostty.Input.MouseButton(fromNSEventButtonNumber: event.buttonNumber)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, button.cMouseButton, mods)
}
override func otherMouseUp(with event: NSEvent) {
guard let surface = self.surface else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
let button = Ghostty.Input.MouseButton(fromNSEventButtonNumber: event.buttonNumber)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, button.cMouseButton, 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) {
mouseOverSurface = true
super.mouseEntered(with: event)
let pos = self.convert(event.locationInWindow, from: nil)
mouseLocationInSurface = pos
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 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) {
mouseOverSurface = false
mouseLocationInSurface = nil
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) {
let pos = self.convert(event.locationInWindow, from: nil)
mouseLocationInSurface = pos
guard let surfaceModel else { return }
// Convert window position to view position. Note (0, 0) is bottom left.
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,
!controller.commandPaletteIsShowing,
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.ghostty.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 dispatch 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)
// 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, then press backspace
// or ctrl+h. This should only cancel the composing state but not
// actually delete the prior input characters (prior to the composing).
let composing = markedText.length > 0 || markedTextBefore
// The input method may commit all or part of the preedit text via
// insertText while handling a key that should not itself be
// encoded. Send that committed text separately, then only replay
// keys that should still affect the terminal after committing.
if markedTextBefore,
let list = keyTextAccumulator,
list.count > 0 {
for text in list {
if Ghostty.SurfaceView.shouldSuppressComposingControlInput(
text,
composing: composing
) {
continue
}
_ = committedPreeditTextAction(action, text: text)
}
if shouldReplayCommittedPreeditKey(translationEvent) {
_ = keyAction(
action,
event: event,
translationEvent: translationEvent,
composing: false
)
}
return
}
if let list = keyTextAccumulator, list.count > 0 {
// Accumulated text from interpretKeyEvents (committed by the IME).
for text in list {
// Drop bare control characters the IME accumulated while
// composing so they don't leak through to the terminal.
if Ghostty.SurfaceView.shouldSuppressComposingControlInput(
text,
composing: composing
) {
continue
}
// We've composed a character; send it down. keyAction's
// default composing=false applies because this is the
// committed result of a composition, not in-progress preedit.
_ = keyAction(
action,
event: event,
translationEvent: translationEvent,
text: text
)
}
} else {
// Raw control characters (e.g. ctrl+h) arriving during
// composition belong to the IME, not the terminal.
if Ghostty.SurfaceView.shouldSuppressComposingControlInput(
event.characters,
composing: composing
) {
return
}
// We have no accumulated text so this is a normal key event.
_ = keyAction(
action,
event: event,
translationEvent: translationEvent,
text: translationEvent.ghosttyCharacters,
composing: composing
)
}
}
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 {
// We only care about key down events. It might not even be possible
// to receive any other event type here.
guard event.type == .keyDown else { 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
}
// Get information about if this is a binding.
let bindingFlags = surfaceModel.flatMap { surface in
var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
return (event.characters ?? "").withCString { ptr in
ghosttyEvent.text = ptr
return surface.keyIsBinding(ghosttyEvent)
}
}
// If this is a binding then we want to perform it.
if let bindingFlags {
// Attempt to trigger a menu item for this key binding. We only do this if:
// - We're not in a key sequence or table (those are separate bindings)
// - The binding is NOT `all` (menu uses FirstResponder chain)
// - The binding is NOT `performable` (menu will always consume)
// - The binding is `consumed` (unconsumed bindings should pass through
// to the terminal, so we must not intercept them for the menu)
if keySequence.isEmpty,
keyTables.isEmpty,
bindingFlags.isDisjoint(with: [.all, .performable]),
bindingFlags.contains(.consumed) {
if let appDelegate = NSApp.delegate as? AppDelegate,
appDelegate.performGhosttyBindingMenuKeyEquivalent(with: event) {
return true
}
}
self.keyDown(with: event)
return true
}
let equivalent: String
switch event.charactersIgnoringModifiers {
case "\r":
// Pass C-<return> 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)
}
}
private func shouldReplayCommittedPreeditKey(_ event: NSEvent) -> Bool {
guard let key = Ghostty.Input.Key(keyCode: event.keyCode) else { return false }
switch key {
case .arrowDown, .arrowRight, .arrowUp:
return true
case .arrowLeft:
// Don't replay plain left-arrow because AppKit already leaves
// the caret in place after Korean IMEs commit preedit text.
return !event.modifierFlags.isDisjoint(with: [.shift, .control, .option, .command])
default:
return false
}
}
private func committedPreeditTextAction(
_ action: ghostty_input_action_e,
text: String
) -> Bool {
guard let surface = self.surface else { return false }
var key_ev = ghostty_input_key_s()
key_ev.action = action
key_ev.keycode = 0
key_ev.text = nil
key_ev.composing = false
key_ev.mods = GHOSTTY_MODS_NONE
key_ev.consumed_mods = GHOSTTY_MODS_NONE
key_ev.unshifted_codepoint = 0
return text.withCString { ptr in
key_ev.text = ptr
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<CTFont>.fromOpaque(fontRaw)
attributes[.font] = font.takeUnretainedValue()
font.release()
}
// Ghostty coordinate system is top-left, convert to bottom-left for AppKit
let pt = NSPoint(x: text.tl_px_x, y: 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 let text = self.accessibilitySelectedText(), text.count > 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")
item = menu.addItem(withTitle: "Terminal Read-only", action: #selector(toggleReadonly(_:)), keyEquivalent: "")
item.setImageIfDesired(systemSymbolName: "eye.fill")
item.state = readonly ? .on : .off
menu.addItem(.separator())
item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "")
item.setImageIfDesired(systemSymbolName: "pencil.line")
item = menu.addItem(withTitle: "Change Terminal Title...", action: #selector(changeTitle(_:)), keyEquivalent: "")
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.lengthOfBytes(using: .utf8))) {
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.lengthOfBytes(using: .utf8))) {
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.lengthOfBytes(using: .utf8))) {
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.lengthOfBytes(using: .utf8))) {
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.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func find(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "start_search"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func selectionForFind(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "search_selection"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func scrollToSelection(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "scroll_to_selection"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func findNext(_ sender: Any?) {
_ = self.navigateSearchToNext()
}
@IBAction func findPrevious(_ sender: Any?) {
_ = navigateSearchToPrevious()
}
@IBAction func findHide(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "end_search"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func toggleReadonly(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "toggle_readonly"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
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.lengthOfBytes(using: .utf8))) {
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.lengthOfBytes(using: .utf8))) {
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, requireFocus: Bool = true) {
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.id.uuidString,
"requireFocus": requireFocus,
]
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 backgroundBlur: Ghostty.Config.BackgroundBlur
let macosWindowShadow: Bool
let windowTitleFontFamily: String?
let windowAppearance: NSAppearance?
let scrollbar: Ghostty.Config.Scrollbar
init() {
self.backgroundColor = Color(NSColor.windowBackgroundColor)
self.backgroundOpacity = 1
self.backgroundBlur = .disabled
self.macosWindowShadow = true
self.windowTitleFontFamily = nil
self.windowAppearance = nil
self.scrollbar = .system
}
init(_ config: Ghostty.Config) {
self.backgroundColor = config.backgroundColor
self.backgroundOpacity = config.backgroundOpacity
self.backgroundBlur = config.backgroundBlur
self.macosWindowShadow = config.macosWindowShadow
self.windowTitleFontFamily = config.windowTitleFontFamily
self.windowAppearance = .init(ghosttyConfig: config)
self.scrollbar = config.scrollbar
}
}
// MARK: - Codable
enum CodingKeys: String, CodingKey {
case pwd
case uuid
case title
case isUserSetTitle
}
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)
let savedTitle = try container.decodeIfPresent(String.self, forKey: .title)
let isUserSetTitle = try container.decodeIfPresent(Bool.self, forKey: .isUserSetTitle) ?? false
self.init(app, baseConfig: config, uuid: uuid)
// Restore the saved title after initialization
if let title = savedTitle {
self.title = title
// If this was a user-set title, we need to prevent it from being overwritten
if isUserSetTitle {
self.titleFromTerminal = title
}
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(pwd, forKey: .pwd)
try container.encode(id.uuidString, forKey: .uuid)
try container.encode(title, forKey: .title)
try container.encode(titleFromTerminal != nil, forKey: .isUserSetTitle)
}
}
}
// 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<CTFont>.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 NSRect(x: frame.origin.x, y: frame.origin.y, width: 0, height: 0)
}
// Ghostty will tell us where it thinks an IME keyboard should render.
var x: Double = 0
var y: Double = 0
var width: Double = cellSize.width
var height: Double = cellSize.height
// 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, &width, &height)
}
} else {
ghostty_surface_ime_point(surface, &x, &y, &width, &height)
}
if range.length == 0, width > 0 {
// This fixes #8493 while speaking
// My guess is that positive width doesn't make sense
// for the dictation microphone indicator
width = 0
x += cellSize.width * Double(range.location + range.length)
}
// Ghostty coordinates are in top-left (0, 0) so we have to convert to
// bottom-left since that is what UIKit expects
// when there's is no characters selected,
// width should be 0 so that dictation indicator
// can start in the right place
let viewRect = NSRect(
x: x,
y: frame.size.height - y,
width: width,
height: max(height, cellSize.height))
// 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)
}
}
/// 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)
}
}
/// True when `text` is a single C0 control character (U+0000-U+001F)
/// arriving while the IME is composing. Such input belongs to the IME
/// and must not be forwarded to the terminal.
static func shouldSuppressComposingControlInput(
_ text: String?,
composing: Bool
) -> Bool {
guard composing, let text else { return false }
let scalars = text.unicodeScalars
guard let scalar = scalars.first,
scalars.index(after: scalars.startIndex) == scalars.endIndex else {
return false
}
return scalar.value < 0x20
}
}
// 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? {
// This function confused me a bit so I'm going to add my own commentary on
// how this works. macOS sends this callback with the given send/return types and
// we must return the responder capable of handling the COMBINATION of those send
// and return types (or super up if we can't handle it).
//
// The "COMBINATION" bit is key: we might get sent a string (we can handle that)
// but get requested an image (we can't handle that at the time of writing this),
// so we must bubble up.
// Types we can receive
let receivable: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")]
// Types that we can send. Currently the same as receivable but I'm separating
// this out so we can modify this in the future.
let sendable: [NSPasteboard.PasteboardType] = receivable
// The sendable types that require a selection (currently all)
let sendableRequiresSelection = sendable
// If we expect no data to be sent/received we can obviously handle it (that's
// the nil check), otherwise it must conform to the types we support on both sides.
if (returnType == nil || receivable.contains(returnType!)) &&
(sendType == nil || sendable.contains(sendType!)) {
// If we're expected to send back a type that requires selection, then
// verify that we have a selection. We do this within this block because
// validateRequestor is called a LOT and we want to prevent unnecessary
// performance hits because `ghostty_surface_has_selection` isn't free.
if let sendType, sendableRequiresSelection.contains(sendType) {
if surface == nil || !ghostty_surface_has_selection(surface) {
return super.validRequestor(forSendType: sendType, returnType: returnType)
}
}
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
case #selector(findHide):
return searchState != nil
case #selector(toggleReadonly):
item.state = readonly ? .on : .off
return true
case #selector(copy(_:)):
// We only enable copy menu item when there're actual selected text
if let text = self.accessibilitySelectedText(), text.count > 0 {
return true
} else {
return false
}
default:
return true
}
}
}
// MARK: NSDraggingDestination
extension Ghostty.SurfaceView {
static let dropTypes: Set<NSPasteboard.PasteboardType> = [
.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: NSRange(location: 0, length: 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<CTFont>.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<T> {
private var value: T?
private let fetch: () -> T
private let duration: Duration
private var expiryTask: Task<Void, Never>?
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
}
}