From 07722804dbea8f85e456793c0f7ad40dc8419f74 Mon Sep 17 00:00:00 2001 From: Morten Trydal Date: Tue, 17 Mar 2026 22:50:52 +0100 Subject: [PATCH 1/2] Add mouse cursor shape, visibility, and link hover URL support Handle GHOSTTY_ACTION_MOUSE_SHAPE, MOUSE_VISIBILITY, and MOUSE_OVER_LINK actions from libghostty. Cursor shape changes via resetCursorRects/NSCursor mapping (15 shapes with macOS 15+ columnResize/rowResize fallbacks), cursor hide/show via setHiddenUntilMouseMoves, and hovered link URLs surfaced as a dual-pill tooltip overlay in PaneLeafView that toggles sides to avoid the cursor. --- .../Terminal/GhosttyTerminalView.swift | 7 ++- .../WorkspaceDetail/PaneLeafView.swift | 49 ++++++++++++++++ .../Ghostty/GhosttyRuntime.swift | 49 +++++++++++++--- .../Ghostty/LibghosttySurfaceView.swift | 57 +++++++++++++++++++ 4 files changed, 152 insertions(+), 10 deletions(-) diff --git a/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift b/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift index fe3d0f9..c642ffd 100644 --- a/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift +++ b/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift @@ -82,6 +82,7 @@ struct GhosttyTerminalView: NSViewRepresentable { let onPaneNavigationRequest: (PaneNodeModel.PaneFocusDirection) -> Void let onProgressReport: (SurfaceProgressReport?) -> Void let onSearchStateChange: (SurfaceSearchState?) -> Void + let onHoverUrlChange: (String?) -> Void /// Builds the AppKit surface host. func makeNSView(context: Context) -> NSView { @@ -98,7 +99,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onChildExited: onChildExited, onPaneNavigationRequest: onPaneNavigationRequest, onProgressReport: onProgressReport, - onSearchStateChange: onSearchStateChange + onSearchStateChange: onSearchStateChange, + onHoverUrlChange: onHoverUrlChange ) Self.syncContainerView( containerView, @@ -139,7 +141,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onChildExited: onChildExited, onPaneNavigationRequest: onPaneNavigationRequest, onProgressReport: onProgressReport, - onSearchStateChange: onSearchStateChange + onSearchStateChange: onSearchStateChange, + onHoverUrlChange: onHoverUrlChange ) Self.syncContainerView( container, diff --git a/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift b/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift index 41fe8ae..1066add 100644 --- a/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift +++ b/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift @@ -8,6 +8,10 @@ struct PaneLeafView: View { /// Active in-terminal search state, set by the runtime when a search starts or ends. @State private var searchState: SurfaceSearchState? + /// URL currently hovered via Cmd+hover link detection, or nil when not hovering a link. + @State private var hoverUrl: String? + /// Tracks which side the URL tooltip is pinned to; toggled when hovering to avoid obstruction. + @State private var isHoveringURLLeft: Bool = false /// Active surface for the pane. private var activeSurface: SurfaceModel? { @@ -241,6 +245,9 @@ struct PaneLeafView: View { }, onSearchStateChange: { state in searchState = state + }, + onHoverUrlChange: { url in + hoverUrl = url } ) .id(activeSurface.id) @@ -300,6 +307,48 @@ struct PaneLeafView: View { .padding(.trailing, 20) .padding(.top, 14) } + + if let hoverUrl { + let padding: CGFloat = 5 + let cornerRadius: CGFloat = 9 + ZStack { + HStack { + Spacer() + VStack(alignment: .leading) { + Spacer() + Text(verbatim: hoverUrl) + .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) + .background( + UnevenRoundedRectangle(cornerRadii: .init(topLeading: cornerRadius)) + .fill(.background) + ) + .lineLimit(1) + .truncationMode(.middle) + .opacity(isHoveringURLLeft ? 1 : 0) + } + } + + HStack { + VStack(alignment: .leading) { + Spacer() + Text(verbatim: hoverUrl) + .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) + .background( + UnevenRoundedRectangle(cornerRadii: .init(topTrailing: cornerRadius)) + .fill(.background) + ) + .lineLimit(1) + .truncationMode(.middle) + .opacity(isHoveringURLLeft ? 0 : 1) + .onHover { hovering in + isHoveringURLLeft = hovering + } + } + Spacer() + } + } + .allowsHitTesting(!isHoveringURLLeft) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift index 348d495..a1b169c 100644 --- a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift +++ b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift @@ -36,6 +36,7 @@ final class GhosttyRuntime { let onChildExited: () -> Void let onProgressReport: (SurfaceProgressReport?) -> Void let onSearchStateChange: (SurfaceSearchState?) -> Void + let onHoverUrlChange: (String?) -> Void } /// Pasteboard targets supported by the embedded host. @@ -99,9 +100,9 @@ final class GhosttyRuntime { private var appDidBecomeActiveObserver: NSObjectProtocol? private var appDidResignActiveObserver: NSObjectProtocol? - /// Registers surface callbacks, preserving any existing search state callback. + /// Registers surface callbacks, preserving any existing search and hover URL callbacks. /// - /// Used by `LibghosttySurfaceView` which is not aware of the search layer. + /// Used by `LibghosttySurfaceView` which is not aware of the search or hover-URL layers. func registerSurfaceCallbacks( surfaceId: UUID, onIdleNotification: @escaping () -> Void, @@ -111,6 +112,7 @@ final class GhosttyRuntime { onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) { let existingSearch = callbacksBySurfaceId[surfaceId]?.onSearchStateChange ?? { _ in } + let existingHover = callbacksBySurfaceId[surfaceId]?.onHoverUrlChange ?? { _ in } registerSurfaceCallbacks( surfaceId: surfaceId, onIdleNotification: onIdleNotification, @@ -118,11 +120,12 @@ final class GhosttyRuntime { onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, onProgressReport: onProgressReport, - onSearchStateChange: existingSearch + onSearchStateChange: existingSearch, + onHoverUrlChange: existingHover ) } - /// Registers all callbacks for a surface model identifier, including progress and search state callbacks. + /// Registers all callbacks for a surface model identifier, including progress, search, and hover URL callbacks. func registerSurfaceCallbacks( surfaceId: UUID, onIdleNotification: @escaping () -> Void, @@ -130,7 +133,8 @@ final class GhosttyRuntime { onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, onProgressReport: @escaping (SurfaceProgressReport?) -> Void, - onSearchStateChange: @escaping (SurfaceSearchState?) -> Void + onSearchStateChange: @escaping (SurfaceSearchState?) -> Void, + onHoverUrlChange: @escaping (String?) -> Void ) { callbacksBySurfaceId[surfaceId] = SurfaceCallbacks( onIdleNotification: onIdleNotification, @@ -138,7 +142,8 @@ final class GhosttyRuntime { onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, onProgressReport: onProgressReport, - onSearchStateChange: onSearchStateChange + onSearchStateChange: onSearchStateChange, + onHoverUrlChange: onHoverUrlChange ) } @@ -154,7 +159,8 @@ final class GhosttyRuntime { onChildExited: @escaping () -> Void, onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, onProgressReport: @escaping (SurfaceProgressReport?) -> Void, - onSearchStateChange: @escaping (SurfaceSearchState?) -> Void + onSearchStateChange: @escaping (SurfaceSearchState?) -> Void, + onHoverUrlChange: @escaping (String?) -> Void ) -> LibghosttySurfaceView { registerSurfaceCallbacks( surfaceId: surfaceModel.id, @@ -163,7 +169,8 @@ final class GhosttyRuntime { onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, onProgressReport: onProgressReport, - onSearchStateChange: onSearchStateChange + onSearchStateChange: onSearchStateChange, + onHoverUrlChange: onHoverUrlChange ) releasedSurfaceIds.remove(surfaceModel.id) @@ -942,6 +949,32 @@ final class GhosttyRuntime { runtime.searchStateBySurfaceId[surfaceId]?.selected = rawSelected < 0 ? nil : UInt(rawSelected) } return true + case GHOSTTY_ACTION_MOUSE_SHAPE: + guard target.tag == GHOSTTY_TARGET_SURFACE, + let surface = target.target.surface else { return false } + let surfaceKey = UInt(bitPattern: surface) + let shape = action.action.mouse_shape + DispatchQueue.main.async { + guard let surfaceId = runtime.surfaceIdsByHandle[surfaceKey], + let view = runtime.hostViewsBySurfaceId[surfaceId] else { return } + view.setCursorShape(shape) + } + return true + case GHOSTTY_ACTION_MOUSE_VISIBILITY: + let visible = action.action.mouse_visibility == GHOSTTY_MOUSE_VISIBLE + DispatchQueue.main.async { NSCursor.setHiddenUntilMouseMoves(!visible) } + return true + case GHOSTTY_ACTION_MOUSE_OVER_LINK: + guard target.tag == GHOSTTY_TARGET_SURFACE, + let surface = target.target.surface else { return false } + let surfaceKey = UInt(bitPattern: surface) + let raw = action.action.mouse_over_link + let url: String? = raw.len > 0 ? string(from: raw.url, length: Int(raw.len)) : nil + DispatchQueue.main.async { + guard let surfaceId = runtime.surfaceIdsByHandle[surfaceKey] else { return } + runtime.callbacksBySurfaceId[surfaceId]?.onHoverUrlChange(url) + } + return true default: return true } diff --git a/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift b/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift index 4ed6eb7..b2e4c0b 100644 --- a/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift +++ b/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift @@ -9,6 +9,8 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati private var terminalConfig: TerminalPanelConfig private var surfaceHandle: ghostty_surface_t? + /// Active cursor shape for this surface, updated via libghostty MOUSE_SHAPE actions. + private var currentCursor: NSCursor = .iBeam private var onActivate: () -> Void private var onIdleNotification: () -> Void private var onInput: (SurfaceInputEvent) -> Void @@ -105,6 +107,61 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati updateDisplayIdentifier() } + /// Registers the active cursor for the entire bounds of the surface. + override func resetCursorRects() { + addCursorRect(bounds, cursor: currentCursor) + } + + /// Updates the displayed cursor shape in response to a libghostty MOUSE_SHAPE action. + func setCursorShape(_ shape: ghostty_action_mouse_shape_e) { + currentCursor = Self.nsCursor(for: shape) + window?.invalidateCursorRects(for: self) + } + + /// Maps a Ghostty cursor shape enum value to the closest available NSCursor. + private static func nsCursor(for shape: ghostty_action_mouse_shape_e) -> NSCursor { + switch shape { + case GHOSTTY_MOUSE_SHAPE_DEFAULT: + return .arrow + case GHOSTTY_MOUSE_SHAPE_TEXT: + return .iBeam + case GHOSTTY_MOUSE_SHAPE_POINTER: + return .pointingHand + case GHOSTTY_MOUSE_SHAPE_GRAB: + return .openHand + case GHOSTTY_MOUSE_SHAPE_GRABBING: + return .closedHand + case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: + return .iBeamCursorForVerticalLayout + case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: + return .contextualMenu + case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: + return .crosshair + case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: + return .operationNotAllowed + case GHOSTTY_MOUSE_SHAPE_W_RESIZE: + if #available(macOS 15.0, *) { return .columnResize(directions: .left) } + return .resizeLeft + case GHOSTTY_MOUSE_SHAPE_E_RESIZE: + if #available(macOS 15.0, *) { return .columnResize(directions: .right) } + return .resizeRight + case GHOSTTY_MOUSE_SHAPE_N_RESIZE: + if #available(macOS 15.0, *) { return .rowResize(directions: .up) } + return .resizeUp + case GHOSTTY_MOUSE_SHAPE_S_RESIZE: + if #available(macOS 15.0, *) { return .rowResize(directions: .down) } + return .resizeDown + case GHOSTTY_MOUSE_SHAPE_NS_RESIZE: + if #available(macOS 15.0, *) { return .rowResize } + return .resizeUpDown + case GHOSTTY_MOUSE_SHAPE_EW_RESIZE: + if #available(macOS 15.0, *) { return .columnResize } + return .resizeLeftRight + default: + return .arrow + } + } + /// Rebuilds tracking regions so Ghostty receives hover and motion events. override func updateTrackingAreas() { super.updateTrackingAreas() From e713e9b1381634b26273eeec7ce8504c4363a2d8 Mon Sep 17 00:00:00 2001 From: Morten Trydal Date: Tue, 17 Mar 2026 23:09:48 +0100 Subject: [PATCH 2/2] Fix hover URL tooltip getting stuck on right side Move .allowsHitTesting(false) from ZStack to the right-side HStack only, so the left-side HStack's onHover can still receive exit events when the cursor moves away. --- Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift b/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift index 1066add..0aa9083 100644 --- a/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift +++ b/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift @@ -327,6 +327,7 @@ struct PaneLeafView: View { .opacity(isHoveringURLLeft ? 1 : 0) } } + .allowsHitTesting(false) HStack { VStack(alignment: .leading) { @@ -347,7 +348,6 @@ struct PaneLeafView: View { Spacer() } } - .allowsHitTesting(!isHoveringURLLeft) } } .frame(maxWidth: .infinity, maxHeight: .infinity)