Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -98,7 +99,8 @@ struct GhosttyTerminalView: NSViewRepresentable {
onChildExited: onChildExited,
onPaneNavigationRequest: onPaneNavigationRequest,
onProgressReport: onProgressReport,
onSearchStateChange: onSearchStateChange
onSearchStateChange: onSearchStateChange,
onHoverUrlChange: onHoverUrlChange
)
Self.syncContainerView(
containerView,
Expand Down Expand Up @@ -139,7 +141,8 @@ struct GhosttyTerminalView: NSViewRepresentable {
onChildExited: onChildExited,
onPaneNavigationRequest: onPaneNavigationRequest,
onProgressReport: onProgressReport,
onSearchStateChange: onSearchStateChange
onSearchStateChange: onSearchStateChange,
onHoverUrlChange: onHoverUrlChange
)
Self.syncContainerView(
container,
Expand Down
49 changes: 49 additions & 0 deletions Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -241,6 +245,9 @@ struct PaneLeafView: View {
},
onSearchStateChange: { state in
searchState = state
},
onHoverUrlChange: { url in
hoverUrl = url
}
)
.id(activeSurface.id)
Expand Down Expand Up @@ -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)
}
}
.allowsHitTesting(false)

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()
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
Expand Down
49 changes: 41 additions & 8 deletions Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -111,34 +112,38 @@ final class GhosttyRuntime {
onProgressReport: @escaping (SurfaceProgressReport?) -> Void
) {
let existingSearch = callbacksBySurfaceId[surfaceId]?.onSearchStateChange ?? { _ in }
let existingHover = callbacksBySurfaceId[surfaceId]?.onHoverUrlChange ?? { _ in }
registerSurfaceCallbacks(
surfaceId: surfaceId,
onIdleNotification: onIdleNotification,
onTitleChange: onTitleChange,
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,
onTitleChange: @escaping (String) -> Void,
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,
onTitleChange: onTitleChange,
onWorkingDirectoryChange: onWorkingDirectoryChange,
onChildExited: onChildExited,
onProgressReport: onProgressReport,
onSearchStateChange: onSearchStateChange
onSearchStateChange: onSearchStateChange,
onHoverUrlChange: onHoverUrlChange
)
}

Expand All @@ -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,
Expand All @@ -163,7 +169,8 @@ final class GhosttyRuntime {
onWorkingDirectoryChange: onWorkingDirectoryChange,
onChildExited: onChildExited,
onProgressReport: onProgressReport,
onSearchStateChange: onSearchStateChange
onSearchStateChange: onSearchStateChange,
onHoverUrlChange: onHoverUrlChange
)
releasedSurfaceIds.remove(surfaceModel.id)

Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading