Merge pull request #1351 from mitchellh/macos-svc

macOS: implement read/write terminal text for services
This commit is contained in:
Mitchell Hashimoto
2024-01-21 18:27:18 -08:00
committed by GitHub
6 changed files with 110 additions and 3 deletions

View File

@ -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);

View File

@ -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 = [

View File

@ -40,6 +40,7 @@
<outlet property="menuSelectSplitBelow" destination="QDz-d9-CBr" id="FsH-Dq-jij"/>
<outlet property="menuSelectSplitLeft" destination="cTK-oy-KuV" id="Jpr-5q-dqz"/>
<outlet property="menuSelectSplitRight" destination="upj-mc-L7X" id="nLY-o1-lky"/>
<outlet property="menuServices" destination="aQe-vS-j8Q" id="uWQ-Wo-T1L"/>
<outlet property="menuSplitHorizontal" destination="VUR-Ld-nLx" id="RxO-Zw-ovb"/>
<outlet property="menuSplitVertical" destination="UDZ-4y-6xL" id="fgZ-Wb-8OR"/>
<outlet property="menuTerminalInspector" destination="QwP-M5-fvh" id="wJi-Dh-S9f"/>
@ -76,6 +77,11 @@
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
<menuItem title="Services" id="rJe-5J-bwL">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Services" id="aQe-vS-j8Q"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="qno-yg-pur"/>
<menuItem title="Hide Ghostty" keyEquivalent="h" id="Olw-nP-bQN">
<connections>
<action selector="hide:" target="-1" id="PnN-Uc-m68"/>

View File

@ -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
}
}

View File

@ -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.

View File

@ -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.