From b94739183db7614e1e7c2b9b35a3b39df03bdbb3 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Tue, 13 Oct 2026 11:05:37 +0200 Subject: [PATCH] perf: avoid JXA startup for macOS clipboard writes --- TODO.md | 3 +- docs/architecture.md | 2 +- docs/editing.md | 2 +- src/clipboard.rs | 226 ++++++++++++++++++++++++++++++++++++------- 4 files changed, 194 insertions(+), 39 deletions(-) diff --git a/TODO.md b/TODO.md index eb2c138..4bf2a33 100644 --- a/TODO.md +++ b/TODO.md @@ -20,7 +20,8 @@ - Space s flips orientation; configurable startup default and shared pane geometry/directional focus. - [x] add `C` and `D` for deleting line from current cursor position (and entering insert mode: `C`); add `Y` for copying line from current cursor position; add `r` for replacing character at cursor position; add `s` for deleting character at current cursor position and entering insert mode; add `dw` for deleting whole words; add `db` for deleting backwards (same as `dw` but backwards). - [x] diff view: make foreground of etched areas (where no lines exist) the same dimmed color as the normal gutter text -- [ ] copying is pretty slow; optimize performance to reduce latency. +- [x] copying is pretty slow; optimize performance to reduce latency. +- [ ] enable margins in bar elements (i.e. for top and bottom bars) and give the lsp symbol (next to language) a default margin of 1 column on either side. - [ ] pull a pr description and display it in a buffer: - open a selector that allows you to pick a pr from remote - selector should be searchable diff --git a/docs/architecture.md b/docs/architecture.md index d5519e0..b0c421b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -91,7 +91,7 @@ Diff highlighting lazily owns two instances of the same syntax cache, keyed by t ## Clipboard providers -macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`, preserving literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Commands have a two-second deadline and bounded output; failures terminate/reap their process group. +macOS publishes `public.utf8-plain-text` through the native Pasteboard Manager, checking allocation and OS-status failures before reporting success. Publication is synchronous and avoids per-copy interpreter/process startup; native calls do not have a subprocess deadline. Reads retain fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`. Both paths preserve literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Subprocess-backed access has a two-second deadline and bounded output; failures terminate/reap the process group. ## Native LSP diff --git a/docs/editing.md b/docs/editing.md index d5569e1..00a2cc8 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -134,7 +134,7 @@ For other applications, characterwise multi-selection exports join fragments wit System paste validates UTF-8, unsupported controls, newlines, and a 4 MiB limit before editing; CRLF/CR normalize to LF. System exports also have a 4 MiB limit. Provider failures report an error without deleting/changing buffer text, caret/selection state, history, or repeat intent. No silent internal fallback occurs in System mode; choose Separate if clipboard access is unavailable. A native provider that fails after publishing may already have changed OS clipboard formats; vamp cannot roll that external side effect back. -System clipboard access supports macOS through `osascript`, Linux Wayland through `wl-paste`/`wl-copy`, and Linux X11 through `xclip`. Linux tools must be on PATH with the corresponding display environment. Other platforms report unsupported access; choose Separate mode when system clipboard access is unavailable. +macOS copies publish UTF-8 plain text directly through the native pasteboard API, avoiding interpreter/process startup for every copy or cut; paste reads still use `osascript` with JXA/AppKit. Linux Wayland uses `wl-paste`/`wl-copy`, and Linux X11 uses `xclip`. Linux tools must be on PATH with the corresponding display environment. Other platforms report unsupported access; choose Separate mode when system clipboard access is unavailable. ## Repeat last edit diff --git a/src/clipboard.rs b/src/clipboard.rs index c03c76e..a0501fb 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -15,20 +15,6 @@ function run() { } "#; -#[cfg(target_os = "macos")] -const MAC_WRITE: &str = r#" -ObjC.import('AppKit'); -function run() { - var data = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile; - var text = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding); - if (text.isNil()) throw Error('Clipboard input is not UTF-8'); - var pasteboard = $.NSPasteboard.generalPasteboard; - pasteboard.clearContents; - if (!pasteboard.setStringForType(text, $.NSPasteboardTypeString)) - throw Error('Cannot write plain-text clipboard'); -} -"#; - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ClipboardMode { System, @@ -42,7 +28,7 @@ struct ClipboardCommand { struct Commands { reader: ClipboardCommand, - writer: ClipboardCommand, + writer: Option, } pub struct SystemClipboard { @@ -72,10 +58,10 @@ impl SystemClipboard { program: reader, args: &[], }, - writer: ClipboardCommand { + writer: Some(ClipboardCommand { program: writer, args: &[], - }, + }), })), timeout: COMMAND_TIMEOUT, } @@ -95,8 +81,13 @@ impl SystemClipboard { } let timeout = self.timeout; let commands = self.commands.get_or_insert_with(select_commands); - let command = &commands.as_ref().map_err(Clone::clone)?.writer; - run(command, Some(text.as_bytes()), timeout).map(|_| ()) + match &commands.as_ref().map_err(Clone::clone)?.writer { + Some(command) => run(command, Some(text.as_bytes()), timeout).map(|_| ()), + #[cfg(target_os = "macos")] + None => macos::write(text), + #[cfg(not(target_os = "macos"))] + None => Err("System clipboard is unsupported on this platform".into()), + } } } @@ -117,19 +108,13 @@ fn executable(name: &str) -> Option { fn select_commands() -> Result { #[cfg(target_os = "macos")] { - if let Some(program) = executable("osascript") { - return Ok(Commands { - reader: ClipboardCommand { - program: program.clone(), - args: &["-l", "JavaScript", "-e", MAC_READ], - }, - writer: ClipboardCommand { - program, - args: &["-l", "JavaScript", "-e", MAC_WRITE], - }, - }); - } - Err("System clipboard requires osascript with JXA/AppKit on PATH".into()) + Ok(Commands { + reader: ClipboardCommand { + program: executable("osascript").unwrap_or_else(|| "/usr/bin/osascript".into()), + args: &["-l", "JavaScript", "-e", MAC_READ], + }, + writer: None, + }) } #[cfg(target_os = "linux")] { @@ -140,10 +125,10 @@ fn select_commands() -> Result { program: reader, args: &["--no-newline", "--type", "text"], }, - writer: ClipboardCommand { + writer: Some(ClipboardCommand { program: writer, args: &["--type", "text/plain;charset=utf-8"], - }, + }), }); } } @@ -154,10 +139,10 @@ fn select_commands() -> Result { program: program.clone(), args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"], }, - writer: ClipboardCommand { + writer: Some(ClipboardCommand { program, args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"], - }, + }), }); } } @@ -169,6 +154,173 @@ fn select_commands() -> Result { } } +#[cfg(target_os = "macos")] +mod macos { + use std::ffi::{c_char, c_void}; + use std::ptr; + + type Object = *const c_void; + + #[link(name = "CoreFoundation", kind = "framework")] + unsafe extern "C" { + fn CFStringCreateWithCString( + allocator: Object, + bytes: *const c_char, + encoding: u32, + ) -> Object; + fn CFDataCreate(allocator: Object, bytes: *const u8, length: isize) -> Object; + fn CFRelease(object: Object); + } + + #[link(name = "CoreServices", kind = "framework")] + unsafe extern "C" { + static kUTTypeUTF8PlainText: Object; + } + + #[link(name = "ApplicationServices", kind = "framework")] + unsafe extern "C" { + fn PasteboardCreate(name: Object, pasteboard: *mut Object) -> i32; + fn PasteboardClear(pasteboard: Object) -> i32; + fn PasteboardPutItemFlavor( + pasteboard: Object, + item: usize, + flavor: Object, + data: Object, + flags: u32, + ) -> i32; + } + + struct Owned(Object); + impl Owned { + fn new(object: Object) -> Result { + if object.is_null() { + Err("Cannot allocate system clipboard data".into()) + } else { + Ok(Self(object)) + } + } + } + impl Drop for Owned { + fn drop(&mut self) { + // SAFETY: each handle owns one Core Foundation reference. + unsafe { CFRelease(self.0) }; + } + } + + fn check(status: i32) -> Result<(), String> { + if status == 0 { + Ok(()) + } else { + Err(format!("System clipboard publication failed ({status})")) + } + } + + fn write_to(name: Object, text: &str) -> Result<(), String> { + // SAFETY: framework constants and byte slices live throughout the calls. + unsafe { + let data = Owned::new(CFDataCreate( + ptr::null(), + text.as_ptr(), + text.len() as isize, + ))?; + let mut board = ptr::null(); + check(PasteboardCreate(name, &mut board))?; + let board = Owned::new(board)?; + // Prepare all allocations before changing clipboard contents. + check(PasteboardClear(board.0))?; + check(PasteboardPutItemFlavor( + board.0, + 1, + kUTTypeUTF8PlainText, + data.0, + 0, + )) + } + } + + pub(super) fn write(text: &str) -> Result<(), String> { + // SAFETY: the clipboard name is a process-lifetime, null-terminated literal. + let name = unsafe { + Owned::new(CFStringCreateWithCString( + ptr::null(), + c"com.apple.pasteboard.clipboard".as_ptr(), + 0x0800_0100, + ))? + }; + write_to(name.0, text) + } + + #[cfg(test)] + mod tests { + use super::*; + unsafe extern "C" { + fn PasteboardSynchronize(pasteboard: Object) -> u32; + fn PasteboardGetItemIdentifier( + pasteboard: Object, + index: isize, + item: *mut usize, + ) -> i32; + fn PasteboardCopyItemFlavorData( + pasteboard: Object, + item: usize, + flavor: Object, + data: *mut Object, + ) -> i32; + fn CFDataGetBytePtr(data: Object) -> *const u8; + fn CFDataGetLength(data: Object) -> isize; + } + #[test] + fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() { + // A private pasteboard leaves the user's clipboard untouched. + let name = unsafe { + Owned::new(CFStringCreateWithCString( + ptr::null(), + c"vamp-native-clipboard-regression".as_ptr(), + 0x0800_0100, + )) + .unwrap() + }; + let board = unsafe { + let mut board = ptr::null(); + check(PasteboardCreate(name.0, &mut board)).unwrap(); + Owned::new(board).unwrap() + }; + for text in [ + "", + "é e\u{301} 👨‍💻\n\0tail", + "{\\rtf1 plain text}", + "%!PS-Adobe-3.0\nplain", + ] { + write_to(name.0, text).unwrap(); + unsafe { + PasteboardSynchronize(board.0); + let mut item = 0; + check(PasteboardGetItemIdentifier(board.0, 1, &mut item)).unwrap(); + let flavor = Owned::new(CFStringCreateWithCString( + ptr::null(), + c"public.utf8-plain-text".as_ptr(), + 0x0800_0100, + )) + .unwrap(); + let mut data = ptr::null(); + check(PasteboardCopyItemFlavorData( + board.0, item, flavor.0, &mut data, + )) + .unwrap(); + let data = Owned::new(data).unwrap(); + let length = CFDataGetLength(data.0) as usize; + let bytes = if length == 0 { + &[] + } else { + std::slice::from_raw_parts(CFDataGetBytePtr(data.0), length) + }; + assert_eq!(bytes, text.as_bytes()); + } + } + } + } +} + #[cfg(not(any(target_os = "macos", target_os = "linux")))] fn run(_: &ClipboardCommand, _: Option<&[u8]>, _: Duration) -> Result, String> { Err("System clipboard is unsupported on this platform".into()) @@ -514,6 +666,8 @@ mod tests { .as_mut() .unwrap() .writer + .as_mut() + .unwrap() .program = fixture.command( "noisy", &format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"),