feat(macos): Refine MacOS surface drag handle UI (#10280)

<img width="638" height="476" alt="Screenshot 2026-01-11 at 1 41 52 PM"
src="https://github.com/user-attachments/assets/bf3457e8-1b1c-4b2d-b6d1-312d48739108"
/>

This PR makes 3 small changes:

1. Makes the surface move grab handle present when the surface is
hovered and the mouse cursor is not hidden.
2. Makes the grab handle partial width, allowing space to more easily
grab the divider for resize (anywhere but the center) and increasing the
grabbable area for the grab handle.
3. Adds appropriate padding to the top of the surface (in the metal
stack so shaders can apply) to give space for the header so that text is
not occluded by the grab handle.

I think it looks good and works well, but I suggest trying it out since
the interaction is the most important part.

Problems I was trying to solve:
1. The old grab bar overlays actual clickable area on TUIs and can make
them hard to use
2. The old bar makes the entire divider also a grab area, making divider
resizing more difficult.
3. The old bar is not always present, making it hard to discover until
you're going to resize something, which then is confusing
4. The old bar is not colored with the style.


https://github.com/user-attachments/assets/588a35b5-ba2f-4074-8edb-e090e0006224


AI Disclosure: I originally did this with Claude, but at this point I've
gone over this code manually enough to feel somewhat familiar. I think
the video and design speak for themselves and the code change is
minimal, but I'm not a Swift programmer, so I can't evaluate whether
this is the best possible solution.

Human Disclosure: I don't have a linux machine to check that the padding
doesn't apply outside of MacOS. I find it hard to believe that it
wouldn't work, but worth calling out.
This commit is contained in:
Mitchell Hashimoto
2026-02-23 12:25:56 -08:00
committed by GitHub
2 changed files with 32 additions and 27 deletions

View File

@@ -1,41 +1,37 @@
import AppKit
import SwiftUI
extension Ghostty {
/// A grab handle overlay at the top of the surface for dragging the window.
/// Only appears when hovering in the top region of the surface.
struct SurfaceGrabHandle: View {
private let handleHeight: CGFloat = 10
let surfaceView: SurfaceView
@ObservedObject var surfaceView: SurfaceView
@State private var isHovering: Bool = false
@State private var isDragging: Bool = false
var body: some View {
VStack(spacing: 0) {
Rectangle()
.fill(Color.primary.opacity(isHovering || isDragging ? 0.15 : 0))
.frame(height: handleHeight)
.overlay(alignment: .center) {
if isHovering || isDragging {
Image(systemName: "ellipsis")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.primary.opacity(0.5))
}
}
.contentShape(Rectangle())
.overlay {
SurfaceDragSource(
surfaceView: surfaceView,
isDragging: $isDragging,
isHovering: $isHovering
)
}
private var ellipsisVisible: Bool {
surfaceView.mouseOverSurface && surfaceView.cursorVisible
}
Spacer()
var body: some View {
ZStack {
SurfaceDragSource(
surfaceView: surfaceView,
isDragging: $isDragging,
isHovering: $isHovering
)
.frame(width: 80, height: 12)
.contentShape(Rectangle())
if ellipsisVisible {
Image(systemName: "ellipsis")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.primary.opacity(isHovering ? 0.8 : 0.3))
.offset(y: -3)
.allowsHitTesting(false)
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
}

View File

@@ -116,6 +116,12 @@ extension Ghostty {
// Whether the pointer should be visible or not
@Published private(set) var pointerStyle: CursorStyle = .horizontalText
// Whether the mouse is currently over this surface
@Published private(set) var mouseOverSurface: Bool = false
// Whether the cursor is currently visible (not hidden by typing, etc.)
@Published private(set) var cursorVisible: Bool = true
/// The configuration derived from the Ghostty config so we don't need to rely on references.
@Published private(set) var derivedConfig: DerivedConfig
@@ -533,6 +539,7 @@ extension Ghostty {
}
func setCursorVisibility(_ visible: Bool) {
cursorVisible = visible
// Technically this action could be called anytime we want to
// change the mouse visibility but at the time of writing this
// mouse-hide-while-typing is the only use case so this is the
@@ -910,6 +917,7 @@ extension Ghostty {
}
override func mouseEntered(with event: NSEvent) {
mouseOverSurface = true
super.mouseEntered(with: event)
guard let surfaceModel else { return }
@@ -928,6 +936,7 @@ extension Ghostty {
}
override func mouseExited(with event: NSEvent) {
mouseOverSurface = false
guard let surfaceModel else { return }
// If the mouse is being dragged then we don't have to emit