diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index a2d6b104d..bbc83c5e5 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -269,6 +269,40 @@ extension Ghostty.Input { self.mods = Mods(cMods: mods) } } + + /// Represents a mouse position/movement event with coordinates and modifier keys. + struct MousePosEvent { + let x: Double + let y: Double + let mods: Mods + + init( + x: Double, + y: Double, + mods: Mods = [] + ) { + self.x = x + self.y = y + self.mods = mods + } + } + + /// Represents a mouse scroll event with scroll deltas and modifier keys. + struct MouseScrollEvent { + let x: Double + let y: Double + let mods: ScrollMods + + init( + x: Double, + y: Double, + mods: ScrollMods = .init(rawValue: 0) + ) { + self.x = x + self.y = y + self.mods = mods + } + } } // MARK: Ghostty.Input.MouseState @@ -335,6 +369,92 @@ extension Ghostty.Input.MouseButton: AppEnum { ] } +// MARK: Ghostty.Input.ScrollMods + +extension Ghostty.Input { + /// `ghostty_input_scroll_mods_t` - Scroll event modifiers + /// + /// This is a packed bitmask that contains precision and momentum information + /// for scroll events, matching the Zig `ScrollMods` packed struct. + struct ScrollMods { + let rawValue: Int32 + + /// True if this is a high-precision scroll event (e.g., trackpad, Magic Mouse) + var precision: Bool { + rawValue & 0b0000_0001 != 0 + } + + /// The momentum phase of the scroll event for inertial scrolling + var momentum: Momentum { + let momentumBits = (rawValue >> 1) & 0b0000_0111 + return Momentum(rawValue: UInt8(momentumBits)) ?? .none + } + + init(precision: Bool = false, momentum: Momentum = .none) { + var value: Int32 = 0 + if precision { + value |= 0b0000_0001 + } + value |= Int32(momentum.rawValue) << 1 + self.rawValue = value + } + + init(rawValue: Int32) { + self.rawValue = rawValue + } + + var cScrollMods: ghostty_input_scroll_mods_t { + rawValue + } + } +} + +// MARK: Ghostty.Input.Momentum + +extension Ghostty.Input { + /// `ghostty_input_mouse_momentum_e` - Momentum phase for scroll events + enum Momentum: UInt8, CaseIterable { + case none = 0 + case began = 1 + case stationary = 2 + case changed = 3 + case ended = 4 + case cancelled = 5 + case mayBegin = 6 + + var cMomentum: ghostty_input_mouse_momentum_e { + switch self { + case .none: GHOSTTY_MOUSE_MOMENTUM_NONE + case .began: GHOSTTY_MOUSE_MOMENTUM_BEGAN + case .stationary: GHOSTTY_MOUSE_MOMENTUM_STATIONARY + case .changed: GHOSTTY_MOUSE_MOMENTUM_CHANGED + case .ended: GHOSTTY_MOUSE_MOMENTUM_ENDED + case .cancelled: GHOSTTY_MOUSE_MOMENTUM_CANCELLED + case .mayBegin: GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN + } + } + } +} + +#if canImport(AppKit) +import AppKit + +extension Ghostty.Input.Momentum { + /// Create a Momentum from an NSEvent.Phase + init(_ phase: NSEvent.Phase) { + switch phase { + case .began: self = .began + case .stationary: self = .stationary + case .changed: self = .changed + case .ended: self = .ended + case .cancelled: self = .cancelled + case .mayBegin: self = .mayBegin + default: self = .none + } + } +} +#endif + // MARK: Ghostty.Input.Mods extension Ghostty.Input { diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift index 2cc85f1e4..c7198e147 100644 --- a/macos/Sources/Ghostty/Ghostty.Surface.swift +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -88,6 +88,38 @@ extension Ghostty { event.mods.cMods) } + /// Send a mouse position event to the terminal. + /// + /// This reports the current mouse position to the terminal, which may be used + /// for mouse tracking, hover effects, or other position-dependent features. + /// The terminal will only receive these events if mouse reporting is enabled. + /// + /// - Parameter event: The mouse position event to send to the terminal + @MainActor + func sendMousePos(_ event: Input.MousePosEvent) { + ghostty_surface_mouse_pos( + surface, + event.x, + event.y, + event.mods.cMods) + } + + /// Send a mouse scroll event to the terminal. + /// + /// This sends scroll wheel input to the terminal with delta values for both + /// horizontal and vertical scrolling, along with precision and momentum information. + /// The terminal processes this according to its scroll handling configuration. + /// + /// - Parameter event: The mouse scroll event to send to the terminal + @MainActor + func sendMouseScroll(_ event: Input.MouseScrollEvent) { + ghostty_surface_mouse_scroll( + surface, + event.x, + event.y, + event.mods.cScrollMods) + } + /// Perform a keybinding action. /// /// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4` diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d987b80be..83a8da29c 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -808,19 +808,23 @@ extension Ghostty { override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) - guard let surface = self.surface else { return } + guard let surfaceModel else { return } // On mouse enter we need to reset our cursor position. This is // super important because we set it to -1/-1 on mouseExit and // lots of mouse logic (i.e. whether to send mouse reports) depend // on the position being in the viewport if it is. let pos = self.convert(event.locationInWindow, from: nil) - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods) + let mouseEvent = Ghostty.Input.MousePosEvent( + x: pos.x, + y: frame.height - pos.y, + mods: .init(nsFlags: event.modifierFlags) + ) + surfaceModel.sendMousePos(mouseEvent) } override func mouseExited(with event: NSEvent) { - guard let surface = self.surface else { return } + guard let surfaceModel else { return } // If the mouse is being dragged then we don't have to emit // this because we get mouse drag events even if we've already @@ -830,17 +834,25 @@ extension Ghostty { } // Negative values indicate cursor has left the viewport - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_pos(surface, -1, -1, mods) + let mouseEvent = Ghostty.Input.MousePosEvent( + x: -1, + y: -1, + mods: .init(nsFlags: event.modifierFlags) + ) + surfaceModel.sendMousePos(mouseEvent) } override func mouseMoved(with event: NSEvent) { - guard let surface = self.surface else { return } + guard let surfaceModel else { return } // Convert window position to view position. Note (0, 0) is bottom left. let pos = self.convert(event.locationInWindow, from: nil) - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods) + let mouseEvent = Ghostty.Input.MousePosEvent( + x: pos.x, + y: frame.height - pos.y, + mods: .init(nsFlags: event.modifierFlags) + ) + surfaceModel.sendMousePos(mouseEvent) // Handle focus-follows-mouse if let window, @@ -866,16 +878,13 @@ extension Ghostty { } override func scrollWheel(with event: NSEvent) { - guard let surface = self.surface else { return } - - // Builds up the "input.ScrollMods" bitmask - var mods: Int32 = 0 + guard let surfaceModel else { return } var x = event.scrollingDeltaX var y = event.scrollingDeltaY - if event.hasPreciseScrollingDeltas { - mods = 1 - + let precision = event.hasPreciseScrollingDeltas + + if precision { // We do a 2x speed multiplier. This is subjective, it "feels" better to me. x *= 2; y *= 2; @@ -883,29 +892,12 @@ extension Ghostty { // 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) { - case .began: - momentum = GHOSTTY_MOUSE_MOMENTUM_BEGAN - case .stationary: - momentum = GHOSTTY_MOUSE_MOMENTUM_STATIONARY - case .changed: - momentum = GHOSTTY_MOUSE_MOMENTUM_CHANGED - case .ended: - momentum = GHOSTTY_MOUSE_MOMENTUM_ENDED - case .cancelled: - momentum = GHOSTTY_MOUSE_MOMENTUM_CANCELLED - case .mayBegin: - momentum = GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN - default: - break - } - - // Pack our momentum value into the mods bitmask - mods |= Int32(momentum.rawValue) << 1 - - ghostty_surface_mouse_scroll(surface, x, y, mods) + let scrollEvent = Ghostty.Input.MouseScrollEvent( + x: x, + y: y, + mods: .init(precision: precision, momentum: .init(event.momentumPhase)) + ) + surfaceModel.sendMouseScroll(scrollEvent) } override func pressureChange(with event: NSEvent) {