Implement "Paste Selection" on macOS like Terminal.app (#4733)

As discussed in #2670 and #2722

- This uses an NSPasteboard with the name
`com.mitchellh.ghostty.selection` as a dedicated 'selection' clipboard
- Sets `supports_selection_clipboard` to true for macOS
- Sets the default `copy-on-select` config to `.true` for macOS
- Adds a "Paste Selection" menu item and default cmd+shift+v key binding
for macOS (to match Terminal.app)
This commit is contained in:
Mitchell Hashimoto
2025-01-08 09:39:21 -08:00
committed by GitHub
9 changed files with 78 additions and 21 deletions

View File

@ -35,6 +35,7 @@ class AppDelegate: NSObject,
@IBOutlet private var menuCopy: NSMenuItem? @IBOutlet private var menuCopy: NSMenuItem?
@IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuPaste: NSMenuItem?
@IBOutlet private var menuPasteSelection: NSMenuItem?
@IBOutlet private var menuSelectAll: NSMenuItem? @IBOutlet private var menuSelectAll: NSMenuItem?
@IBOutlet private var menuToggleVisibility: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem?
@ -353,6 +354,7 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy)
syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste)
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)
syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll)
syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit)

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23094" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct"> <document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23504" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies> <dependencies>
<deployment identifier="macosx"/> <deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23094"/> <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23504"/>
</dependencies> </dependencies>
<objects> <objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication"> <customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
@ -31,6 +31,7 @@
<outlet property="menuNextSplit" destination="bD7-ei-wKU" id="LeT-xw-eh4"/> <outlet property="menuNextSplit" destination="bD7-ei-wKU" id="LeT-xw-eh4"/>
<outlet property="menuOpenConfig" destination="BOF-NM-1cW" id="Nze-Go-glw"/> <outlet property="menuOpenConfig" destination="BOF-NM-1cW" id="Nze-Go-glw"/>
<outlet property="menuPaste" destination="i27-pK-umN" id="ICc-X2-gV3"/> <outlet property="menuPaste" destination="i27-pK-umN" id="ICc-X2-gV3"/>
<outlet property="menuPasteSelection" destination="akq-ov-Jjh" id="GS8-aQ-hVw"/>
<outlet property="menuPreviousSplit" destination="Lic-px-1wg" id="Rto-CG-yRe"/> <outlet property="menuPreviousSplit" destination="Lic-px-1wg" id="Rto-CG-yRe"/>
<outlet property="menuQuickTerminal" destination="1pv-LF-NBJ" id="glN-5B-IGi"/> <outlet property="menuQuickTerminal" destination="1pv-LF-NBJ" id="glN-5B-IGi"/>
<outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/> <outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/>
@ -185,6 +186,12 @@
<action selector="paste:" target="-1" id="ZKe-2B-mel"/> <action selector="paste:" target="-1" id="ZKe-2B-mel"/>
</connections> </connections>
</menuItem> </menuItem>
<menuItem title="Paste Selection" id="akq-ov-Jjh">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="pasteSelection:" target="-1" id="vo3-Rf-Udb"/>
</connections>
</menuItem>
<menuItem title="Select All" id="q2h-lq-e4r"> <menuItem title="Select All" id="q2h-lq-e4r">
<modifierMask key="keyEquivalentModifierMask"/> <modifierMask key="keyEquivalentModifierMask"/>
<connections> <connections>

View File

