From 1d238bc9a365d9dd1297b4e11fe775cd656aebd0 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 22 Jul 2026 18:59:01 +0300 Subject: refactor: replace pane mouse and clipboard internals replaces contributions from othavi0 --- src/ghostty/mod.rs | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) (limited to 'src/ghostty') diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index a664ee05..3a3a0791 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -443,10 +443,13 @@ impl CellWide { type WritePtyCallback = dyn FnMut(&[u8]) + Send; +const MAX_CLIPBOARD_BYTES: usize = 192 * 1024; + #[derive(Default)] struct TerminalCallbackState { write_pty: Option>, pwd_changes: Vec>, + clipboard_writes: Vec>, } unsafe extern "C" fn write_pty_trampoline( @@ -470,6 +473,90 @@ unsafe extern "C" fn write_pty_trampoline( callback(bytes); } +unsafe extern "C" fn clipboard_write_trampoline( + _terminal: ffi::GhosttyTerminal, + userdata: *mut c_void, + write: *const ffi::GhosttyClipboardWrite, +) -> ffi::GhosttyClipboardWriteResult { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // SAFETY: libghostty-vt owns these values for the synchronous callback. + unsafe { capture_clipboard_write(userdata, write) } + })) + .unwrap_or(ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA) +} + +unsafe fn capture_clipboard_write( + userdata: *mut c_void, + write: *const ffi::GhosttyClipboardWrite, +) -> ffi::GhosttyClipboardWriteResult { + if userdata.is_null() || write.is_null() { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + } + + let required_size = std::mem::offset_of!(ffi::GhosttyClipboardWrite, contents_len) + + std::mem::size_of::(); + // SAFETY: size is the leading field of the live request. + if unsafe { (*write).size } < required_size { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + } + // SAFETY: the size check covers every field accessed below. + let request = unsafe { &*write }; + if request.location != ffi::GhosttyClipboardLocation_GHOSTTY_CLIPBOARD_LOCATION_STANDARD { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED; + } + + // SAFETY: userdata is the TerminalCallbackState installed with this terminal. + let state = unsafe { &mut *userdata.cast::() }; + if request.contents_len == 0 { + state.clipboard_writes.push(Vec::new()); + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS; + } + if request.contents_len != 1 { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED; + } + if request.contents.is_null() { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + } + + // SAFETY: libghostty-vt keeps the single content and its strings alive for the callback. + let content = unsafe { &*request.contents }; + // SAFETY: the MIME string is borrowed from the live callback request. + let Some(mime) = (unsafe { borrowed_bytes(content.mime) }) else { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + }; + let is_text = std::str::from_utf8(mime) + .ok() + .and_then(|mime| mime.split(';').next()) + .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/plain")); + if !is_text { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED; + } + + // SAFETY: the data string is borrowed from the live callback request. + let Some(bytes) = (unsafe { borrowed_bytes(content.data) }) else { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + }; + if bytes.is_empty() { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED; + } + if bytes.len() > MAX_CLIPBOARD_BYTES { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + } + state.clipboard_writes.push(bytes.to_vec()); + ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS +} + +unsafe fn borrowed_bytes<'a>(value: ffi::GhosttyString) -> Option<&'a [u8]> { + if value.len == 0 { + Some(&[]) + } else if value.ptr.is_null() { + None + } else { + // SAFETY: the callback contract keeps pointer and length valid until return. + Some(unsafe { slice::from_raw_parts(value.ptr, value.len) }) + } +} + unsafe extern "C" fn pwd_changed_trampoline(terminal: ffi::GhosttyTerminal, userdata: *mut c_void) { if terminal.is_null() || userdata.is_null() { return; @@ -660,6 +747,12 @@ impl Terminal { (pwd_changed_trampoline as *const ()).cast(), ) .into_result()?; + ffi::ghostty_terminal_set( + terminal.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE, + (clipboard_write_trampoline as *const ()).cast(), + ) + .into_result()?; ffi::ghostty_terminal_set( terminal.raw, ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL, @@ -758,6 +851,10 @@ impl Terminal { mem::take(&mut self.callback_state.pwd_changes) } + pub fn take_clipboard_writes(&mut self) -> Vec> { + mem::take(&mut self.callback_state.clipboard_writes) + } + pub fn mode_get(&self, mode: u16) -> Result { let mut out = false; unsafe { ffi::ghostty_terminal_mode_get(self.raw, mode, &mut out).into_result()? }; @@ -3640,6 +3737,98 @@ mod tests { assert_eq!(rows.selection().unwrap(), None); } + fn test_clipboard_content(mime: &[u8], data: &[u8]) -> ffi::GhosttyClipboardContent { + ffi::GhosttyClipboardContent { + mime: ffi::GhosttyString { + ptr: mime.as_ptr(), + len: mime.len(), + }, + data: ffi::GhosttyString { + ptr: data.as_ptr(), + len: data.len(), + }, + } + } + + fn invoke_clipboard_callback( + terminal: &mut Terminal, + contents: &[ffi::GhosttyClipboardContent], + size: usize, + ) -> ffi::GhosttyClipboardWriteResult { + let request = ffi::GhosttyClipboardWrite { + size, + location: ffi::GhosttyClipboardLocation_GHOSTTY_CLIPBOARD_LOCATION_STANDARD, + contents: contents.as_ptr(), + contents_len: contents.len(), + }; + // SAFETY: the request and its borrowed content live through this call. + unsafe { + clipboard_write_trampoline( + terminal.raw, + (&mut *terminal.callback_state as *mut TerminalCallbackState).cast(), + &request, + ) + } + } + + #[test] + fn clipboard_callback_rejects_writes_the_text_pipeline_cannot_represent() { + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + let full_size = std::mem::size_of::(); + let success = ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS; + let unsupported = + ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED; + let invalid = ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + + assert_eq!( + invoke_clipboard_callback(&mut terminal, &[], full_size), + success + ); + assert_eq!(terminal.take_clipboard_writes(), vec![Vec::::new()]); + + let empty = test_clipboard_content(b"text/plain", b""); + assert_eq!( + invoke_clipboard_callback(&mut terminal, &[empty], full_size), + unsupported + ); + let text = test_clipboard_content(b"text/plain", b"text"); + let image = test_clipboard_content(b"image/png", b"image"); + assert_eq!( + invoke_clipboard_callback(&mut terminal, &[text, image], full_size), + unsupported + ); + + let oversized = vec![b'x'; MAX_CLIPBOARD_BYTES + 1]; + let oversized = test_clipboard_content(b"text/plain", &oversized); + assert_eq!( + invoke_clipboard_callback(&mut terminal, &[oversized], full_size), + invalid + ); + assert_eq!( + invoke_clipboard_callback(&mut terminal, &[text], full_size - 1), + invalid + ); + assert!(terminal.take_clipboard_writes().is_empty()); + } + + #[test] + fn libghostty_completes_osc52_writes_for_bel_and_st_without_queries() { + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + terminal.write(b"\x1b]52;c;aGVs"); + assert!(terminal.take_clipboard_writes().is_empty()); + terminal.write(b"bG8=\x07"); + assert_eq!(terminal.take_clipboard_writes(), vec![b"hello".to_vec()]); + + terminal.write(b"\x1b]52;c;d29ybGQ=\x1b\\"); + assert_eq!(terminal.take_clipboard_writes(), vec![b"world".to_vec()]); + + terminal.write(b"\x1b]52;c;?\x07"); + assert!(terminal.take_clipboard_writes().is_empty()); + + terminal.write(b"\x1b]52;c;\x07"); + assert_eq!(terminal.take_clipboard_writes(), vec![Vec::::new()]); + } + #[test] fn row_cell_basic_data_uses_batched_vendor_reads() { let mut terminal = Terminal::new(8, 3, 100).unwrap(); -- cgit v1.2.3