diff --git a/include/ghostty.h b/include/ghostty.h index a6283170d..cef635867 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -489,6 +489,8 @@ void ghostty_surface_split_equalize(ghostty_surface_t); bool ghostty_surface_binding_action(ghostty_surface_t, const char *, uintptr_t); void ghostty_surface_complete_clipboard_request(ghostty_surface_t, const char *, void *, bool); uintptr_t ghostty_surface_pwd(ghostty_surface_t, char *, uintptr_t); +bool ghostty_surface_has_selection(ghostty_surface_t); +uintptr_t ghostty_surface_selection(ghostty_surface_t, char *, uintptr_t); ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); void ghostty_inspector_free(ghostty_surface_t); diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 1aa27387e..d3d3e1f4b 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -17,7 +17,8 @@ class AppDelegate: NSObject, category: String(describing: AppDelegate.self) ) - /// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config. + /// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config + @IBOutlet private var menuServices: NSMenu? @IBOutlet private var menuCheckForUpdates: NSMenuItem? @IBOutlet private var menuOpenConfig: NSMenuItem? @IBOutlet private var menuReloadConfig: NSMenuItem? @@ -108,9 +109,11 @@ class AppDelegate: NSObject, // Initial config loading configDidReload(ghostty) - // Register our service provider. This must happen after everything - // else is initialized. + // Register our service provider. This must happen after everything is initialized. NSApp.servicesProvider = ServiceProvider() + + // This registers the Ghostty => Services menu to exist. + NSApp.servicesMenu = menuServices // Configure user notifications let actions = [ diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 2e6041bed..ca9cd1f35 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -40,6 +40,7 @@ + @@ -76,6 +77,11 @@ + + + + + diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index fb1915fc1..251ddeaa8 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -953,3 +953,60 @@ extension Ghostty.SurfaceView: NSTextInputClient { 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.string(forType: .string) 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 + } +} diff --git a/src/Surface.zig b/src/Surface.zig index ada0dfd7f..4b6d151dd 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -865,6 +865,21 @@ fn changeConfig(self: *Surface, config: *const configpkg.Config) !void { }; } +/// Returns true if the terminal has a selection. +pub fn hasSelection(self: *const Surface) bool { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + return self.io.terminal.screen.selection != null; +} + +/// Returns the selected text. This is allocated. +pub fn selectionString(self: *Surface, alloc: Allocator) !?[]const u8 { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const sel = self.io.terminal.screen.selection orelse return null; + return try self.io.terminal.screen.selectionString(alloc, sel, false); +} + /// Returns the pwd of the terminal, if any. This is always copied because /// the pwd can change at any point from termio. If we are calling from the IO /// thread you should just check the terminal directly. diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 6b631de17..d8e915550 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1438,6 +1438,30 @@ pub const CAPI = struct { return surface.core_surface.needsConfirmQuit(); } + /// Returns true if the surface has a selection. + export fn ghostty_surface_has_selection(surface: *Surface) bool { + return surface.core_surface.hasSelection(); + } + + /// Copies the surface selection text into the provided buffer and + /// returns the copied size. If the buffer is too small, there is no + /// selection, or there is an error, then 0 is returned. + export fn ghostty_surface_selection(surface: *Surface, buf: [*]u8, cap: usize) usize { + const selection_ = surface.core_surface.selectionString(global.alloc) catch |err| { + log.warn("error getting selection err={}", .{err}); + return 0; + }; + const selection = selection_ orelse return 0; + defer global.alloc.free(selection); + + // If the buffer is too small, return no selection. + if (selection.len > cap) return 0; + + // Copy into the buffer and return the length + @memcpy(buf[0..selection.len], selection); + return selection.len; + } + /// Copies the surface working directory into the provided buffer and /// returns the copied size. If the buffer is too small, there is no pwd, /// or there is an error, then 0 is returned.