@ -389,9 +389,9 @@ class BaseTerminalController: NSWindowController,
} }
switch (request) { switch (request) {
case .osc_52_write: case let .osc_52_write(pasteboard):
guard case .confirm = action else { break } guard case .confirm = action else { break }
let pb = NSPasteboard.general let pb = pasteboard ?? NSPasteboard.general
pb.declareTypes([.string], owner: nil) pb.declareTypes([.string], owner: nil)
pb.setString(cc.contents, forType: .string) pb.setString(cc.contents, forType: .string)
case .osc_52_read, .paste: case .osc_52_read, .paste:

View File

@ -62,7 +62,7 @@ extension Ghostty {
// uses to interface with the application runtime environment. // uses to interface with the application runtime environment.
var runtime_cfg = ghostty_runtime_config_s( var runtime_cfg = ghostty_runtime_config_s(
userdata: Unmanaged.passUnretained(self).toOpaque(), userdata: Unmanaged.passUnretained(self).toOpaque(),
supports_selection_clipboard: false, supports_selection_clipboard: true,
wakeup_cb: { userdata in App.wakeup(userdata) }, wakeup_cb: { userdata in App.wakeup(userdata) },
action_cb: { app, target, action in App.action(app!, target: target, action: action) }, action_cb: { app, target, action in App.action(app!, target: target, action: action) },
read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) }, read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) },
@ -320,13 +320,13 @@ extension Ghostty {
let surfaceView = self.surfaceUserdata(from: userdata) let surfaceView = self.surfaceUserdata(from: userdata)
guard let surface = surfaceView.surface else { return } guard let surface = surfaceView.surface else { return }
// We only support the standard clipboard // Get our pasteboard
if (location != GHOSTTY_CLIPBOARD_STANDARD) { guard let pasteboard = NSPasteboard.ghostty(location) else {
return completeClipboardRequest(surface, data: "", state: state) return completeClipboardRequest(surface, data: "", state: state)
} }
// Get our string // Get our string
let str = NSPasteboard.general.getOpinionatedStringContents() ?? "" let str = pasteboard.getOpinionatedStringContents() ?? ""
completeClipboardRequest(surface, data: str, state: state) completeClipboardRequest(surface, data: str, state: state)
} }
@ -364,14 +364,12 @@ extension Ghostty {
static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer<CChar>?, location: ghostty_clipboard_e, confirm: Bool) { static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer<CChar>?, location: ghostty_clipboard_e, confirm: Bool) {
let surface = self.surfaceUserdata(from: userdata) let surface = self.surfaceUserdata(from: userdata)
// We only support the standard clipboard
if (location != GHOSTTY_CLIPBOARD_STANDARD) { return }
guard let pasteboard = NSPasteboard.ghostty(location) else { return }
guard let valueStr = String(cString: string!, encoding: .utf8) else { return } guard let valueStr = String(cString: string!, encoding: .utf8) else { return }
if !confirm { if !confirm {
let pb = NSPasteboard.general pasteboard.declareTypes([.string], owner: nil)
pb.declareTypes([.string], owner: nil) pasteboard.setString(valueStr, forType: .string)
pb.setString(valueStr, forType: .string)
return return
} }
@ -380,7 +378,7 @@ extension Ghostty {
object: surface, object: surface,
userInfo: [ userInfo: [
Notification.ConfirmClipboardStrKey: valueStr, Notification.ConfirmClipboardStrKey: valueStr,
Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write, Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write(pasteboard),
] ]
) )
} }

View File

@ -159,7 +159,7 @@ extension Ghostty {
case osc_52_read case osc_52_read
/// An application is attempting to write to the clipboard using OSC 52 /// An application is attempting to write to the clipboard using OSC 52
case osc_52_write case osc_52_write(OSPasteboard?)
/// The text to show in the clipboard confirmation prompt for a given request type /// The text to show in the clipboard confirmation prompt for a given request type
func text() -> String { func text() -> String {
@ -188,7 +188,7 @@ extension Ghostty {
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ: case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ:
return .osc_52_read return .osc_52_read
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE: case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE:
return .osc_52_write return .osc_52_write(nil)
default: default:
return nil return nil
} }

View File

@ -1127,6 +1127,14 @@ extension Ghostty {
} }
} }
@IBAction func pasteSelection(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "paste_from_selection"
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction override func selectAll(_ sender: Any?) { @IBAction override func selectAll(_ sender: Any?) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
let action = "select_all" let action = "select_all"
@ -1448,3 +1456,19 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
return true return true
} }
} }
// MARK: NSMenuItemValidation
extension Ghostty.SurfaceView: NSMenuItemValidation {
func validateMenuItem(_ item: NSMenuItem) -> Bool {
switch item.action {
case #selector(pasteSelection):
let pb = NSPasteboard.ghosttySelection
guard let str = pb.getOpinionatedStringContents() else { return false }
return !str.isEmpty
default:
return true
}
}
}

