aboutsummaryrefslogtreecommitdiff
path: root/src/ghostty/mod.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-22 18:59:01 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-22 18:59:01 +0300
commit1d238bc9a365d9dd1297b4e11fe775cd656aebd0 (patch)
treea12551ab3dc80d36ef15549aee1db08b467606b8 /src/ghostty/mod.rs
parentc234f221f9be698bc0501c60459e1ae8e67df6c3 (diff)
refactor: replace pane mouse and clipboard internals
replaces contributions from othavi0
Diffstat (limited to 'src/ghostty/mod.rs')
-rw-r--r--src/ghostty/mod.rs189
1 files changed, 189 insertions, 0 deletions
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<Box<WritePtyCallback>>,
pwd_changes: Vec<Vec<u8>>,
+ clipboard_writes: Vec<Vec<u8>>,
}
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::<usize>();
+ // 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::<TerminalCallbackState>() };
+ 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;
@@ -662,6 +749,12 @@ impl Terminal {
.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,
(&glyph_protocol as *const bool).cast(),
)
@@ -758,6 +851,10 @@ impl Terminal {
mem::take(&mut self.callback_state.pwd_changes)
}
+ pub fn take_clipboard_writes(&mut self) -> Vec<Vec<u8>> {
+ mem::take(&mut self.callback_state.clipboard_writes)
+ }
+
pub fn mode_get(&self, mode: u16) -> Result<bool, Error> {
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::<ffi::GhosttyClipboardWrite>();
+ 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::<u8>::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::<u8>::new()]);
+ }
+
#[test]
fn row_cell_basic_data_uses_batched_vendor_reads() {
let mut terminal = Terminal::new(8, 3, 100).unwrap();