mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-05-24 05:40:15 +00:00
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.
2385 lines
100 KiB
Swift
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
|
|
}
|
|
}
|