ghostty/macos/Sources/Ghostty/SurfaceView_AppKit.swift
Mitchell Hashimoto 50019bc766 minor comment changes
2024-06-18 17:09:29 -04:00

1058 lines
42 KiB
Swift

import SwiftUI
import UserNotifications
import GhosttyKit
extension Ghostty {
/// The NSView implementation for a terminal surface.
class SurfaceView: OSView, ObservableObject {
/// Unique ID per surface
let uuid: UUID
// The current title of the surface as defined by the pty. This can be
// changed with escape codes. This is public because the callbacks go
// to the app level and it is set from there.
@Published var title: String = "👻"
// The cell size of this surface. This is set by the core when the
// surface is first created and any time the cell size changes (i.e.
// when the font size changes). This is used to allow windows to be
// resized in discrete steps of a single cell.
@Published var cellSize: NSSize = .zero
// The health state of the surface. This currently only reflects the
// renderer health. In the future we may want to make this an enum.
@Published var healthy: Bool = true
// Any error while initializing the surface.
@Published var error: Error? = nil
// An initial size to request for a window. This will only affect
// then the view is moved to a new window.
var initialSize: NSSize? = nil
// 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 the pwd of the surface if it has one.
var pwd: String? {
guard let surface = self.surface else { return nil }
let v = String(unsafeUninitializedCapacity: 1024) {
Int(ghostty_surface_pwd(surface, $0.baseAddress, UInt($0.count)))
}
if (v.count == 0) { return nil }
return v
}
// Returns the inspector instance for this surface, or nil if the
// surface has been closed.
var inspector: ghostty_inspector_t? {
guard let surface = self.surface else { return nil }
return ghostty_surface_inspector(surface)
}
// True if the inspector should be visible
@Published var inspectorVisible: Bool = false {
didSet {
if (oldValue && !inspectorVisible) {
guard let surface = self.surface else { return }
ghostty_inspector_free(surface)
}
}
}
// Notification identifiers associated with this surface
var notificationIdentifiers: Set<String> = []
private(set) var surface: ghostty_surface_t?
private var markedText: NSMutableAttributedString
private var mouseEntered: Bool = false
private(set) var focused: Bool = true
private var cursor: NSCursor = .iBeam
private var cursorVisible: CursorVisibility = .visible
private var appearanceObserver: NSKeyValueObservation? = nil
// This is set to non-null during keyDown to accumulate insertText contents
private var keyTextAccumulator: [String]? = nil
// We need to support being a first responder so that we can get input events
override var acceptsFirstResponder: Bool { return true }
// I don't think we need this but this lets us know we should redraw our layer
// so we'll use that to tell ghostty to refresh.
override var wantsUpdateLayer: Bool { return true }
// State machine for mouse cursor visibility because every call to
// NSCursor.hide/unhide must be balanced.
enum CursorVisibility {
case visible
case hidden
case pendingVisible
case pendingHidden
}
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
self.markedText = NSMutableAttributedString()
self.uuid = uuid ?? .init()
// Initialize with some default frame size. The important thing is that this
// is non-zero so that our layer bounds are non-zero so that our renderer
// can do SOMETHING.
super.init(frame: NSMakeRect(0, 0, 800, 600))
// 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(windowDidChangeScreen),
name: NSWindow.didChangeScreenNotification,
object: nil)
// Setup our surface. This will also initialize all the terminal IO.
let surface_cfg = baseConfig ?? SurfaceConfiguration()
var surface_cfg_c = surface_cfg.ghosttyConfig(view: self)
guard let surface = ghostty_surface_new(app, &surface_cfg_c) else {
self.error = AppError.surfaceCreateError
return
}
self.surface = surface;
// Setup our tracking area so we get mouse moved events
updateTrackingAreas()
// Observe our appearance so we can report the correct value to libghostty.
// This is the best way I know of to get appearance change notifications.
self.appearanceObserver = observe(\.effectiveAppearance, options: [.new, .initial]) { view, change in
guard let appearance = change.newValue else { return }
guard let surface = view.surface else { return }
let scheme: ghostty_color_scheme_e
switch (appearance.name) {
case .aqua, .vibrantLight:
scheme = GHOSTTY_COLOR_SCHEME_LIGHT
case .darkAqua, .vibrantDark:
scheme = GHOSTTY_COLOR_SCHEME_DARK
default:
return
}
ghostty_surface_set_color_scheme(surface, scheme)
}
}
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)
// 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) }
// mouseExited is not called by AppKit one last time when the view
// closes so we do it manually to ensure our NSCursor state remains
// accurate.
if (mouseEntered) {
mouseExited(with: NSEvent())
}
guard let surface = self.surface else { return }
ghostty_surface_free(surface)
}
/// Close the surface early. This will free the associated Ghostty surface and the view will
/// no longer render. The view can never be used again. This is a way for us to free the
/// Ghostty resources while references may still be held to this view. I've found that SwiftUI
/// tends to hold this view longer than it should so we free the expensive stuff explicitly.
func close() {
// Remove any notifications associated with this surface
let identifiers = Array(self.notificationIdentifiers)
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
guard let surface = self.surface else { return }
ghostty_surface_free(surface)
self.surface = nil
}
func focusDidChange(_ focused: Bool) {
guard let surface = self.surface else { return }
guard self.focused != focused else { return }
self.focused = focused
ghostty_surface_set_focus(surface, focused)
}
func sizeDidChange(_ size: CGSize) {
guard let surface = self.surface else { return }
// 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)
ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height))
// Frame changes do not always call mouseEntered/mouseExited, so we do some
// calculations ourself to call those events.
if let window = self.window {
let mouseScreen = NSEvent.mouseLocation
let mouseWindow = window.convertPoint(fromScreen: mouseScreen)
let mouseView = self.convert(mouseWindow, from: nil)
let isEntered = self.isMousePoint(mouseView, in: bounds)
if (isEntered) {
mouseEntered(with: NSEvent())
} else {
mouseExited(with: NSEvent())
}
} else {
// If we don't have a window, then our mouse can NOT be in our view.
// When the window comes back, I believe this event fires again so
// we'll get a mouseEntered.
mouseExited(with: NSEvent())
}
}
func setCursorShape(_ shape: ghostty_mouse_shape_e) {
switch (shape) {
case GHOSTTY_MOUSE_SHAPE_DEFAULT:
cursor = .arrow
case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU:
cursor = .contextualMenu
case GHOSTTY_MOUSE_SHAPE_TEXT:
cursor = .iBeam
case GHOSTTY_MOUSE_SHAPE_CROSSHAIR:
cursor = .crosshair
case GHOSTTY_MOUSE_SHAPE_GRAB:
cursor = .openHand
case GHOSTTY_MOUSE_SHAPE_GRABBING:
cursor = .closedHand
case GHOSTTY_MOUSE_SHAPE_POINTER:
cursor = .pointingHand
case GHOSTTY_MOUSE_SHAPE_W_RESIZE:
cursor = .resizeLeft
case GHOSTTY_MOUSE_SHAPE_E_RESIZE:
cursor = .resizeRight
case GHOSTTY_MOUSE_SHAPE_N_RESIZE:
cursor = .resizeUp
case GHOSTTY_MOUSE_SHAPE_S_RESIZE:
cursor = .resizeDown
case GHOSTTY_MOUSE_SHAPE_NS_RESIZE:
cursor = .resizeUpDown
case GHOSTTY_MOUSE_SHAPE_EW_RESIZE:
cursor = .resizeLeftRight
case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT:
cursor = .iBeamCursorForVerticalLayout
case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED:
cursor = .operationNotAllowed
default:
// We ignore unknown shapes.
return
}
// Set our cursor immediately if our mouse is over our window
if (mouseEntered) { cursorUpdate(with: NSEvent()) }
if let window = self.window {
window.invalidateCursorRects(for: self)
}
}
func setCursorVisibility(_ visible: Bool) {
switch (cursorVisible) {
case .visible:
// If we want to be visible, do nothing. If we want to be hidden
// enter the pending state.
if (visible) { return }
cursorVisible = .pendingHidden
case .hidden:
// If we want to be hidden, do nothing. If we want to be visible
// enter the pending state.
if (!visible) { return }
cursorVisible = .pendingVisible
case .pendingVisible:
// If we want to be visible, do nothing because we're already pending.
// If we want to be hidden, we're already hidden so reset state.
if (visible) { return }
cursorVisible = .hidden
case .pendingHidden:
// If we want to be hidden, do nothing because we're pending that switch.
// If we want to be visible, we're already visible so reset state.
if (!visible) { return }
cursorVisible = .visible
}
if (mouseEntered) {
cursorUpdate(with: NSEvent())
}
}
// MARK: - Notifications
@objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) {
guard let healthAny = notification.userInfo?["health"] else { return }
guard let health = healthAny as? ghostty_renderer_health_e else { return }
healthy = health == GHOSTTY_RENDERER_HEALTH_OK
}
@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.
let id = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! NSNumber).uint32Value
ghostty_surface_set_display_id(surface, id)
}
// 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 resetCursorRects() {
discardCursorRects()
addCursorRect(frame, cursor: self.cursor)
}
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
ghostty_surface_set_size(surface, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height))
}
override func updateLayer() {
guard let surface = self.surface else { return }
ghostty_surface_draw(surface);
}
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
// "Override this method in a subclass to allow instances to respond to
// click-through. This allows the user to click on a view in an inactive
// window, activating the view with one click, instead of clicking first
// to make the window active and then clicking the view."
return true
}
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) {
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)
}
override func otherMouseDown(with event: NSEvent) {
guard let surface = self.surface else { return }
guard event.buttonNumber == 2 else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mods)
}
override func otherMouseUp(with event: NSEvent) {
guard let surface = self.surface else { return }
guard event.buttonNumber == 2 else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, mods)
}
override func rightMouseDown(with event: NSEvent) {
guard let surface = self.surface else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods)
}
override func rightMouseUp(with event: NSEvent) {
guard let surface = self.surface else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods)
}
override func mouseMoved(with event: NSEvent) {
guard let surface = self.surface else { return }
// Convert window position to view position. Note (0, 0) is bottom left.
let pos = self.convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y)
// If focus follows mouse is enabled then move focus to this surface.
if let window = self.window as? TerminalWindow,
window.isKeyWindow &&
window.focusFollowsMouse &&
!self.focused
{
Ghostty.moveFocus(to: self)
}
}
override func mouseDragged(with event: NSEvent) {
self.mouseMoved(with: event)
}
override func mouseEntered(with event: NSEvent) {
// For reasons unknown (Cocoaaaaaaaaa), mouseEntered is called
// multiple times in an unbalanced way with mouseExited when a new
// tab is created. In this scenario, we only want to process our
// callback once since this is stateful and we expect balancing.
if (mouseEntered) { return }
mouseEntered = true
// Update our cursor when we enter so we fully process our
// cursorVisible state.
cursorUpdate(with: NSEvent())
}
override func mouseExited(with event: NSEvent) {
// See mouseEntered
if (!mouseEntered) { return }
mouseEntered = false
// If the mouse is currently hidden, we want to show it when we exit
// this view. We go through the cursorVisible dance so that only
// cursorUpdate manages cursor state.
if (cursorVisible == .hidden) {
cursorVisible = .pendingVisible
cursorUpdate(with: NSEvent())
assert(cursorVisible == .visible)
// We set the state to pending hidden again for the next time
// we enter.
cursorVisible = .pendingHidden
}
}
override func scrollWheel(with event: NSEvent) {
guard let surface = self.surface else { return }
// Builds up the "input.ScrollMods" bitmask
var mods: Int32 = 0
var x = event.scrollingDeltaX
var y = event.scrollingDeltaY
if event.hasPreciseScrollingDeltas {
mods = 1
// 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?
}
// Determine our momentum value
var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE
switch (event.momentumPhase) {
case .began:
momentum = GHOSTTY_MOUSE_MOMENTUM_BEGAN
case .stationary:
momentum = GHOSTTY_MOUSE_MOMENTUM_STATIONARY
case .changed:
momentum = GHOSTTY_MOUSE_MOMENTUM_CHANGED
case .ended:
momentum = GHOSTTY_MOUSE_MOMENTUM_ENDED
case .cancelled:
momentum = GHOSTTY_MOUSE_MOMENTUM_CANCELLED
case .mayBegin:
momentum = GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN
default:
break
}
// Pack our momentum value into the mods bitmask
mods |= Int32(momentum.rawValue) << 1
ghostty_surface_mouse_scroll(surface, x, y, mods)
}
override func cursorUpdate(with event: NSEvent) {
switch (cursorVisible) {
case .visible, .hidden:
// Do nothing, stable state
break
case .pendingHidden:
NSCursor.hide()
cursorVisible = .hidden
case .pendingVisible:
NSCursor.unhide()
cursorVisible = .visible
}
cursor.set()
}
override func keyDown(with event: NSEvent) {
guard let surface = self.surface else {
self.interpretKeyEvents([event])
return
}
// 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
self.interpretKeyEvents([translationEvent])
// If we have text, then we've composed a character, send that down. We do this
// first because if we completed a preedit, the text will be available here
// AND we'll have a preedit.
var handled: Bool = false
if let list = keyTextAccumulator, list.count > 0 {
handled = true
for text in list {
keyAction(action, event: event, text: text)
}
}
// If we have marked text, we're in a preedit state. Send that down.
// If we don't have marked text but we had marked text before, then the preedit
// was cleared so we want to send down an empty string to ensure we've cleared
// the preedit.
if (markedText.length > 0 || markedTextBefore) {
handled = true
keyAction(action, event: event, preedit: markedText.string)
}
if (!handled) {
// No text or anything, we want to handle this manually.
keyAction(action, event: event)
}
}
override func keyUp(with event: NSEvent) {
keyAction(GHOSTTY_ACTION_RELEASE, event: event)
}
/// Special case handling for some control keys
override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Only process keys when Control is the only modifier
if (!event.modifierFlags.contains(.control) ||
!event.modifierFlags.isDisjoint(with: [.shift, .command, .option])) {
return false
}
// Only process key down events
if (event.type != .keyDown) {
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.
if (!focused) {
return false
}
let equivalent: String
switch (event.charactersIgnoringModifiers) {
case "/":
// Treat C-/ as C-_. We do this because C-/ makes macOS make a beep
// sound and we don't like the beep sound.
equivalent = "_"
default:
// Ignore other events
return false
}
let newEvent = NSEvent.keyEvent(
with: .keyDown,
location: event.locationInWindow,
modifierFlags: .control,
timestamp: event.timestamp,
windowNumber: event.windowNumber,
context: nil,
characters: equivalent,
charactersIgnoringModifiers: equivalent,
isARepeat: event.isARepeat,
keyCode: event.keyCode
)
self.keyDown(with: newEvent!)
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
}
// 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) {
guard let surface = self.surface else { return }
var key_ev = ghostty_input_key_s()
key_ev.action = action
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
key_ev.text = nil
key_ev.composing = false
ghostty_surface_key(surface, key_ev)
}
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, preedit: String) {
guard let surface = self.surface else { return }
preedit.withCString { ptr in
var key_ev = ghostty_input_key_s()
key_ev.action = action
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
key_ev.text = ptr
key_ev.composing = true
ghostty_surface_key(surface, key_ev)
}
}
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, text: String) {
guard let surface = self.surface else { return }
text.withCString { ptr in
var key_ev = ghostty_input_key_s()
key_ev.action = action
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
key_ev.text = ptr
ghostty_surface_key(surface, key_ev)
}
}
// MARK: Menu Handlers
@IBAction func copy(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "copy_to_clipboard"
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func paste(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "paste_from_clipboard"
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func pasteAsPlainText(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "paste_from_clipboard"
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction override func selectAll(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "select_all"
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
/// Show a user notification and associate it with this surface
func showUserNotification(title: String, body: String) {
let content = UNMutableNotificationContent()
content.title = title
content.subtitle = self.title
content.body = body
content.sound = UNNotificationSound.default
content.categoryIdentifier = Ghostty.userNotificationCategory
content.userInfo = ["surface": self.uuid.uuidString]
let uuid = UUID().uuidString
let request = UNNotificationRequest(
identifier: uuid,
content: content,
trigger: nil
)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
AppDelegate.logger.error("Error scheduling user notification: \(error)")
return
}
self.notificationIdentifiers.insert(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)
}
}
}
}
// 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 {
return NSRange()
}
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)")
}
}
func unmarkText() {
self.markedText.mutableString.setString("")
}
func validAttributesForMarkedText() -> [NSAttributedString.Key] {
return []
}
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
return nil
}
func characterIndex(for point: NSPoint) -> Int {
return 0
}
func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
guard let surface = self.surface else {
return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0)
}
// Ghostty will tell us where it thinks an IME keyboard should render.
var x: Double = 0;
var y: Double = 0;
ghostty_surface_ime_point(surface, &x, &y)
// Ghostty coordinates are in top-left (0, 0) so we have to convert to
// bottom-left since that is what UIKit expects
let viewRect = NSMakeRect(x, frame.size.height - y, 0, 0)
// Convert the point to the window coordinates
let winRect = self.convert(viewRect, to: nil)
// Convert from view to screen coordinates
guard let window = self.window else { return winRect }
return window.convertToScreen(winRect)
}
func insertText(_ string: Any, replacementRange: NSRange) {
// We must have an associated event
guard NSApp.currentEvent != nil else { return }
guard let surface = self.surface 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
}
let len = chars.utf8CString.count
if (len == 0) { return }
chars.withCString { ptr in
// len includes the null terminator so we do len - 1
ghostty_surface_text(surface, ptr, UInt(len - 1))
}
}
override func doCommand(by selector: Selector) {
// This currently just prevents NSBeep from interpretKeyEvents but in the future
// we may want to make some of this work.
print("SEL: \(selector)")
}
}
// MARK: Services
// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/SysServices/Articles/using.html
extension Ghostty.SurfaceView: NSServicesMenuRequestor {
override func validRequestor(
forSendType sendType: NSPasteboard.PasteboardType?,
returnType: NSPasteboard.PasteboardType?
) -> Any? {
// Types that we accept sent to us
let accepted: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")]
// We can always receive the accepted types
if (returnType == nil || accepted.contains(returnType!)) {
return self
}
// If we have a selection we can send the accepted types too
if ((self.surface != nil && ghostty_surface_has_selection(self.surface)) &&
(sendType == nil || accepted.contains(sendType!))
) {
return self
}
return super.validRequestor(forSendType: sendType, returnType: returnType)
}
func writeSelection(
to pboard: NSPasteboard,
types: [NSPasteboard.PasteboardType]
) -> Bool {
guard let surface = self.surface else { return false }
// We currently cap the maximum copy size to 1MB. iTerm2 I believe
// caps theirs at 0.1MB (configurable) so this is probably reasonable.
let v = String(unsafeUninitializedCapacity: 1000000) {
Int(ghostty_surface_selection(surface, $0.baseAddress, UInt($0.count)))
}
pboard.declareTypes([.string], owner: nil)
pboard.setString(v, 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
}
}