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 menuPaste: NSMenuItem?
@IBOutlet private var menuPasteSelection: NSMenuItem?
@IBOutlet private var menuSelectAll: 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: "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: "toggle_split_zoom", menuItem: self.menuZoomSplit)

View File

@ -1,8 +1,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>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23094"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23504"/>
</dependencies>
<objects>
<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="menuOpenConfig" destination="BOF-NM-1cW" id="Nze-Go-glw"/>
<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="menuQuickTerminal" destination="1pv-LF-NBJ" id="glN-5B-IGi"/>
<outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/>
@ -185,6 +186,12 @@
<action selector="paste:" target="-1" id="ZKe-2B-mel"/>
</connections>
</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">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>

View File

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

View File

@ -62,7 +62,7 @@ extension Ghostty {
// uses to interface with the application runtime environment.
var runtime_cfg = ghostty_runtime_config_s(
userdata: Unmanaged.passUnretained(self).toOpaque(),
supports_selection_clipboard: false,
supports_selection_clipboard: true,
wakeup_cb: { userdata in App.wakeup(userdata) },
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) },
@ -320,13 +320,13 @@ extension Ghostty {
let surfaceView = self.surfaceUserdata(from: userdata)
guard let surface = surfaceView.surface else { return }
// We only support the standard clipboard
if (location != GHOSTTY_CLIPBOARD_STANDARD) {
// Get our pasteboard
guard let pasteboard = NSPasteboard.ghostty(location) else {
return completeClipboardRequest(surface, data: "", state: state)
}
// Get our string
let str = NSPasteboard.general.getOpinionatedStringContents() ?? ""
let str = pasteboard.getOpinionatedStringContents() ?? ""
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) {
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 }
if !confirm {
let pb = NSPasteboard.general
pb.declareTypes([.string], owner: nil)
pb.setString(valueStr, forType: .string)
pasteboard.declareTypes([.string], owner: nil)
pasteboard.setString(valueStr, forType: .string)
return
}
@ -380,7 +378,7 @@ extension Ghostty {
object: surface,
userInfo: [
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
/// 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
func text() -> String {
@ -188,7 +188,7 @@ extension Ghostty {
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ:
return .osc_52_read
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE:
return .osc_52_write
return .osc_52_write(nil)
default:
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?) {
guard let surface = self.surface else { return }
let action = "select_all"
@ -1448,3 +1456,19 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
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 OSColor = NSColor
typealias OSSize = NSSize
typealias OSPasteboard = NSPasteboard
protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType {
associatedtype OSViewType: NSView
@ -34,6 +35,7 @@ import UIKit
typealias OSView = UIView
typealias OSColor = UIColor
typealias OSSize = CGSize
typealias OSPasteboard = UIPasteboard
protocol OSViewRepresentable: UIViewRepresentable {
associatedtype OSViewType: UIView

View File

@ -1,6 +1,12 @@
import AppKit
import GhosttyKit
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.
/// Does these things in order:
/// - 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)
}
/// 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
/// even if this is `false`.
///
/// The default value is true on Linux and false on macOS. macOS copy on
/// 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.
/// The default value is true on Linux and macOS.
@"copy-on-select": CopyOnSelect = switch (builtin.os.tag) {
.linux => .true,
.macos => .false,
.macos => .true,
else => .false,
},
@ -2749,6 +2746,13 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
.{ .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
// to legacy encoding (not fixterms). It seems macOS users more than
// others are used to these keys so we set them as defaults. If