mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-18 21:40:29 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user