View File

@ -10,6 +10,7 @@ import AppKit
typealias OSView = NSView typealias OSView = NSView
typealias OSColor = NSColor typealias OSColor = NSColor
typealias OSSize = NSSize typealias OSSize = NSSize
typealias OSPasteboard = NSPasteboard
protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType { protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType {
associatedtype OSViewType: NSView associatedtype OSViewType: NSView
@ -34,6 +35,7 @@ import UIKit
typealias OSView = UIView typealias OSView = UIView
typealias OSColor = UIColor typealias OSColor = UIColor
typealias OSSize = CGSize typealias OSSize = CGSize
typealias OSPasteboard = UIPasteboard
protocol OSViewRepresentable: UIViewRepresentable { protocol OSViewRepresentable: UIViewRepresentable {
associatedtype OSViewType: UIView associatedtype OSViewType: UIView

View File

@ -1,6 +1,12 @@
import AppKit import AppKit
import GhosttyKit
extension NSPasteboard { extension NSPasteboard {
/// The pasteboard to used for Ghostty selection.
static var ghosttySelection: NSPasteboard = {
NSPasteboard(name: .init("com.mitchellh.ghostty.selection"))
}()
/// Gets the contents of the pasteboard as a string following a specific set of semantics. /// Gets the contents of the pasteboard as a string following a specific set of semantics.
/// Does these things in order: /// Does these things in order:
/// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one. /// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one.
@ -14,4 +20,18 @@ extension NSPasteboard {
} }
return self.string(forType: .string) return self.string(forType: .string)
} }
/// The pasteboard for the Ghostty enum type.
static func ghostty(_ clipboard: ghostty_clipboard_e) -> NSPasteboard? {
switch (clipboard) {
case GHOSTTY_CLIPBOARD_STANDARD:
return Self.general
case GHOSTTY_CLIPBOARD_SELECTION:
return Self.ghosttySelection
default:
return nil
}
}
} }

View File

@ -1389,13 +1389,10 @@ keybind: Keybinds = .{},
/// and the system clipboard on macOS. Middle-click paste is always enabled /// and the system clipboard on macOS. Middle-click paste is always enabled
/// even if this is `false`. /// even if this is `false`.
/// ///
/// The default value is true on Linux and false on macOS. macOS copy on /// The default value is true on Linux and macOS.
/// select behavior is not typical for applications so it is disabled by
/// default. On Linux, this is a standard behavior so it is enabled by
/// default.
@"copy-on-select": CopyOnSelect = switch (builtin.os.tag) { @"copy-on-select": CopyOnSelect = switch (builtin.os.tag) {
.linux => .true, .linux => .true,
.macos => .false, .macos => .true,
else => .false, else => .false,
}, },
@ -2749,6 +2746,13 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
.{ .toggle_fullscreen = {} }, .{ .toggle_fullscreen = {} },
); );
// Selection clipboard paste, matches Terminal.app
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .v }, .mods = .{ .super = true, .shift = true } },
.{ .paste_from_selection = {} },
);
// "Natural text editing" keybinds. This forces these keys to go back // "Natural text editing" keybinds. This forces these keys to go back
// to legacy encoding (not fixterms). It seems macOS users more than // to legacy encoding (not fixterms). It seems macOS users more than
// others are used to these keys so we set them as defaults. If // others are used to these keys so we set them as defaults. If