From fe98f3884d7dd72f0988949ab661beb018a191b4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Mar 2026 10:37:57 -0700 Subject: [PATCH] macos: only show split grab handle when the mouse is near it Fixes #11379 For this pass, I made it a very simple "within 20%" (height-wise) of the split handle. There is no horizontal component. I want to find the right balance between always visible (today mostly) to only visible on direct hover, because I think it'll be too hard to discover on that far right side. --- .../Surface View/SurfaceGrabHandle.swift | 36 +++++++++++++++++-- .../Surface View/SurfaceView_AppKit.swift | 13 +++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index 086511bb6..c5ab84124 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -3,6 +3,12 @@ import SwiftUI extension Ghostty { /// A grab handle overlay at the top of the surface for dragging a surface. struct SurfaceGrabHandle: View { + // Size of the actual drag handle; the hover reveal region is larger. + private static let handleSize = CGSize(width: 80, height: 12) + + // Reveal the handle anywhere within the top % of the pane height. + private static let hoverHeightFactor: CGFloat = 0.2 + @ObservedObject var surfaceView: SurfaceView @State private var isHovering: Bool = false @@ -19,7 +25,15 @@ extension Ghostty { } private var ellipsisVisible: Bool { - surfaceView.mouseOverSurface && surfaceView.cursorVisible + // If the cursor isn't visible, never show the handle + guard surfaceView.cursorVisible else { return false } + // If we're hovering or actively dragging, always visible + if isHovering || isDragging { return true } + + // Require our mouse location to be within the top area of the + // surface. + guard let mouseLocation = surfaceView.mouseLocationInSurface else { return false } + return Self.isInHoverRegion(mouseLocation, in: surfaceView.bounds) } var body: some View { @@ -30,7 +44,7 @@ extension Ghostty { isDragging: $isDragging, isHovering: $isHovering ) - .frame(width: 80, height: 12) + .frame(width: Self.handleSize.width, height: Self.handleSize.height) .contentShape(Rectangle()) if ellipsisVisible { @@ -45,5 +59,23 @@ extension Ghostty { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } + + /// The full-width hover band that reveals the drag handle. + private static func hoverRect(in bounds: CGRect) -> CGRect { + guard !bounds.isEmpty else { return .zero } + + let hoverHeight = min(bounds.height, max(handleSize.height, bounds.height * hoverHeightFactor)) + return CGRect( + x: bounds.minX, + y: bounds.maxY - hoverHeight, + width: bounds.width, + height: hoverHeight + ) + } + + /// Returns true when the pointer is inside the top hover band. + private static func isInHoverRegion(_ point: CGPoint, in bounds: CGRect) -> Bool { + hoverRect(in: bounds).contains(point) + } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index a37feb9a8..338d20118 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -119,6 +119,10 @@ extension Ghostty { // Whether the mouse is currently over this surface @Published private(set) var mouseOverSurface: Bool = false + // The last known mouse location in the surface's local coordinate space, + // used by overlays such as the split drag handle reveal region. + @Published private(set) var mouseLocationInSurface: CGPoint? + // Whether the cursor is currently visible (not hidden by typing, etc.) @Published private(set) var cursorVisible: Bool = true @@ -952,13 +956,15 @@ extension Ghostty { mouseOverSurface = true super.mouseEntered(with: event) + let pos = self.convert(event.locationInWindow, from: nil) + mouseLocationInSurface = pos + 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 mouseEvent = Ghostty.Input.MousePosEvent( x: pos.x, y: frame.height - pos.y, @@ -969,6 +975,7 @@ extension Ghostty { override func mouseExited(with event: NSEvent) { mouseOverSurface = false + mouseLocationInSurface = nil guard let surfaceModel else { return } // If the mouse is being dragged then we don't have to emit @@ -988,10 +995,12 @@ extension Ghostty { } override func mouseMoved(with event: NSEvent) { + let pos = self.convert(event.locationInWindow, from: nil) + mouseLocationInSurface = pos + 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 mouseEvent = Ghostty.Input.MousePosEvent( x: pos.x, y: frame.height - pos.y,