diff --git a/include/ghostty.h b/include/ghostty.h index e3a9e4223..a082d1a6a 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -533,7 +533,8 @@ ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e); void ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); -void ghostty_surface_mouse_button(ghostty_surface_t, +bool ghostty_surface_mouse_captured(ghostty_surface_t); +bool ghostty_surface_mouse_button(ghostty_surface_t, ghostty_input_mouse_state_e, ghostty_input_mouse_button_e, ghostty_input_mods_e); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index fa0b5e263..0b5af9251 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -50,6 +50,8 @@ A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; }; A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */; }; A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */; }; + A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; + A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */; }; A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; @@ -111,6 +113,7 @@ A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.TerminalSplit.swift; sourceTree = ""; }; A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitNode.swift; sourceTree = ""; }; + A5985CD62C320C4500C57AD3 /* String+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = ""; }; A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = ""; }; A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; @@ -205,6 +208,7 @@ C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */, AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, + A5985CD62C320C4500C57AD3 /* String+Extension.swift */, C1F26EE72B76CBFC00404083 /* VibrantLayer.h */, C1F26EE82B76CBFC00404083 /* VibrantLayer.m */, A5CEAFDA29B8005900646FDA /* SplitView */, @@ -503,6 +507,7 @@ A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, + A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, @@ -537,6 +542,7 @@ A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */, A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */, A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */, + A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */, C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index f41a01d57..499f85b0e 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -471,15 +471,39 @@ extension Ghostty { override func rightMouseDown(with event: NSEvent) { - guard let surface = self.surface else { return } + guard let surface = self.surface else { return super.rightMouseDown(with: event) } + let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods) + if (ghostty_surface_mouse_button( + surface, + GHOSTTY_MOUSE_PRESS, + GHOSTTY_MOUSE_RIGHT, + mods + )) { + // Consumed + return + } + + // Mouse event not consumed + super.rightMouseDown(with: event) } override func rightMouseUp(with event: NSEvent) { - guard let surface = self.surface else { return } + guard let surface = self.surface else { return super.rightMouseUp(with: event) } + let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods) + if (ghostty_surface_mouse_button( + surface, + GHOSTTY_MOUSE_RELEASE, + GHOSTTY_MOUSE_RIGHT, + mods + )) { + // Handled + return + } + + // Mouse event not consumed + super.rightMouseUp(with: event) } override func mouseMoved(with event: NSEvent) { @@ -842,7 +866,59 @@ extension Ghostty { ghostty_surface_key(surface, key_ev) } } + + override func menu(for event: NSEvent) -> NSMenu? { + // We only support right-click menus + switch event.type { + case .rightMouseDown: + // Good + break + + case .leftMouseDown: + if !event.modifierFlags.contains(.control) { + return nil + } + + // In this case, AppKit calls menu BEFORE calling any mouse events. + // If mouse capturing is enabled then we never show the context menu + // so that we can handle ctrl+left-click in the terminal app. + guard let surface = self.surface else { return nil } + if ghostty_surface_mouse_captured(surface) { + return nil + } + + // If we return a non-nil menu then mouse events will never be + // processed by the core, so we need to manually send a right + // mouse down event. + // + // Note this never sounds a right mouse up event but that's the + // same as normal right-click with capturing disabled from AppKit. + let mods = Ghostty.ghosttyMods(event.modifierFlags) + ghostty_surface_mouse_button( + surface, + GHOSTTY_MOUSE_PRESS, + GHOSTTY_MOUSE_RIGHT, + mods + ) + + default: + return nil + } + + let menu = NSMenu() + + // If we have a selection, add copy + if self.selectedRange().length > 0 { + menu.addItem(withTitle: "Copy", action: #selector(copy(_:)), keyEquivalent: "") + } + menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "") + + menu.addItem(.separator()) + menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(TerminalController.toggleTerminalInspector(_:)), keyEquivalent: "") + return menu + } + // MARK: Menu Handlers @IBAction func copy(_ sender: Any?) { diff --git a/macos/Sources/Helpers/String+Extension.swift b/macos/Sources/Helpers/String+Extension.swift new file mode 100644 index 000000000..f39ef8d3d --- /dev/null +++ b/macos/Sources/Helpers/String+Extension.swift @@ -0,0 +1,20 @@ +extension String { + func truncate(length: Int, trailing: String = "…") -> String { + let maxLength = length - trailing.count + guard maxLength > 0, !self.isEmpty, self.count > length else { + return self + } + return self.prefix(maxLength) + trailing + } + + #if canImport(AppKit) + func temporaryFile(_ filename: String = "temp") -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(filename) + .appendingPathExtension("txt") + let string = self + try? string.write(to: url, atomically: true, encoding: .utf8) + return url + } + #endif +} diff --git a/src/Surface.zig b/src/Surface.zig index a4557be78..a63c85cb2 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2129,12 +2129,23 @@ fn mouseShiftCapture(self: *const Surface, lock: bool) bool { }; } +/// Returns true if the mouse is currently captured by the terminal +/// (i.e. reporting events). +pub fn mouseCaptured(self: *Surface) bool { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + return self.io.terminal.flags.mouse_event != .none; +} + +/// Called for mouse button press/release events. This will return true +/// if the mouse event was consumed in some way (i.e. the program is capturing +/// mouse events). If the event was not consumed, then false is returned. pub fn mouseButtonCallback( self: *Surface, action: input.MouseButtonState, button: input.MouseButton, mods: input.Mods, -) !void { +) !bool { // log.debug("mouse action={} button={} mods={}", .{ action, button, mods }); // If we have an inspector, we always queue a render @@ -2155,7 +2166,7 @@ pub fn mouseButtonCallback( const screen = &self.renderer_state.terminal.screen; const p = screen.pages.pin(.{ .viewport = point }) orelse { log.warn("failed to get pin for clicked point", .{}); - return; + return false; }; insp.cell.select( @@ -2166,7 +2177,7 @@ pub fn mouseButtonCallback( ) catch |err| { log.warn("error selecting cell for inspector err={}", .{err}); }; - return; + return false; } } @@ -2205,7 +2216,7 @@ pub fn mouseButtonCallback( if (selection) { const pos = try self.rt_surface.getCursorPos(); try self.cursorPosCallback(pos); - return; + return true; } } } @@ -2216,7 +2227,7 @@ pub fn mouseButtonCallback( if (button == .left and action == .release and self.mouse.over_link) { const pos = try self.rt_surface.getCursorPos(); if (self.processLinks(pos)) |processed| { - if (processed) return; + if (processed) return true; } else |err| { log.warn("error processing links err={}", .{err}); } @@ -2257,7 +2268,7 @@ pub fn mouseButtonCallback( // If we're doing mouse reporting, we do not support any other // selection or highlighting. - return; + return true; } } @@ -2269,7 +2280,7 @@ pub fn mouseButtonCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); try self.clickMoveCursor(pin.*); - return; + return true; } // For left button clicks we always record some information for @@ -2398,6 +2409,53 @@ pub fn mouseButtonCallback( try self.startClipboardRequest(clipboard, .{ .paste = {} }); } } + + // Right-click down selects word for context menus. If the apprt + // doesn't implement context menus this can be a bit weird but they + // are supported by our two main apprts so we always do this. If we + // want to be careful in the future we can add a function to apprts + // that let's us know. + if (button == .right and action == .press) sel: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // Get our viewport pin + const screen = &self.renderer_state.terminal.screen; + const pin = pin: { + const pos = try self.rt_surface.getCursorPos(); + const pt_viewport = self.posToViewport(pos.x, pos.y); + const pin = screen.pages.pin(.{ + .viewport = .{ + .x = pt_viewport.x, + .y = pt_viewport.y, + }, + }) orelse { + // Weird... our viewport x/y that we just converted isn't + // found in our pages. This is probably a bug but we don't + // want to crash in releases because its harmless. So, we + // only assert in debug mode. + if (comptime std.debug.runtime_safety) unreachable; + break :sel; + }; + + break :pin pin; + }; + + // If we already have a selection and the selection contains + // where we clicked then we don't want to modify the selection. + if (self.io.terminal.screen.selection) |prev_sel| { + if (prev_sel.contains(screen, pin)) break :sel; + + // The selection doesn't contain our pin, so we create a new + // word selection where we clicked. + } + + const sel = screen.selectWord(pin) orelse break :sel; + try self.setSelection(sel); + try self.queueRender(); + } + + return false; } /// Performs the "click-to-move" logic to move the cursor to the given diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 0ab955206..d1557eaa0 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -710,10 +710,10 @@ pub const Surface = struct { action: input.MouseButtonState, button: input.MouseButton, mods: input.Mods, - ) void { - self.core_surface.mouseButtonCallback(action, button, mods) catch |err| { + ) bool { + return self.core_surface.mouseButtonCallback(action, button, mods) catch |err| { log.err("error in mouse button callback err={}", .{err}); - return; + return false; }; } @@ -1632,14 +1632,20 @@ pub const CAPI = struct { surface.textCallback(ptr[0..len]); } + /// Returns true if the surface currently has mouse capturing + /// enabled. + export fn ghostty_surface_mouse_captured(surface: *Surface) bool { + return surface.core_surface.mouseCaptured(); + } + /// Tell the surface that it needs to schedule a render export fn ghostty_surface_mouse_button( surface: *Surface, action: input.MouseButtonState, button: input.MouseButton, mods: c_int, - ) void { - surface.mouseButtonCallback( + ) bool { + return surface.mouseButtonCallback( action, button, @bitCast(@as( diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 932e27de5..911eb6f5e 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -1048,7 +1048,7 @@ pub const Surface = struct { else => unreachable, }; - core_win.mouseButtonCallback(action, button, mods) catch |err| { + _ = core_win.mouseButtonCallback(action, button, mods) catch |err| { log.err("error in scroll callback err={}", .{err}); return; }; diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 57bbf0d38..1ee433db9 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1164,7 +1164,7 @@ fn gtkMouseDown( self.grabFocus(); } - self.core_surface.mouseButtonCallback(.press, button, mods) catch |err| { + _ = self.core_surface.mouseButtonCallback(.press, button, mods) catch |err| { log.err("error in key callback err={}", .{err}); return; }; @@ -1184,7 +1184,7 @@ fn gtkMouseUp( const mods = translateMods(gtk_mods); const self = userdataSelf(ud.?); - self.core_surface.mouseButtonCallback(.release, button, mods) catch |err| { + _ = self.core_surface.mouseButtonCallback(.release, button, mods) catch |err| { log.err("error in key callback err={}", .{err}); return; };