From b30feeb596ae8dd2a73948dbfffcab540e98bcce Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Sep 2023 10:18:17 -0700 Subject: [PATCH 1/3] core: move clipboard to async process --- src/Surface.zig | 249 +++++++++++++++++++++++------------------- src/apprt/glfw.zig | 22 ++-- src/apprt/structs.zig | 10 ++ 3 files changed, 161 insertions(+), 120 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 2cd946c2f..57c1d8363 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -537,7 +537,14 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .cell_size => |size| try self.setCellSize(size), - .clipboard_read => |kind| try self.clipboardRead(kind), + .clipboard_read => |kind| { + if (!self.config.clipboard_read) { + log.info("application attempted to read clipboard, but 'clipboard-read' setting is off", .{}); + return; + } + + try self.startClipboardRequest(.standard, .{ .osc_52 = kind }); + }, .clipboard_write => |req| switch (req) { .small => |v| try self.clipboardWrite(v.data[0..v.len], .standard), @@ -663,89 +670,6 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos { return .{ .x = x, .y = y }; } -/// Paste from the clipboard -fn clipboardPaste( - self: *Surface, - loc: apprt.Clipboard, - lock: bool, -) !void { - const data = self.rt_surface.getClipboardString(loc) catch |err| { - log.warn("error reading clipboard: {}", .{err}); - return; - }; - - if (data.len > 0) { - const bracketed = bracketed: { - if (lock) self.renderer_state.mutex.lock(); - defer if (lock) self.renderer_state.mutex.unlock(); - - // With the lock held, we must scroll to the bottom. - // We always scroll to the bottom for these inputs. - self.scrollToBottom() catch |err| { - log.warn("error scrolling to bottom err={}", .{err}); - }; - - break :bracketed self.io.terminal.modes.get(.bracketed_paste); - }; - - if (bracketed) { - _ = self.io_thread.mailbox.push(.{ - .write_stable = "\x1B[200~", - }, .{ .forever = {} }); - } - - _ = self.io_thread.mailbox.push(try termio.Message.writeReq( - self.alloc, - data, - ), .{ .forever = {} }); - - if (bracketed) { - _ = self.io_thread.mailbox.push(.{ - .write_stable = "\x1B[201~", - }, .{ .forever = {} }); - } - - try self.io_thread.wakeup.notify(); - } -} - -/// This is similar to clipboardPaste but is used specifically for OSC 52 -fn clipboardRead(self: *const Surface, kind: u8) !void { - if (!self.config.clipboard_read) { - log.info("application attempted to read clipboard, but 'clipboard-read' setting is off", .{}); - return; - } - - const data = self.rt_surface.getClipboardString(.standard) catch |err| { - log.warn("error reading clipboard: {}", .{err}); - return; - }; - - // Even if the clipboard data is empty we reply, since presumably - // the client app is expecting a reply. We first allocate our buffer. - // This must hold the base64 encoded data PLUS the OSC code surrounding it. - const enc = std.base64.standard.Encoder; - const size = enc.calcSize(data.len); - var buf = try self.alloc.alloc(u8, size + 9); // const for OSC - defer self.alloc.free(buf); - - // Wrap our data with the OSC code - const prefix = try std.fmt.bufPrint(buf, "\x1b]52;{c};", .{kind}); - assert(prefix.len == 7); - buf[buf.len - 2] = '\x1b'; - buf[buf.len - 1] = '\\'; - - // Do the base64 encoding - const encoded = enc.encode(buf[prefix.len..], data); - assert(encoded.len == size); - - _ = self.io_thread.mailbox.push(try termio.Message.writeReq( - self.alloc, - buf, - ), .{ .forever = {} }); - self.io_thread.wakeup.notify() catch {}; -} - fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) !void { if (!self.config.clipboard_write) { log.info("application attempted to write clipboard, but 'clipboard-write' setting is off", .{}); @@ -1541,41 +1465,45 @@ pub fn mouseButtonCallback( } } - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - // Report mouse events if enabled - if (self.io.terminal.flags.mouse_event != .none) report: { - // Shift overrides mouse "grabbing" in the window, taken from Kitty. - if (mods.shift) break :report; + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + if (self.io.terminal.flags.mouse_event != .none) report: { + // Shift overrides mouse "grabbing" in the window, taken from Kitty. + if (mods.shift) break :report; - // In any other mouse button scenario without shift pressed we - // clear the selection since the underlying application can handle - // that in any way (i.e. "scrolling"). - self.setSelection(null); + // In any other mouse button scenario without shift pressed we + // clear the selection since the underlying application can handle + // that in any way (i.e. "scrolling"). + self.setSelection(null); - const pos = try self.rt_surface.getCursorPos(); + const pos = try self.rt_surface.getCursorPos(); - const report_action: MouseReportAction = switch (action) { - .press => .press, - .release => .release, - }; + const report_action: MouseReportAction = switch (action) { + .press => .press, + .release => .release, + }; - try self.mouseReport( - button, - report_action, - self.mouse.mods, - pos, - ); + try self.mouseReport( + button, + report_action, + self.mouse.mods, + pos, + ); - // If we're doing mouse reporting, we do not support any other - // selection or highlighting. - return; + // If we're doing mouse reporting, we do not support any other + // selection or highlighting. + return; + } } // For left button clicks we always record some information for // selection/highlighting purposes. if (button == .left and action == .press) { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const pos = try self.rt_surface.getCursorPos(); // If we move our cursor too much between clicks then we reset @@ -1655,7 +1583,8 @@ pub fn mouseButtonCallback( .clipboard => .standard, .false => unreachable, }; - try self.clipboardPaste(clipboard, false); + + try self.startClipboardRequest(clipboard, .{ .paste = {} }); } } } @@ -2022,7 +1951,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !void } }, - .paste_from_clipboard => try self.clipboardPaste(.standard, true), + .paste_from_clipboard => try self.startClipboardRequest( + .standard, + .{ .paste = {} }, + ), .increase_font_size => |delta| { log.debug("increase font size={}", .{delta}); @@ -2202,6 +2134,103 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !void } } +/// Call this to complete a clipboard request sent to apprt. This should +/// only be called once for each request. The data is immediately copied so +/// it is safe to free the data after this call. +pub fn completeClipboardRequest( + self: *Surface, + req: apprt.ClipboardRequest, + data: []const u8, +) !void { + switch (req) { + .paste => try self.completeClipboardPaste(data), + .osc_52 => |kind| try self.completeClipboardReadOSC52(data, kind), + } +} + +/// This starts a clipboard request, with some basic validation. For example, +/// an OSC 52 request is not actually requested if OSC 52 is disabled. +fn startClipboardRequest( + self: *Surface, + loc: apprt.Clipboard, + req: apprt.ClipboardRequest, +) !void { + switch (req) { + .paste => {}, // always allowed + .osc_52 => if (!self.config.clipboard_read) { + log.info( + "application attempted to read clipboard, but 'clipboard-read' setting is off", + .{}, + ); + return; + }, + } + + try self.rt_surface.clipboardRequest(loc, req); +} + +fn completeClipboardPaste(self: *Surface, data: []const u8) !void { + if (data.len == 0) return; + + const bracketed = bracketed: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // With the lock held, we must scroll to the bottom. + // We always scroll to the bottom for these inputs. + self.scrollToBottom() catch |err| { + log.warn("error scrolling to bottom err={}", .{err}); + }; + + break :bracketed self.io.terminal.modes.get(.bracketed_paste); + }; + + if (bracketed) { + _ = self.io_thread.mailbox.push(.{ + .write_stable = "\x1B[200~", + }, .{ .forever = {} }); + } + + _ = self.io_thread.mailbox.push(try termio.Message.writeReq( + self.alloc, + data, + ), .{ .forever = {} }); + + if (bracketed) { + _ = self.io_thread.mailbox.push(.{ + .write_stable = "\x1B[201~", + }, .{ .forever = {} }); + } + + try self.io_thread.wakeup.notify(); +} + +fn completeClipboardReadOSC52(self: *Surface, data: []const u8, kind: u8) !void { + // Even if the clipboard data is empty we reply, since presumably + // the client app is expecting a reply. We first allocate our buffer. + // This must hold the base64 encoded data PLUS the OSC code surrounding it. + const enc = std.base64.standard.Encoder; + const size = enc.calcSize(data.len); + var buf = try self.alloc.alloc(u8, size + 9); // const for OSC + defer self.alloc.free(buf); + + // Wrap our data with the OSC code + const prefix = try std.fmt.bufPrint(buf, "\x1b]52;{c};", .{kind}); + assert(prefix.len == 7); + buf[buf.len - 2] = '\x1b'; + buf[buf.len - 1] = '\\'; + + // Do the base64 encoding + const encoded = enc.encode(buf[prefix.len..], data); + assert(encoded.len == size); + + _ = self.io_thread.mailbox.push(try termio.Message.writeReq( + self.alloc, + buf, + ), .{ .forever = {} }); + self.io_thread.wakeup.notify() catch {}; +} + const face_ttf = @embedFile("font/res/FiraCode-Regular.ttf"); const face_bold_ttf = @embedFile("font/res/FiraCode-Bold.ttf"); const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf"); diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 3a53bb9dc..e4acab289 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -547,25 +547,27 @@ pub const Surface = struct { self.window.setInputModeCursor(if (visible) .normal else .hidden); } - /// Read the clipboard. The windowing system is responsible for allocating - /// a buffer as necessary. This should be a stable pointer until the next - /// time getClipboardString is called. - pub fn getClipboardString( - self: *const Surface, + /// Start an async clipboard request. + pub fn clipboardRequest( + self: *Surface, clipboard_type: apprt.Clipboard, - ) ![:0]const u8 { - _ = self; - return switch (clipboard_type) { - .standard => glfw.getClipboardString() orelse glfw.mustGetErrorCode(), + state: apprt.ClipboardRequest, + ) !void { + // GLFW can read clipboards immediately so just do that. + const str: []const u8 = switch (clipboard_type) { + .standard => glfw.getClipboardString() orelse return glfw.mustGetErrorCode(), .selection => selection: { // Not supported except on Linux - if (comptime builtin.os.tag != .linux) return ""; + if (comptime builtin.os.tag != .linux) break :selection ""; const raw = glfwNative.getX11SelectionString() orelse return glfw.mustGetErrorCode(); break :selection std.mem.span(raw); }, }; + + // Complete our request + try self.core_surface.completeClipboardRequest(state, str); } /// Set the clipboard. diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 5e379af71..93a2b7cbb 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -31,3 +31,13 @@ pub const Clipboard = enum(u1) { standard = 0, // ctrl+c/v selection = 1, // also known as the "primary" clipboard }; + +/// Clipboard request. This is used to request clipboard contents and must +/// be sent as a response to a ClipboardRequest event. +pub const ClipboardRequest = union(enum) { + /// A direct paste of clipboard contents. + paste: void, + + /// A request to write clipboard contents via OSC 52. + osc_52: u8, +}; From 5a02635d2c94080285778ca236036558b2e967a9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Sep 2023 10:35:33 -0700 Subject: [PATCH 2/3] macos: async style clipboard reading --- include/ghostty.h | 3 +- macos/Sources/Ghostty/AppState.swift | 33 +++++++++++---------- src/apprt/embedded.zig | 44 +++++++++++++++++++++++----- 3 files changed, 57 insertions(+), 23 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 7b4afa1d5..1bdb4fc31 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -316,7 +316,7 @@ typedef const ghostty_config_t (*ghostty_runtime_reload_config_cb)(void *); typedef void (*ghostty_runtime_set_title_cb)(void *, const char *); typedef void (*ghostty_runtime_set_mouse_shape_cb)(void *, ghostty_mouse_shape_e); typedef void (*ghostty_runtime_set_mouse_visibility_cb)(void *, bool); -typedef const char* (*ghostty_runtime_read_clipboard_cb)(void *, ghostty_clipboard_e); +typedef void (*ghostty_runtime_read_clipboard_cb)(void *, ghostty_clipboard_e, void *); typedef void (*ghostty_runtime_write_clipboard_cb)(void *, const char *, ghostty_clipboard_e); typedef void (*ghostty_runtime_new_split_cb)(void *, ghostty_split_direction_e, ghostty_surface_config_s); typedef void (*ghostty_runtime_new_tab_cb)(void *, ghostty_surface_config_s); @@ -393,6 +393,7 @@ void ghostty_surface_request_close(ghostty_surface_t); void ghostty_surface_split(ghostty_surface_t, ghostty_split_direction_e); void ghostty_surface_split_focus(ghostty_surface_t, ghostty_split_focus_direction_e); bool ghostty_surface_binding_action(ghostty_surface_t, const char *, uintptr_t); +void ghostty_surface_complete_clipboard_request(ghostty_surface_t, const char *, uintptr_t, void *); // APIs I'd like to get rid of eventually but are still needed for now. // Don't use these unless you know what you're doing. diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index e0dab0b75..fe6ee42ae 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -72,9 +72,6 @@ extension Ghostty { return v; } - /// Cached clipboard string for `read_clipboard` callback. - private var cached_clipboard_string: String? = nil - init() { // Initialize ghostty global state. This happens once per process. guard ghostty_init() == GHOSTTY_SUCCESS else { @@ -100,7 +97,7 @@ extension Ghostty { set_title_cb: { userdata, title in AppState.setTitle(userdata, title: title) }, set_mouse_shape_cb: { userdata, shape in AppState.setMouseShape(userdata, shape: shape) }, set_mouse_visibility_cb: { userdata, visible in AppState.setMouseVisibility(userdata, visible: visible) }, - read_clipboard_cb: { userdata, loc in AppState.readClipboard(userdata, location: loc) }, + read_clipboard_cb: { userdata, loc, state in AppState.readClipboard(userdata, location: loc, state: state) }, write_clipboard_cb: { userdata, str, loc in AppState.writeClipboard(userdata, string: str, location: loc) }, new_split_cb: { userdata, direction, surfaceConfig in AppState.newSplit(userdata, direction: direction, config: surfaceConfig) }, new_tab_cb: { userdata, surfaceConfig in AppState.newTab(userdata, config: surfaceConfig) }, @@ -301,18 +298,24 @@ extension Ghostty { ) } - static func readClipboard(_ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e) -> UnsafePointer? { + static func readClipboard(_ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer?) { + // If we don't even have a surface, something went terrible wrong so we have + // to leak "state". + guard let surfaceView = self.surfaceUserdata(from: userdata) else { return } + guard let surface = surfaceView.surface else { return } + // We only support the standard clipboard - if (location != GHOSTTY_CLIPBOARD_STANDARD) { return nil } - - guard let surface = self.surfaceUserdata(from: userdata) else { return nil } - guard let appState = self.appState(fromView: surface) else { return nil } - guard let str = NSPasteboard.general.string(forType: .string) else { return nil } - - // Ghostty requires we cache the string because the pointer we return has to remain - // stable until the next call to readClipboard. - appState.cached_clipboard_string = str - return (str as NSString).utf8String + if (location != GHOSTTY_CLIPBOARD_STANDARD) { + return completeClipboardRequest(surface, data: "", state: state) + } + + // Get our string + let str = NSPasteboard.general.string(forType: .string) ?? "" + completeClipboardRequest(surface, data: str, state: state) + } + + static private func completeClipboardRequest(_ surface: ghostty_surface_t, data: String, state: UnsafeMutableRawPointer?) { + ghostty_surface_complete_clipboard_request(surface, data, UInt(data.count), state) } static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?, location: ghostty_clipboard_e) { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 4ee4b5770..bc1efc3ec 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -58,7 +58,7 @@ pub const App = struct { /// Read the clipboard value. The return value must be preserved /// by the host until the next call. If there is no valid clipboard /// value then this should return null. - read_clipboard: *const fn (SurfaceUD, c_int) callconv(.C) ?[*:0]const u8, + read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.C) void, /// Write the clipboard value. write_clipboard: *const fn (SurfaceUD, [*:0]const u8, c_int) callconv(.C) void, @@ -342,15 +342,25 @@ pub const Surface = struct { }; } - pub fn getClipboardString( - self: *const Surface, + pub fn clipboardRequest( + self: *Surface, clipboard_type: apprt.Clipboard, - ) ![:0]const u8 { - const ptr = self.app.opts.read_clipboard( + state: apprt.ClipboardRequest, + ) !void { + // We need to allocate to get a pointer to store our clipboard request + // so that it is stable until the read_clipboard callback and call + // complete_clipboard_request. This sucks but clipboard requests aren't + // high throughput so it's probably fine. + const alloc = self.app.core_app.alloc; + const state_ptr = try alloc.create(apprt.ClipboardRequest); + errdefer alloc.destroy(state_ptr); + state_ptr.* = state; + + self.app.opts.read_clipboard( self.opts.userdata, @intCast(@intFromEnum(clipboard_type)), - ) orelse return ""; - return std.mem.sliceTo(ptr, 0); + state_ptr, + ); } pub fn setClipboardString( @@ -982,6 +992,26 @@ pub const CAPI = struct { return true; } + /// Complete a clipboard read request startd via the read callback. + /// This can only be called once for a given request. Once it is called + /// with a request the request pointer will be invalidated. + export fn ghostty_surface_complete_clipboard_request( + ptr: *Surface, + str_ptr: [*]const u8, + str_len: usize, + state: *apprt.ClipboardRequest, + ) void { + // The state is unusable after this + defer ptr.core_surface.app.alloc.destroy(state); + + if (str_len == 0) return; + const str = str_ptr[0..str_len]; + ptr.core_surface.completeClipboardRequest(state.*, str) catch |err| { + log.err("error completing clipboard request err={}", .{err}); + return; + }; + } + /// Sets the window background blur on macOS to the desired value. /// I do this in Zig as an extern function because I don't know how to /// call these functions in Swift. From 7748390a7e8a710961aa0015e9bc263a1f472ecd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Sep 2023 10:53:45 -0700 Subject: [PATCH 3/3] apprt/gtk: async clipboard --- src/apprt/gtk/Surface.zig | 80 +++++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index ad8369c7c..bcc24fc1f 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -75,7 +75,6 @@ font_size: ?font.face.DesiredSize = null, /// Cached metrics about the surface from GTK callbacks. size: apprt.SurfaceSize, cursor_pos: apprt.CursorPos, -clipboard: c.GValue, /// Key input states. See gtkKeyPressed for detailed descriptions. in_keypress: bool = false, @@ -151,7 +150,6 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { .font_size = opts.font_size, .size = .{ .width = 800, .height = 600 }, .cursor_pos = .{ .x = 0, .y = 0 }, - .clipboard = std.mem.zeroes(c.GValue), .im_context = im_context, }; errdefer self.* = undefined; @@ -228,7 +226,6 @@ pub fn deinit(self: *Surface) void { // Free all our GTK stuff c.g_object_unref(self.im_context); - c.g_value_unset(&self.clipboard); if (self.cursor) |cursor| c.g_object_unref(cursor); } @@ -419,35 +416,26 @@ pub fn setMouseVisibility(self: *Surface, visible: bool) void { c.gtk_widget_set_cursor(@ptrCast(self.gl_area), self.app.cursor_none); } -pub fn getClipboardString( +pub fn clipboardRequest( self: *Surface, clipboard_type: apprt.Clipboard, -) ![:0]const u8 { + state: apprt.ClipboardRequest, +) !void { + // We allocate for userdata for the clipboard request. Not ideal but + // clipboard requests aren't common so probably not a big deal. + const alloc = self.app.core_app.alloc; + const ud_ptr = try alloc.create(ClipboardRequest); + errdefer alloc.destroy(ud_ptr); + ud_ptr.* = .{ .self = self, .state = state }; + + // Start our async request const clipboard = getClipboard(@ptrCast(self.gl_area), clipboard_type); - const content = c.gdk_clipboard_get_content(clipboard) orelse { - // On my machine, this NEVER works, so we fallback to glfw's - // implementation... I believe this never works because we need to - // use the async mechanism with GTK but that doesn't play nice - // with what our core expects. - log.debug("no GTK clipboard contents, falling back to glfw", .{}); - return switch (clipboard_type) { - .standard => glfw.getClipboardString() orelse glfw.mustGetErrorCode(), - .selection => value: { - const raw = glfw_native.getX11SelectionString() orelse - return glfw.mustGetErrorCode(); - break :value std.mem.span(raw); - }, - }; - }; - - c.g_value_unset(&self.clipboard); - _ = c.g_value_init(&self.clipboard, c.G_TYPE_STRING); - if (c.gdk_content_provider_get_value(content, &self.clipboard, null) == 0) { - return ""; - } - - const ptr = c.g_value_get_string(&self.clipboard); - return std.mem.sliceTo(ptr, 0); + c.gdk_clipboard_read_text_async( + clipboard, + null, + >kClipboardRead, + ud_ptr, + ); } pub fn setClipboardString( @@ -459,6 +447,40 @@ pub fn setClipboardString( c.gdk_clipboard_set_text(clipboard, val.ptr); } +const ClipboardRequest = struct { + self: *Surface, + state: apprt.ClipboardRequest, +}; + +fn gtkClipboardRead( + source: ?*c.GObject, + res: ?*c.GAsyncResult, + ud: ?*anyopaque, +) callconv(.C) void { + const req: *ClipboardRequest = @ptrCast(@alignCast(ud orelse return)); + const self = req.self; + const alloc = self.app.core_app.alloc; + defer alloc.destroy(req); + + var gerr: ?*c.GError = null; + const cstr = c.gdk_clipboard_read_text_finish( + @ptrCast(source orelse return), + res, + &gerr, + ); + if (gerr) |err| { + defer c.g_error_free(err); + log.warn("failed to read clipboard err={s}", .{err.message}); + return; + } + defer c.g_free(cstr); + + const str = std.mem.sliceTo(cstr, 0); + self.core_surface.completeClipboardRequest(req.state, str) catch |err| { + log.err("failed to complete clipboard request err={}", .{err}); + }; +} + fn getClipboard(widget: *c.GtkWidget, clipboard: apprt.Clipboard) ?*c.GdkClipboard { return switch (clipboard) { .standard => c.gtk_widget_get_clipboard(widget),