diff --git a/include/ghostty.h b/include/ghostty.h index eab70bac2..225265633 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -70,6 +70,43 @@ typedef enum { GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN, } ghostty_input_mouse_momentum_e; +typedef enum { + GHOSTTY_MOUSE_SHAPE_DEFAULT, + GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU, + GHOSTTY_MOUSE_SHAPE_HELP, + GHOSTTY_MOUSE_SHAPE_POINTER, + GHOSTTY_MOUSE_SHAPE_PROGRESS, + GHOSTTY_MOUSE_SHAPE_WAIT, + GHOSTTY_MOUSE_SHAPE_CELL, + GHOSTTY_MOUSE_SHAPE_CROSSHAIR, + GHOSTTY_MOUSE_SHAPE_TEXT, + GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT, + GHOSTTY_MOUSE_SHAPE_ALIAS, + GHOSTTY_MOUSE_SHAPE_COPY, + GHOSTTY_MOUSE_SHAPE_MOVE, + GHOSTTY_MOUSE_SHAPE_NO_DROP, + GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED, + GHOSTTY_MOUSE_SHAPE_GRAB, + GHOSTTY_MOUSE_SHAPE_GRABBING, + GHOSTTY_MOUSE_SHAPE_ALL_SCROLL, + GHOSTTY_MOUSE_SHAPE_COL_RESIZE, + GHOSTTY_MOUSE_SHAPE_ROW_RESIZE, + GHOSTTY_MOUSE_SHAPE_N_RESIZE, + GHOSTTY_MOUSE_SHAPE_E_RESIZE, + GHOSTTY_MOUSE_SHAPE_S_RESIZE, + GHOSTTY_MOUSE_SHAPE_W_RESIZE, + GHOSTTY_MOUSE_SHAPE_NE_RESIZE, + GHOSTTY_MOUSE_SHAPE_NW_RESIZE, + GHOSTTY_MOUSE_SHAPE_SE_RESIZE, + GHOSTTY_MOUSE_SHAPE_SW_RESIZE, + GHOSTTY_MOUSE_SHAPE_EW_RESIZE, + GHOSTTY_MOUSE_SHAPE_NS_RESIZE, + GHOSTTY_MOUSE_SHAPE_NESW_RESIZE, + GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE, + GHOSTTY_MOUSE_SHAPE_ZOOM_IN, + GHOSTTY_MOUSE_SHAPE_ZOOM_OUT, +} ghostty_mouse_shape_e; + typedef enum { GHOSTTY_NON_NATIVE_FULLSCREEN_FALSE, GHOSTTY_NON_NATIVE_FULLSCREEN_TRUE, @@ -264,6 +301,7 @@ typedef struct { typedef void (*ghostty_runtime_wakeup_cb)(void *); 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 const char* (*ghostty_runtime_read_clipboard_cb)(void *, ghostty_clipboard_e); 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); @@ -281,6 +319,7 @@ typedef struct { ghostty_runtime_wakeup_cb wakeup_cb; ghostty_runtime_reload_config_cb reload_config_cb; ghostty_runtime_set_title_cb set_title_cb; + ghostty_runtime_set_mouse_shape_cb set_mouse_shape_cb; ghostty_runtime_read_clipboard_cb read_clipboard_cb; ghostty_runtime_write_clipboard_cb write_clipboard_cb; ghostty_runtime_new_split_cb new_split_cb; diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index b6dec9dec..2743ef01f 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -72,6 +72,7 @@ extension Ghostty { wakeup_cb: { userdata in AppState.wakeup(userdata) }, reload_config_cb: { userdata in AppState.reloadConfig(userdata) }, set_title_cb: { userdata, title in AppState.setTitle(userdata, title: title) }, + set_mouse_shape_cb: { userdata, shape in AppState.setMouseShape(userdata, shape: shape) }, read_clipboard_cb: { userdata, loc in AppState.readClipboard(userdata, location: loc) }, 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) }, @@ -332,6 +333,11 @@ extension Ghostty { surfaceView.title = titleStr } } + + static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) { + let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + surfaceView.setCursorShape(shape) + } static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) { guard let surface = self.surfaceUserdata(from: userdata) else { return } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index ae7f73652..94c4700f4 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -6,7 +6,7 @@ extension Ghostty { struct Terminal: View { @Environment(\.ghosttyApp) private var app @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? - + var body: some View { if let app = self.app { SurfaceForApp(app) { surfaceView in @@ -16,44 +16,44 @@ extension Ghostty { } } } - + /// Yields a SurfaceView for a ghostty app that can then be used however you want. struct SurfaceForApp: View { let content: ((SurfaceView) -> Content) - + @StateObject private var surfaceView: SurfaceView - + init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) { _surfaceView = StateObject(wrappedValue: SurfaceView(app, nil)) self.content = content } - + var body: some View { content(surfaceView) } } - + struct SurfaceWrapper: View { // The surface to create a view for. This must be created upstream. As long as this // remains the same, the surface that is being rendered remains the same. @ObservedObject var surfaceView: SurfaceView - + // True if this surface is part of a split view. This is important to know so // we know whether to dim the surface out of focus. var isSplit: Bool = false - + // Maintain whether our view has focus or not @FocusState private var surfaceFocus: Bool - + // Maintain whether our window has focus (is key) or not @State private var windowFocus: Bool = true - + @Environment(\.ghosttyConfig) private var ghostty_config - + // This is true if the terminal is considered "focused". The terminal is focused if // it is both individually focused and the containing window is key. private var hasFocus: Bool { surfaceFocus && windowFocus } - + // The opacity of the rectangle when unfocused. private var unfocusedOpacity: Double { var opacity: Double = 0.85 @@ -61,7 +61,7 @@ extension Ghostty { _ = ghostty_config_get(ghostty_config, &opacity, key, UInt(key.count)) return 1 - opacity } - + var body: some View { ZStack { // We use a GeometryReader to get the frame bounds so that our metal surface @@ -73,7 +73,7 @@ extension Ghostty { let pubBecomeFocused = NotificationCenter.default.publisher(for: Notification.didBecomeFocusedSurface, object: surfaceView) let pubBecomeKey = NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification) let pubResign = NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification) - + Surface(view: surfaceView, hasFocus: hasFocus, size: geo.size) .focused($surfaceFocus) .focusedValue(\.ghosttySurfaceTitle, surfaceView.title) @@ -95,7 +95,7 @@ extension Ghostty { // method doesn't work properly. See the dispatch of this notification // for more information. if #available(macOS 13, *) { return } - + DispatchQueue.main.async { surfaceFocus = true } @@ -125,13 +125,13 @@ extension Ghostty { surfaceFocus = true } } - + // I don't know how older macOS versions behave but Ghostty only // supports back to macOS 12 so its moot. } } .ghosttySurfaceView(surfaceView) - + // If we're part of a split view and don't have focus, we put a semi-transparent // rectangle above our view to make it look unfocused. We use "surfaceFocus" // because we want to keep our focused surface dark even if we don't have window @@ -145,7 +145,7 @@ extension Ghostty { } } } - + /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. @@ -156,12 +156,12 @@ extension Ghostty { struct Surface: NSViewRepresentable { /// The view to render for the terminal surface. let view: SurfaceView - + /// This should be set to true when the surface has focus. This is up to the parent because /// focus is also defined by window focus. It is important this is set correctly since if it is /// false then the surface will idle at almost 0% CPU. let hasFocus: Bool - + /// The size of the frame containing this view. We use this to update the the underlying /// surface. This does not actually SET the size of our frame, this only sets the size /// of our Metal surface for drawing. @@ -171,74 +171,76 @@ extension Ghostty { /// /// The best approach is to wrap this view in a GeometryReader and pass in the geo.size. let size: CGSize - + func makeNSView(context: Context) -> SurfaceView { // We need the view as part of the state to be created previously because // the view is sent to the Ghostty API so that it can manipulate it // directly since we draw on a render thread. return view; } - + func updateNSView(_ view: SurfaceView, context: Context) { view.focusDidChange(hasFocus) view.sizeDidChange(size) } } - + /// The NSView implementation for a terminal surface. class SurfaceView: NSView, NSTextInputClient, ObservableObject { // The current title of the surface as defined by the pty. This can be // changed with escape codes. This is public because the callbacks go // to the app level and it is set from there. @Published var title: String = "👻" - + private(set) var surface: ghostty_surface_t? var error: Error? = nil - - private var markedText: NSMutableAttributedString; - + + private var markedText: NSMutableAttributedString + private var mouseEntered: Bool = false + private var cursor: NSCursor = .arrow + // We need to support being a first responder so that we can get input events override var acceptsFirstResponder: Bool { return true } - + // I don't think we need this but this lets us know we should redraw our layer // so we'll use that to tell ghostty to refresh. override var wantsUpdateLayer: Bool { return true } - + init(_ app: ghostty_app_t, _ baseConfig: ghostty_surface_config_s?) { self.markedText = NSMutableAttributedString() - + // Initialize with some default frame size. The important thing is that this // is non-zero so that our layer bounds are non-zero so that our renderer // can do SOMETHING. super.init(frame: NSMakeRect(0, 0, 800, 600)) - + // Setup our surface. This will also initialize all the terminal IO. var surface_cfg = baseConfig ?? ghostty_surface_config_new() surface_cfg.userdata = Unmanaged.passUnretained(self).toOpaque() surface_cfg.nsview = Unmanaged.passUnretained(self).toOpaque() surface_cfg.scale_factor = NSScreen.main!.backingScaleFactor - + guard let surface = ghostty_surface_new(app, &surface_cfg) else { self.error = AppError.surfaceCreateError return } self.surface = surface; - + // Setup our tracking area so we get mouse moved events updateTrackingAreas() } - + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported for this view") } - + deinit { trackingAreas.forEach { removeTrackingArea($0) } - + guard let surface = self.surface else { return } ghostty_surface_free(surface) } - + /// Close the surface early. This will free the associated Ghostty surface and the view will /// no longer render. The view can never be used again. This is a way for us to free the /// Ghostty resources while references may still be held to this view. I've found that SwiftUI @@ -248,15 +250,15 @@ extension Ghostty { ghostty_surface_free(surface) self.surface = nil } - + func focusDidChange(_ focused: Bool) { guard let surface = self.surface else { return } ghostty_surface_set_focus(surface, focused) } - + func sizeDidChange(_ size: CGSize) { guard let surface = self.surface else { return } - + // Ghostty wants to know the actual framebuffer size... It is very important // here that we use "size" and NOT the view frame. If we're in the middle of // an animation (i.e. a fullscreen animation), the frame will not yet be updated. @@ -264,35 +266,93 @@ extension Ghostty { let scaledSize = self.convertToBacking(size) ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height)) } - + + func setCursorShape(_ shape: ghostty_mouse_shape_e) { + switch (shape) { + case GHOSTTY_MOUSE_SHAPE_DEFAULT: + cursor = .arrow + + case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: + cursor = .contextualMenu + + case GHOSTTY_MOUSE_SHAPE_TEXT: + cursor = .iBeam + + case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: + cursor = .crosshair + + case GHOSTTY_MOUSE_SHAPE_GRAB: + cursor = .openHand + + case GHOSTTY_MOUSE_SHAPE_GRABBING: + cursor = .closedHand + + case GHOSTTY_MOUSE_SHAPE_POINTER: + cursor = .pointingHand + + case GHOSTTY_MOUSE_SHAPE_W_RESIZE: + cursor = .resizeLeft + + case GHOSTTY_MOUSE_SHAPE_E_RESIZE: + cursor = .resizeRight + + case GHOSTTY_MOUSE_SHAPE_N_RESIZE: + cursor = .resizeUp + + case GHOSTTY_MOUSE_SHAPE_S_RESIZE: + cursor = .resizeDown + + case GHOSTTY_MOUSE_SHAPE_NS_RESIZE: + cursor = .resizeUpDown + + case GHOSTTY_MOUSE_SHAPE_EW_RESIZE: + cursor = .resizeLeftRight + + case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: + cursor = .iBeamCursorForVerticalLayout + + case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: + cursor = .operationNotAllowed + + default: + // We ignore unknown shapes. + return + } + + // Set our cursor immediately if our mouse is over our window + if (mouseEntered) { + cursor.set() + } + } + override func viewDidMoveToWindow() { guard let window = self.window else { return } guard let surface = self.surface else { return } guard ghostty_surface_transparent(surface) else { return } - + // Set the window transparency settings window.isOpaque = false window.hasShadow = false window.backgroundColor = .clear - + // If we have a blur, set the blur ghostty_set_window_background_blur(surface, Unmanaged.passUnretained(window).toOpaque()) } - + override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() - + // We sometimes call this manually (see SplitView) as a way to force us to // yield our focus state. if (result) { focusDidChange(false) } - + return result } - + override func updateTrackingAreas() { // To update our tracking area we just recreate it all. trackingAreas.forEach { removeTrackingArea($0) } - + // This tracking area is across the entire frame to notify us of mouse movements. addTrackingArea(NSTrackingArea( rect: frame, @@ -300,7 +360,7 @@ extension Ghostty { .mouseEnteredAndExited, .mouseMoved, .inVisibleRect, - + // It is possible this is incorrect when we have splits. This will make // mouse events only happen while the terminal is focused. Is that what // we want? @@ -309,12 +369,12 @@ extension Ghostty { owner: self, userInfo: nil)) } - + override func resetCursorRects() { discardCursorRects() addCursorRect(frame, cursor: .iBeam) } - + override func viewDidChangeBackingProperties() { guard let surface = self.surface else { return } @@ -323,71 +383,79 @@ extension Ghostty { let xScale = fbFrame.size.width / self.frame.size.width let yScale = fbFrame.size.height / self.frame.size.height ghostty_surface_set_content_scale(surface, xScale, yScale) - + // When our scale factor changes, so does our fb size so we send that too ghostty_surface_set_size(surface, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height)) } - + override func updateLayer() { guard let surface = self.surface else { return } ghostty_surface_refresh(surface); } - + override func mouseDown(with event: NSEvent) { guard let surface = self.surface else { return } let mods = Self.translateFlags(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) } - + override func mouseUp(with event: NSEvent) { guard let surface = self.surface else { return } let mods = Self.translateFlags(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) } - + override func rightMouseDown(with event: NSEvent) { guard let surface = self.surface else { return } let mods = Self.translateFlags(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods) } - + override func rightMouseUp(with event: NSEvent) { guard let surface = self.surface else { return } let mods = Self.translateFlags(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods) } - + override func mouseMoved(with event: NSEvent) { guard let surface = self.surface else { return } // Convert window position to view position. Note (0, 0) is bottom left. let pos = self.convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y) - + } - + override func mouseDragged(with event: NSEvent) { self.mouseMoved(with: event) } - + + override func mouseEntered(with event: NSEvent) { + mouseEntered = true + } + + override func mouseExited(with event: NSEvent) { + mouseEntered = false + } + override func scrollWheel(with event: NSEvent) { guard let surface = self.surface else { return } - + // Builds up the "input.ScrollMods" bitmask var mods: Int32 = 0 - + var x = event.scrollingDeltaX var y = event.scrollingDeltaY if event.hasPreciseScrollingDeltas { mods = 1 - + // We do a 2x speed multiplier. This is subjective, it "feels" better to me. x *= 2; y *= 2; - + // TODO(mitchellh): do we have to scale the x/y here by window scale factor? } - + // Determine our momentum value var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE switch (event.momentumPhase) { @@ -406,17 +474,21 @@ extension Ghostty { default: break } - + // Pack our momentum value into the mods bitmask mods |= Int32(momentum.rawValue) << 1 - + ghostty_surface_mouse_scroll(surface, x, y, mods) } + override func cursorUpdate(with event: NSEvent) { + cursor.set() + } + override func keyDown(with event: NSEvent) { let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS keyAction(action, event: event) - + // We specifically DO NOT call interpretKeyEvents because ghostty_surface_key // automatically handles all key translation, and we don't handle any commands // currently. @@ -427,11 +499,11 @@ extension Ghostty { // // self.interpretKeyEvents([event]) } - + override func keyUp(with event: NSEvent) { keyAction(GHOSTTY_ACTION_RELEASE, event: event) } - + override func flagsChanged(with event: NSEvent) { let mod: UInt32; switch (event.keyCode) { @@ -442,26 +514,26 @@ extension Ghostty { case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue default: return } - + // The keyAction function will do this AGAIN below which sucks to repeat // but this is super cheap and flagsChanged isn't that common. let mods = Self.translateFlags(event.modifierFlags) - + // If the key that pressed this is active, its a press, else release var action = GHOSTTY_ACTION_RELEASE if (mods.rawValue & mod != 0) { action = GHOSTTY_ACTION_PRESS } - + keyAction(action, event: event) } - + private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { guard let surface = self.surface else { return } let mods = Self.translateFlags(event.modifierFlags) ghostty_surface_key(surface, action, UInt32(event.keyCode), mods) } - + // MARK: Menu Handlers - + @IBAction func copy(_ sender: Any?) { guard let surface = self.surface else { return } let action = "copy_to_clipboard" @@ -469,7 +541,7 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } - + @IBAction func paste(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" @@ -477,7 +549,7 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } - + @IBAction func pasteAsPlainText(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" @@ -485,75 +557,75 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } - + // MARK: NSTextInputClient - + func hasMarkedText() -> Bool { return markedText.length > 0 } - + func markedRange() -> NSRange { guard markedText.length > 0 else { return NSRange() } return NSRange(0...(markedText.length-1)) } - + func selectedRange() -> NSRange { return NSRange() } - + func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { switch string { case let v as NSAttributedString: self.markedText = NSMutableAttributedString(attributedString: v) - + case let v as String: self.markedText = NSMutableAttributedString(string: v) - + default: print("unknown marked text: \(string)") } } - + func unmarkText() { self.markedText.mutableString.setString("") } - + func validAttributesForMarkedText() -> [NSAttributedString.Key] { return [] } - + func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { return nil } - + func characterIndex(for point: NSPoint) -> Int { return 0 } - + func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { guard let surface = self.surface else { return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0) } - + // Ghostty will tell us where it thinks an IME keyboard should render. var x: Double = 0; var y: Double = 0; ghostty_surface_ime_point(surface, &x, &y) - + // Ghostty coordinates are in top-left (0, 0) so we have to convert to // bottom-left since that is what UIKit expects let rect = NSMakeRect(x, frame.size.height - y, 0, 0) - + // Convert from view to screen coordinates guard let window = self.window else { return rect } return window.convertToScreen(rect) } - + func insertText(_ string: Any, replacementRange: NSRange) { // We must have an associated event guard NSApp.currentEvent != nil else { return } guard let surface = self.surface else { return } - + // We want the string view of the any value var chars = "" switch (string) { @@ -564,39 +636,39 @@ extension Ghostty { default: return } - + for codepoint in chars.unicodeScalars { ghostty_surface_char(surface, codepoint.value) } } - + override func doCommand(by selector: Selector) { // This currently just prevents NSBeep from interpretKeyEvents but in the future // we may want to make some of this work. - + print("SEL: \(selector)") } - + private static func translateFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue - + if (flags.contains(.shift)) { mods |= GHOSTTY_MODS_SHIFT.rawValue } if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue } if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue } if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue } if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue } - + // Handle sided input. We can't tell that both are pressed in the // Ghostty structure but thats okay -- we don't use that information. let rawFlags = flags.rawValue if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue } if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue } if (rawFlags & UInt(NX_DEVICERALTKEYMASK) != 0) { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue } - if (rawFlags & UInt(NX_DEVICERCMDKEYMASK) != 0) { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue } - + if (rawFlags & UInt(NX_DEVICERCMDKEYMASK) != 0) { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue } + return ghostty_input_mods_e(mods) } - + // Mapping of event keyCode to ghostty input key values. This is cribbed from // glfw mostly since we started as a glfw-based app way back in the day! static let keycodes: [UInt16 : ghostty_input_key_e] = [ @@ -742,7 +814,7 @@ extension FocusedValues { get { self[FocusedGhosttySurface.self] } set { self[FocusedGhosttySurface.self] = newValue } } - + struct FocusedGhosttySurface: FocusedValueKey { typealias Value = Ghostty.SurfaceView } @@ -753,7 +825,7 @@ extension FocusedValues { get { self[FocusedGhosttySurfaceTitle.self] } set { self[FocusedGhosttySurfaceTitle.self] = newValue } } - + struct FocusedGhosttySurfaceTitle: FocusedValueKey { typealias Value = String } @@ -764,7 +836,7 @@ extension FocusedValues { get { self[FocusedGhosttySurfaceZoomed.self] } set { self[FocusedGhosttySurfaceZoomed.self] = newValue } } - + struct FocusedGhosttySurfaceZoomed: FocusedValueKey { typealias Value = Bool } diff --git a/src/Surface.zig b/src/Surface.zig index ba657181f..eda00483f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -525,6 +525,11 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { try self.rt_surface.setTitle(slice); }, + .set_mouse_shape => |shape| { + log.debug("changing mouse shape: {}", .{shape}); + try self.rt_surface.setMouseShape(shape); + }, + .cell_size => |size| try self.setCellSize(size), .clipboard_read => |kind| try self.clipboardRead(kind), diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 3420fdd05..54746ec50 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -11,6 +11,7 @@ const Allocator = std.mem.Allocator; const objc = @import("objc"); const apprt = @import("../apprt.zig"); const input = @import("../input.zig"); +const terminal = @import("../terminal/main.zig"); const CoreApp = @import("../App.zig"); const CoreSurface = @import("../Surface.zig"); const configpkg = @import("../config.zig"); @@ -48,6 +49,9 @@ pub const App = struct { /// Called to set the title of the window. set_title: *const fn (SurfaceUD, [*]const u8) callconv(.C) void, + /// Called to set the cursor shape. + set_mouse_shape: *const fn (SurfaceUD, terminal.MouseShape) callconv(.C) void, + /// 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. @@ -310,6 +314,13 @@ pub const Surface = struct { ); } + pub fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void { + self.app.opts.set_mouse_shape( + self.opts.userdata, + shape, + ); + } + pub fn supportsClipboard( self: *const Surface, clipboard_type: apprt.Clipboard, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 47b37ec9a..2efab9539 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -15,6 +15,7 @@ const objc = @import("objc"); const input = @import("../input.zig"); const internal_os = @import("../os/main.zig"); const renderer = @import("../renderer.zig"); +const terminal = @import("../terminal/main.zig"); const Renderer = renderer.Renderer; const apprt = @import("../apprt.zig"); const CoreApp = @import("../App.zig"); @@ -275,7 +276,7 @@ pub const Surface = struct { window: glfw.Window, /// The glfw mouse cursor handle. - cursor: glfw.Cursor, + cursor: ?glfw.Cursor, /// The app we're part of app: *App, @@ -335,16 +336,6 @@ pub const Surface = struct { nswindow.setProperty("tabbingIdentifier", app.darwin.tabbing_id); } - // Create the cursor - const cursor = glfw.Cursor.createStandard(.ibeam) orelse return glfw.mustGetErrorCode(); - errdefer cursor.destroy(); - if ((comptime !builtin.target.isDarwin()) or internal_os.macosVersionAtLeast(13, 0, 0)) { - // We only set our cursor if we're NOT on Mac, or if we are then the - // macOS version is >= 13 (Ventura). On prior versions, glfw crashes - // since we use a tab group. - win.setCursor(cursor); - } - // Set our callbacks win.setUserPointer(&self.core_surface); win.setSizeCallback(sizeCallback); @@ -360,11 +351,14 @@ pub const Surface = struct { self.* = .{ .app = app, .window = win, - .cursor = cursor, + .cursor = null, .core_surface = undefined, }; errdefer self.* = undefined; + // Initialize our cursor + try self.setMouseShape(.text); + // Add ourselves to the list of surfaces on the app. try app.app.addSurface(self); errdefer app.app.deleteSurface(self); @@ -425,7 +419,10 @@ pub const Surface = struct { // We can now safely destroy our windows. We have to do this BEFORE // setting up the new focused window below. self.window.destroy(); - self.cursor.destroy(); + if (self.cursor) |c| { + c.destroy(); + self.cursor = null; + } // If we have a tabgroup set, we want to manually focus the next window. // We should NOT have to do this usually, see the comments above. @@ -508,6 +505,43 @@ pub const Surface = struct { self.window.setTitle(slice.ptr); } + /// Set the shape of the cursor. + pub fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void { + if ((comptime builtin.target.isDarwin()) and + !internal_os.macosVersionAtLeast(13, 0, 0)) + { + // We only set our cursor if we're NOT on Mac, or if we are then the + // macOS version is >= 13 (Ventura). On prior versions, glfw crashes + // since we use a tab group. + return; + } + + const new = glfw.Cursor.createStandard(switch (shape) { + .default => .arrow, + .text => .ibeam, + .crosshair => .crosshair, + .pointer => .pointing_hand, + .ew_resize => .resize_ew, + .ns_resize => .resize_ns, + .nwse_resize => .resize_nwse, + .nesw_resize => .resize_nesw, + .all_scroll => .resize_all, + .not_allowed => .not_allowed, + else => return, // unsupported, ignore + }) orelse { + const err = glfw.mustGetErrorCode(); + log.warn("error creating cursor: {}", .{err}); + return; + }; + errdefer new.destroy(); + + // Set our cursor before we destroy the old one + self.window.setCursor(new); + + if (self.cursor) |c| c.destroy(); + self.cursor = new; + } + /// 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. diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 580970711..560c24b34 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -8,6 +8,7 @@ const glfw = @import("glfw"); const apprt = @import("../apprt.zig"); const font = @import("../font/main.zig"); const input = @import("../input.zig"); +const terminal = @import("../terminal/main.zig"); const CoreApp = @import("../App.zig"); const CoreSurface = @import("../Surface.zig"); const configpkg = @import("../config.zig"); @@ -44,9 +45,6 @@ pub const App = struct { app: *c.GtkApplication, ctx: *c.GMainContext, - cursor_default: *c.GdkCursor, - cursor_ibeam: *c.GdkCursor, - /// This is set to false when the main loop should exit. running: bool = true, @@ -125,19 +123,11 @@ pub const App = struct { // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 c.g_application_activate(gapp); - // Get our cursors - const cursor_default = c.gdk_cursor_new_from_name("default", null).?; - errdefer c.g_object_unref(cursor_default); - const cursor_ibeam = c.gdk_cursor_new_from_name("text", cursor_default).?; - errdefer c.g_object_unref(cursor_ibeam); - return .{ .core_app = core_app, .app = app, .config = config, .ctx = ctx, - .cursor_default = cursor_default, - .cursor_ibeam = cursor_ibeam, // If we are NOT the primary instance, then we never want to run. // This means that another instance of the GTK app is running and @@ -154,9 +144,6 @@ pub const App = struct { c.g_main_context_release(self.ctx); c.g_object_unref(self.app); - c.g_object_unref(self.cursor_ibeam); - c.g_object_unref(self.cursor_default); - self.config.deinit(); glfw.terminate(); @@ -722,6 +709,9 @@ pub const Surface = struct { /// Our GTK area gl_area: *c.GtkGLArea, + /// Any active cursor we may have + cursor: ?*c.GdkCursor = null, + /// Our title label (if there is one). title: Title, @@ -798,9 +788,6 @@ pub const Surface = struct { c.gtk_widget_set_focusable(widget, 1); c.gtk_widget_set_focus_on_click(widget, 1); - // When we're over the widget, set the cursor to the ibeam - c.gtk_widget_set_cursor(widget, app.cursor_ibeam); - // Build our result self.* = .{ .app = app, @@ -880,6 +867,8 @@ pub const Surface = struct { // 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); } fn render(self: *Surface) !void { @@ -998,6 +987,61 @@ pub const Surface = struct { // )); } + pub fn setMouseShape( + self: *Surface, + shape: terminal.MouseShape, + ) !void { + const name: [:0]const u8 = switch (shape) { + .default => "default", + .help => "help", + .pointer => "pointer", + .context_menu => "context-menu", + .progress => "progress", + .wait => "wait", + .cell => "cell", + .crosshair => "crosshair", + .text => "text", + .vertical_text => "vertical-text", + .alias => "alias", + .copy => "copy", + .no_drop => "no-drop", + .move => "move", + .not_allowed => "not-allowed", + .grab => "grab", + .grabbing => "grabbing", + .all_scroll => "all-scroll", + .col_resize => "col-resize", + .row_resize => "row-resize", + .n_resize => "n-resize", + .e_resize => "e-resize", + .s_resize => "s-resize", + .w_resize => "w-resize", + .ne_resize => "ne-resize", + .nw_resize => "nw-resize", + .se_resize => "se-resize", + .sw_resize => "sw-resize", + .ew_resize => "ew-resize", + .ns_resize => "ns-resize", + .nesw_resize => "nesw-resize", + .nwse_resize => "nwse-resize", + .zoom_in => "zoom-in", + .zoom_out => "zoom-out", + }; + + const cursor = c.gdk_cursor_new_from_name(name.ptr, null) orelse { + log.warn("unsupported cursor name={s}", .{name}); + return; + }; + errdefer c.g_object_unref(cursor); + + // Set our new cursor + c.gtk_widget_set_cursor(@ptrCast(self.gl_area), cursor); + + // Free our existing cursor + if (self.cursor) |old| c.g_object_unref(old); + self.cursor = cursor; + } + pub fn getClipboardString( self: *Surface, clipboard_type: apprt.Clipboard, diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 67b4247e8..1cf303888 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -2,6 +2,7 @@ const App = @import("../App.zig"); const Surface = @import("../Surface.zig"); const renderer = @import("../renderer.zig"); const termio = @import("../termio.zig"); +const terminal = @import("../terminal/main.zig"); const Config = @import("../config.zig").Config; /// The message types that can be sent to a single surface. @@ -16,6 +17,9 @@ pub const Message = union(enum) { /// of any length set_title: [256]u8, + /// Set the mouse shape. + set_mouse_shape: terminal.MouseShape, + /// Change the cell size. cell_size: renderer.CellSize, diff --git a/src/terminal/main.zig b/src/terminal/main.zig index e1a6ee439..3de1835d9 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -15,6 +15,7 @@ pub const parse_table = @import("parse_table.zig"); pub const Charset = charsets.Charset; pub const CharsetSlot = charsets.Slots; pub const CharsetActiveSlot = charsets.ActiveSlot; +pub const MouseShape = @import("mouse_shape.zig").MouseShape; pub const Terminal = @import("Terminal.zig"); pub const Parser = @import("Parser.zig"); pub const Selection = @import("Selection.zig"); diff --git a/src/terminal/mouse_shape.zig b/src/terminal/mouse_shape.zig new file mode 100644 index 000000000..cf8f42c4b --- /dev/null +++ b/src/terminal/mouse_shape.zig @@ -0,0 +1,115 @@ +const std = @import("std"); + +/// The possible cursor shapes. Not all app runtimes support these shapes. +/// The shapes are always based on the W3C supported cursor styles so we +/// can have a cross platform list. +// +// Must be kept in sync with ghostty_cursor_shape_e +pub const MouseShape = enum(c_int) { + default, + context_menu, + help, + pointer, + progress, + wait, + cell, + crosshair, + text, + vertical_text, + alias, + copy, + move, + no_drop, + not_allowed, + grab, + grabbing, + all_scroll, + col_resize, + row_resize, + n_resize, + e_resize, + s_resize, + w_resize, + ne_resize, + nw_resize, + se_resize, + sw_resize, + ew_resize, + ns_resize, + nesw_resize, + nwse_resize, + zoom_in, + zoom_out, + + /// Build cursor shape from string or null if its unknown. + pub fn fromString(v: []const u8) ?MouseShape { + return string_map.get(v); + } +}; + +const string_map = std.ComptimeStringMap(MouseShape, .{ + // W3C + .{ "default", .default }, + .{ "context-menu", .context_menu }, + .{ "help", .help }, + .{ "pointer", .pointer }, + .{ "progress", .progress }, + .{ "wait", .wait }, + .{ "cell", .cell }, + .{ "crosshair", .crosshair }, + .{ "text", .text }, + .{ "vertical-text", .vertical_text }, + .{ "alias", .alias }, + .{ "copy", .copy }, + .{ "move", .move }, + .{ "no-drop", .no_drop }, + .{ "not-allowed", .not_allowed }, + .{ "grab", .grab }, + .{ "grabbing", .grabbing }, + .{ "all-scroll", .all_scroll }, + .{ "col-resize", .col_resize }, + .{ "row-resize", .row_resize }, + .{ "n-resize", .n_resize }, + .{ "e-resize", .e_resize }, + .{ "s-resize", .s_resize }, + .{ "w-resize", .w_resize }, + .{ "ne-resize", .ne_resize }, + .{ "nw-resize", .nw_resize }, + .{ "se-resize", .se_resize }, + .{ "sw-resize", .sw_resize }, + .{ "ew-resize", .ew_resize }, + .{ "ns-resize", .ns_resize }, + .{ "nesw-resize", .nesw_resize }, + .{ "nwse-resize", .nwse_resize }, + .{ "zoom-in", .zoom_in }, + .{ "zoom-out", .zoom_out }, + + // xterm/foot + .{ "left_ptr", .default }, + .{ "question_arrow", .help }, + .{ "hand", .pointer }, + .{ "left_ptr_watch", .progress }, + .{ "watch", .wait }, + .{ "cross", .crosshair }, + .{ "xterm", .text }, + .{ "dnd-link", .alias }, + .{ "dnd-copy", .copy }, + .{ "dnd-move", .move }, + .{ "dnd-no-drop", .no_drop }, + .{ "crossed_circle", .not_allowed }, + .{ "hand1", .grab }, + .{ "right_side", .e_resize }, + .{ "top_side", .n_resize }, + .{ "top_right_corner", .ne_resize }, + .{ "top_left_corner", .nw_resize }, + .{ "bottom_side", .s_resize }, + .{ "bottom_right_corner", .se_resize }, + .{ "bottom_left_corner", .sw_resize }, + .{ "left_side", .w_resize }, + .{ "fleur", .all_scroll }, +}); + +test "cursor shape from string" { + const testing = std.testing; + try testing.expectEqual(MouseShape.default, MouseShape.fromString("default").?); +} diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 990962261..f61504daf 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -83,6 +83,14 @@ pub const Command = union(enum) { /// be a file URL but it is up to the caller to utilize this value. value: []const u8, }, + + /// OSC 22. Set the mouse shape. There doesn't seem to be a standard + /// naming scheme for cursors but it looks like terminals such as Foot + /// are moving towards using the W3C CSS cursor names. For OSC parsing, + /// we just parse whatever string is given. + mouse_shape: struct { + value: []const u8, + }, }; pub const Parser = struct { @@ -130,6 +138,7 @@ pub const Parser = struct { @"13", @"133", @"2", + @"22", @"5", @"52", @"7", @@ -226,6 +235,7 @@ pub const Parser = struct { }, .@"2" => switch (c) { + '2' => self.state = .@"22", ';' => { self.command = .{ .change_window_title = undefined }; @@ -236,6 +246,17 @@ pub const Parser = struct { else => self.state = .invalid, }, + .@"22" => switch (c) { + ';' => { + self.command = .{ .mouse_shape = undefined }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.mouse_shape.value }; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + .@"5" => switch (c) { '2' => self.state = .@"52", else => self.state = .invalid, @@ -642,6 +663,19 @@ test "OSC: report pwd" { try testing.expect(std.mem.eql(u8, "file:///tmp/example", cmd.report_pwd.value)); } +test "OSC: pointer cursor" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "22;pointer"; + for (input) |ch| p.next(ch); + + const cmd = p.end().?; + try testing.expect(cmd == .mouse_shape); + try testing.expect(std.mem.eql(u8, "pointer", cmd.mouse_shape.value)); +} + test "OSC: report pwd empty" { const testing = std.testing; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 04d2b9aef..c12bc4063 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -9,6 +9,7 @@ const modes = @import("modes.zig"); const osc = @import("osc.zig"); const sgr = @import("sgr.zig"); const trace = @import("tracy").trace; +const MouseShape = @import("mouse_shape.zig").MouseShape; const log = std.log.scoped(.stream); @@ -849,6 +850,17 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, + .mouse_shape => |v| { + if (@hasDecl(T, "setMouseShape")) { + const shape = MouseShape.fromString(v.value) orelse { + log.warn("unknown cursor shape: {s}", .{v.value}); + return; + }; + + try self.handler.setMouseShape(shape); + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + else => if (@hasDecl(T, "oscUnimplemented")) try self.handler.oscUnimplemented(cmd) else diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index aa4627f35..36deb1235 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1682,6 +1682,15 @@ const StreamHandler = struct { }, .{ .forever = {} }); } + pub fn setMouseShape( + self: *StreamHandler, + shape: terminal.MouseShape, + ) !void { + _ = self.ev.surface_mailbox.push(.{ + .set_mouse_shape = shape, + }, .{ .forever = {} }); + } + pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { // Note: we ignore the "kind" field and always use the standard clipboard. // iTerm also appears to do this but other terminals seem to only allow