mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 15:56:13 +03:00
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:
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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:
|
||||
|
@ -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),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user