macOS: Split drag move (#10090)

Related to #1525

This implements a _Mouse-only_ approach to moving splits. When you hover
near the top of a split, a grab handle now appears that can be used to
drag the split into any other split position.

> [!NOTE]
>
> **This PR only lets you move splits to _other split positions_.** I
will create follow-up issues to track moving a split out into a new tab
or window.

## Demo


https://github.com/user-attachments/assets/fbeeff13-a03c-4e79-b4ba-95537d43c083

## Other Notes

This PR also lays a ton of the groundwork on macOS for surfaces to be
draggable and copyable (pasteboard) _in general_. That isn't used yet
but there might be some interesting ideas here like pasting screenshots
by simply dragging the surface. I don't know!
This commit is contained in:
Mitchell Hashimoto
2025-12-29 08:29:34 -08:00
committed by GitHub
21 changed files with 1038 additions and 47 deletions

View File

@@ -100,5 +100,20 @@
<false/>
<key>SUPublicEDKey</key>
<string>wsNcGf5hirwtdXMVnYoxRIX/SqZQLMOsYlD3q3imeok=</string>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.mitchellh.ghosttySurfaceId</string>
<key>UTTypeDescription</key>
<string>Ghostty Surface Identifier</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict/>
</dict>
</array>
</dict>
</plist>

View File

@@ -66,6 +66,7 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
App/macOS/AppDelegate.swift,
"App/macOS/AppDelegate+Ghostty.swift",
App/macOS/main.swift,
App/macOS/MainMenu.xib,
Features/About/About.xib,
@@ -142,10 +143,12 @@
Ghostty/Ghostty.Event.swift,
Ghostty/Ghostty.Input.swift,
Ghostty/Ghostty.Surface.swift,
Ghostty/InspectorView.swift,
"Ghostty/NSEvent+Extension.swift",
Ghostty/SurfaceScrollView.swift,
Ghostty/SurfaceView_AppKit.swift,
"Ghostty/Surface View/InspectorView.swift",
"Ghostty/Surface View/SurfaceDragSource.swift",
"Ghostty/Surface View/SurfaceGrabHandle.swift",
"Ghostty/Surface View/SurfaceScrollView.swift",
"Ghostty/Surface View/SurfaceView_AppKit.swift",
Helpers/AppInfo.swift,
Helpers/CodableBridge.swift,
Helpers/Cursor.swift,
@@ -166,6 +169,7 @@
"Helpers/Extensions/NSView+Extension.swift",
"Helpers/Extensions/NSWindow+Extension.swift",
"Helpers/Extensions/NSWorkspace+Extension.swift",
"Helpers/Extensions/Transferable+Extension.swift",
"Helpers/Extensions/UndoManager+Extension.swift",
"Helpers/Extensions/View+Extension.swift",
Helpers/Fullscreen.swift,
@@ -187,7 +191,7 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
App/iOS/iOSApp.swift,
Ghostty/SurfaceView_UIKit.swift,
"Ghostty/Surface View/SurfaceView_UIKit.swift",
);
target = A5B30530299BEAAA0047F10C /* Ghostty */;
};

View File

@@ -0,0 +1,23 @@
import AppKit
// MARK: Ghostty Delegate
/// This implements the Ghostty app delegate protocol which is used by the Ghostty
/// APIs for app-global information.
extension AppDelegate: Ghostty.Delegate {
func ghosttySurface(id: UUID) -> Ghostty.SurfaceView? {
for window in NSApp.windows {
guard let controller = window.windowController as? BaseTerminalController else {
continue
}
for surface in controller.surfaceTree {
if surface.id == id {
return surface
}
}
}
return nil
}
}

View File

@@ -1,15 +1,40 @@
import SwiftUI
/// A single operation within the split tree.
///
/// Rather than binding the split tree (which is immutable), any mutable operations are
/// exposed via this enum to the embedder to handle.
enum TerminalSplitOperation {
case resize(Resize)
case drop(Drop)
struct Resize {
let node: SplitTree<Ghostty.SurfaceView>.Node
let ratio: Double
}
struct Drop {
/// The surface being dragged.
let payload: Ghostty.SurfaceView
/// The surface it was dragged onto
let destination: Ghostty.SurfaceView
/// The zone it was dropped to determine how to split the destination.
let zone: TerminalSplitDropZone
}
}
struct TerminalSplitTreeView: View {
let tree: SplitTree<Ghostty.SurfaceView>
let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
let action: (TerminalSplitOperation) -> Void
var body: some View {
if let node = tree.zoomed ?? tree.root {
TerminalSplitSubtreeView(
node: node,
isRoot: node == tree.root,
onResize: onResize)
action: action)
// This is necessary because we can't rely on SwiftUI's implicit
// structural identity to detect changes to this view. Due to
// the tree structure of splits it could result in bad behaviors.
@@ -19,21 +44,17 @@ struct TerminalSplitTreeView: View {
}
}
struct TerminalSplitSubtreeView: View {
fileprivate struct TerminalSplitSubtreeView: View {
@EnvironmentObject var ghostty: Ghostty.App
let node: SplitTree<Ghostty.SurfaceView>.Node
var isRoot: Bool = false
let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
let action: (TerminalSplitOperation) -> Void
var body: some View {
switch (node) {
case .leaf(let leafView):
Ghostty.InspectableSurface(
surfaceView: leafView,
isSplit: !isRoot)
.accessibilityElement(children: .contain)
.accessibilityLabel("Terminal pane")
TerminalSplitLeaf(surfaceView: leafView, isSplit: !isRoot, action: action)
case .split(let split):
let splitViewDirection: SplitViewDirection = switch (split.direction) {
@@ -46,15 +67,15 @@ struct TerminalSplitSubtreeView: View {
.init(get: {
CGFloat(split.ratio)
}, set: {
onResize(node, $0)
action(.resize(.init(node: node, ratio: $0)))
}),
dividerColor: ghostty.config.splitDividerColor,
resizeIncrements: .init(width: 1, height: 1),
left: {
TerminalSplitSubtreeView(node: split.left, onResize: onResize)
TerminalSplitSubtreeView(node: split.left, action: action)
},
right: {
TerminalSplitSubtreeView(node: split.right, onResize: onResize)
TerminalSplitSubtreeView(node: split.right, action: action)
},
onEqualize: {
guard let surface = node.leftmostLeaf().surface else { return }
@@ -64,3 +85,173 @@ struct TerminalSplitSubtreeView: View {
}
}
}
fileprivate struct TerminalSplitLeaf: View {
let surfaceView: Ghostty.SurfaceView
let isSplit: Bool
let action: (TerminalSplitOperation) -> Void
@State private var dropState: DropState = .idle
@State private var isSelfDragging: Bool = false
var body: some View {
GeometryReader { geometry in
Ghostty.InspectableSurface(
surfaceView: surfaceView,
isSplit: isSplit)
.background {
// If we're dragging ourself, we hide the entire drop zone. This makes
// it so that a released drop animates back to its source properly
// so it is a proper invalid drop zone.
if !isSelfDragging {
Color.clear
.onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate(
dropState: $dropState,
viewSize: geometry.size,
destinationSurface: surfaceView,
action: action
))
}
}
.overlay {
if !isSelfDragging, case .dropping(let zone) = dropState {
zone.overlay(in: geometry)
.allowsHitTesting(false)
}
}
.onPreferenceChange(Ghostty.DraggingSurfaceKey.self) { value in
isSelfDragging = value == surfaceView.id
if isSelfDragging {
dropState = .idle
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel("Terminal pane")
}
}
private enum DropState: Equatable {
case idle
case dropping(TerminalSplitDropZone)
}
private struct SplitDropDelegate: DropDelegate {
@Binding var dropState: DropState
let viewSize: CGSize
let destinationSurface: Ghostty.SurfaceView
let action: (TerminalSplitOperation) -> Void
func validateDrop(info: DropInfo) -> Bool {
info.hasItemsConforming(to: [.ghosttySurfaceId])
}
func dropEntered(info: DropInfo) {
dropState = .dropping(.calculate(at: info.location, in: viewSize))
}
func dropUpdated(info: DropInfo) -> DropProposal? {
// For some reason dropUpdated is sent after performDrop is called
// and we don't want to reset our drop zone to show it so we have
// to guard on the state here.
guard case .dropping = dropState else { return DropProposal(operation: .forbidden) }
dropState = .dropping(.calculate(at: info.location, in: viewSize))
return DropProposal(operation: .move)
}
func dropExited(info: DropInfo) {
dropState = .idle
}
func performDrop(info: DropInfo) -> Bool {
let zone = TerminalSplitDropZone.calculate(at: info.location, in: viewSize)
dropState = .idle
// Load the dropped surface asynchronously using Transferable
let providers = info.itemProviders(for: [.ghosttySurfaceId])
guard let provider = providers.first else { return false }
// Capture action before the async closure
_ = provider.loadTransferable(type: Ghostty.SurfaceView.self) { [weak destinationSurface] result in
switch result {
case .success(let sourceSurface):
DispatchQueue.main.async {
// Don't allow dropping on self
guard let destinationSurface else { return }
guard sourceSurface !== destinationSurface else { return }
action(.drop(.init(payload: sourceSurface, destination: destinationSurface, zone: zone)))
}
case .failure:
break
}
}
return true
}
}
}
enum TerminalSplitDropZone: String, Equatable {
case top
case bottom
case left
case right
/// Determines which drop zone the cursor is in based on proximity to edges.
///
/// Divides the view into four triangular regions by drawing diagonals from
/// corner to corner. The drop zone is determined by which edge the cursor
/// is closest to, creating natural triangular hit regions for each side.
static func calculate(at point: CGPoint, in size: CGSize) -> TerminalSplitDropZone {
let relX = point.x / size.width
let relY = point.y / size.height
let distToLeft = relX
let distToRight = 1 - relX
let distToTop = relY
let distToBottom = 1 - relY
let minDist = min(distToLeft, distToRight, distToTop, distToBottom)
if minDist == distToLeft { return .left }
if minDist == distToRight { return .right }
if minDist == distToTop { return .top }
return .bottom
}
@ViewBuilder
func overlay(in geometry: GeometryProxy) -> some View {
let overlayColor = Color.accentColor.opacity(0.3)
switch self {
case .top:
VStack(spacing: 0) {
Rectangle()
.fill(overlayColor)
.frame(height: geometry.size.height / 2)
Spacer()
}
case .bottom:
VStack(spacing: 0) {
Spacer()
Rectangle()
.fill(overlayColor)
.frame(height: geometry.size.height / 2)
}
case .left:
HStack(spacing: 0) {
Rectangle()
.fill(overlayColor)
.frame(width: geometry.size.width / 2)
Spacer()
}
case .right:
HStack(spacing: 0) {
Spacer()
Rectangle()
.fill(overlayColor)
.frame(width: geometry.size.width / 2)
}
}
}
}

View File

@@ -466,33 +466,33 @@ class BaseTerminalController: NSWindowController,
Ghostty.moveFocus(to: newView, from: oldView)
}
}
// Setup our undo
if let undoManager {
if let undoAction {
undoManager.setActionName(undoAction)
guard let undoManager else { return }
if let undoAction {
undoManager.setActionName(undoAction)
}
undoManager.registerUndo(
withTarget: self,
expiresAfter: undoExpiration
) { target in
target.surfaceTree = oldTree
if let oldView {
DispatchQueue.main.async {
Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
}
}
undoManager.registerUndo(
withTarget: self,
expiresAfter: undoExpiration
withTarget: target,
expiresAfter: target.undoExpiration
) { target in
target.surfaceTree = oldTree
if let oldView {
DispatchQueue.main.async {
Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
}
}
undoManager.registerUndo(
withTarget: target,
expiresAfter: target.undoExpiration
) { target in
target.replaceSurfaceTree(
newTree,
moveFocusTo: newView,
moveFocusFrom: target.focusedSurface,
undoAction: undoAction)
}
target.replaceSurfaceTree(
newTree,
moveFocusTo: newView,
moveFocusFrom: target.focusedSurface,
undoAction: undoAction)
}
}
}
@@ -817,14 +817,110 @@ class BaseTerminalController: NSWindowController,
self.window?.contentResizeIncrements = to
}
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
func performSplitAction(_ action: TerminalSplitOperation) {
switch action {
case .resize(let resize):
splitDidResize(node: resize.node, to: resize.ratio)
case .drop(let drop):
splitDidDrop(source: drop.payload, destination: drop.destination, zone: drop.zone)
}
}
private func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
let resizedNode = node.resize(to: newRatio)
do {
surfaceTree = try surfaceTree.replace(node: node, with: resizedNode)
} catch {
Ghostty.logger.warning("failed to replace node during split resize: \(error)")
}
}
private func splitDidDrop(
source: Ghostty.SurfaceView,
destination: Ghostty.SurfaceView,
zone: TerminalSplitDropZone
) {
// Map drop zone to split direction
let direction: SplitTree<Ghostty.SurfaceView>.NewDirection = switch zone {
case .top: .up
case .bottom: .down
case .left: .left
case .right: .right
}
// Check if source is in our tree
if let sourceNode = surfaceTree.root?.node(view: source) {
// Source is in our tree - same window move
let treeWithoutSource = surfaceTree.remove(sourceNode)
do {
let newTree = try treeWithoutSource.insert(view: source, at: destination, direction: direction)
replaceSurfaceTree(
newTree,
moveFocusTo: source,
moveFocusFrom: focusedSurface,
undoAction: "Move Split")
} catch {
Ghostty.logger.warning("failed to insert surface during drop: \(error)")
}
return
}
// Source is not in our tree - search other windows
var sourceController: BaseTerminalController?
var sourceNode: SplitTree<Ghostty.SurfaceView>.Node?
for window in NSApp.windows {
guard let controller = window.windowController as? BaseTerminalController else { continue }
guard controller !== self else { continue }
if let node = controller.surfaceTree.root?.node(view: source) {
sourceController = controller
sourceNode = node
break
}
}
guard let sourceController, let sourceNode else {
Ghostty.logger.warning("source surface not found in any window during drop")
return
}
// Remove from source controller's tree and add it to our tree.
// We do this first because if there is an error then we can
// abort.
let sourceTreeWithoutNode = sourceController.surfaceTree.remove(sourceNode)
let newTree: SplitTree<Ghostty.SurfaceView>
do {
newTree = try surfaceTree.insert(view: source, at: destination, direction: direction)
} catch {
Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error)")
return
}
// If our old sourceTree became empty, disable undo, because this will
// close the window and we don't have a way to restore that currently.
if sourceTreeWithoutNode.isEmpty {
undoManager?.disableUndoRegistration()
}
defer {
if sourceTreeWithoutNode.isEmpty {
undoManager?.enableUndoRegistration()
}
}
// Treat our undo below as a full group.
undoManager?.beginUndoGrouping()
undoManager?.setActionName("Move Split")
defer {
undoManager?.endUndoGrouping()
}
sourceController.replaceSurfaceTree(
sourceTreeWithoutNode)
replaceSurfaceTree(
newTree,
moveFocusTo: source,
moveFocusFrom: focusedSurface)
}
func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) {

View File

@@ -671,7 +671,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
/// Closes the current window (including any other tabs) immediately and without
/// confirmation. This will setup proper undo state so the action can be undone.
private func closeWindowImmediately() {
func closeWindowImmediately() {
guard let window = window else { return }
registerUndoForCloseWindow()

View File

@@ -1,5 +1,6 @@
import SwiftUI
import GhosttyKit
import os
/// This delegate is notified of actions and property changes regarding the terminal view. This
/// delegate is optional and can be used by a TerminalView caller to react to changes such as
@@ -16,9 +17,9 @@ protocol TerminalViewDelegate: AnyObject {
/// Perform an action. At the time of writing this is only triggered by the command palette.
func performAction(_ action: String, on: Ghostty.SurfaceView)
/// A split is resizing to a given value.
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double)
/// A split tree operation
func performSplitAction(_ action: TerminalSplitOperation)
}
/// The view model is a required implementation for TerminalView callers. This contains
@@ -81,7 +82,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
TerminalSplitTreeView(
tree: viewModel.surfaceTree,
onResize: { delegate?.splitDidResize(node: $0, to: $1) })
action: { delegate?.performSplitAction($0) })
.environmentObject(ghostty)
.focused($focused)
.onAppear { self.focused = true }

View File

@@ -0,0 +1,10 @@
import Foundation
extension Ghostty {
/// This is a delegate that should be applied to your global app delegate for GhosttyKit
/// to perform app-global operations.
protocol Delegate {
/// Look up a surface within the application by ID.
func ghosttySurface(id: UUID) -> SurfaceView?
}
}

View File

@@ -0,0 +1,203 @@
import AppKit
import SwiftUI
extension Ghostty {
/// A preference key that propagates the ID of the SurfaceView currently being dragged,
/// or nil if no surface is being dragged.
struct DraggingSurfaceKey: PreferenceKey {
static var defaultValue: SurfaceView.ID? = nil
static func reduce(value: inout SurfaceView.ID?, nextValue: () -> SurfaceView.ID?) {
value = nextValue() ?? value
}
}
/// A SwiftUI view that provides drag source functionality for terminal surfaces.
///
/// This view wraps an AppKit-based drag source to enable drag-and-drop reordering
/// of terminal surfaces within split views. When the user drags this view, it initiates
/// an `NSDraggingSession` with the surface's UUID encoded in the pasteboard, allowing
/// drop targets to identify which surface is being moved.
///
/// The view also publishes the dragging state via `DraggingSurfaceKey` preference,
/// enabling parent views to react to ongoing drag operations.
struct SurfaceDragSource: View {
/// The surface view that will be dragged.
let surfaceView: SurfaceView
/// Binding that reflects whether a drag session is currently active.
@Binding var isDragging: Bool
/// Binding that reflects whether the mouse is hovering over this view.
@Binding var isHovering: Bool
var body: some View {
SurfaceDragSourceViewRepresentable(
surfaceView: surfaceView,
isDragging: $isDragging,
isHovering: $isHovering)
.preference(key: DraggingSurfaceKey.self, value: isDragging ? surfaceView.id : nil)
}
}
/// An NSViewRepresentable that provides AppKit-based drag source functionality.
/// This gives us control over the drag lifecycle, particularly detecting drag start.
fileprivate struct SurfaceDragSourceViewRepresentable: NSViewRepresentable {
let surfaceView: SurfaceView
@Binding var isDragging: Bool
@Binding var isHovering: Bool
func makeNSView(context: Context) -> SurfaceDragSourceView {
let view = SurfaceDragSourceView()
view.surfaceView = surfaceView
view.onDragStateChanged = { dragging in
isDragging = dragging
}
view.onHoverChanged = { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = hovering
}
}
return view
}
func updateNSView(_ nsView: SurfaceDragSourceView, context: Context) {
nsView.surfaceView = surfaceView
nsView.onDragStateChanged = { dragging in
isDragging = dragging
}
nsView.onHoverChanged = { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = hovering
}
}
}
}
/// The underlying NSView that handles drag operations.
///
/// This view manages mouse tracking and drag initiation for surface reordering.
/// It uses a local event loop to detect drag gestures and initiates an
/// `NSDraggingSession` when the user drags beyond the threshold distance.
fileprivate class SurfaceDragSourceView: NSView, NSDraggingSource {
/// Scale factor applied to the surface snapshot for the drag preview image.
private static let previewScale: CGFloat = 0.2
/// The surface view that will be dragged. Its UUID is encoded into the
/// pasteboard for drop targets to identify which surface is being moved.
var surfaceView: SurfaceView?
/// Callback invoked when the drag state changes. Called with `true` when
/// a drag session begins, and `false` when it ends (completed or cancelled).
var onDragStateChanged: ((Bool) -> Void)?
/// Callback invoked when the mouse enters or exits this view's bounds.
/// Used to update the hover state for visual feedback in the parent view.
var onHoverChanged: ((Bool) -> Void)?
/// Whether we are currently in a mouse tracking loop (between mouseDown
/// and either mouseUp or drag initiation). Used to determine cursor state.
private var isTracking: Bool = false
override func updateTrackingAreas() {
super.updateTrackingAreas()
// To update our tracking area we just recreate it all.
trackingAreas.forEach { removeTrackingArea($0) }
// Add our tracking area for mouse events
addTrackingArea(NSTrackingArea(
rect: bounds,
options: [.mouseEnteredAndExited, .activeInActiveApp],
owner: self,
userInfo: nil
))
}
override func resetCursorRects() {
addCursorRect(bounds, cursor: isTracking ? .closedHand : .openHand)
}
override func mouseEntered(with event: NSEvent) {
onHoverChanged?(true)
}
override func mouseExited(with event: NSEvent) {
onHoverChanged?(false)
}
override func mouseDragged(with event: NSEvent) {
guard !isTracking, let surfaceView = surfaceView else { return }
// Create our dragging item from our transferable
guard let pasteboardItem = surfaceView.pasteboardItem() else { return }
let item = NSDraggingItem(pasteboardWriter: pasteboardItem)
// Create a scaled preview image from the surface snapshot
if let snapshot = surfaceView.asImage {
let imageSize = NSSize(
width: snapshot.size.width * Self.previewScale,
height: snapshot.size.height * Self.previewScale
)
let scaledImage = NSImage(size: imageSize)
scaledImage.lockFocus()
snapshot.draw(
in: NSRect(origin: .zero, size: imageSize),
from: NSRect(origin: .zero, size: snapshot.size),
operation: .copy,
fraction: 1.0
)
scaledImage.unlockFocus()
// Position the drag image so the mouse is at the center of the image.
// I personally like the top middle or top left corner best but
// this matches macOS native tab dragging behavior (at least, as of
// macOS 26.2 on Dec 29, 2025).
let mouseLocation = convert(event.locationInWindow, from: nil)
let origin = NSPoint(
x: mouseLocation.x - imageSize.width / 2,
y: mouseLocation.y - imageSize.height / 2
)
item.setDraggingFrame(
NSRect(origin: origin, size: imageSize),
contents: scaledImage
)
}
onDragStateChanged?(true)
beginDraggingSession(with: [item], event: event, source: self)
}
// MARK: NSDraggingSource
func draggingSession(
_ session: NSDraggingSession,
sourceOperationMaskFor context: NSDraggingContext
) -> NSDragOperation {
return context == .withinApplication ? .move : []
}
func draggingSession(
_ session: NSDraggingSession,
willBeginAt screenPoint: NSPoint
) {
isTracking = true
}
func draggingSession(
_ session: NSDraggingSession,
movedTo screenPoint: NSPoint
) {
NSCursor.closedHand.set()
}
func draggingSession(
_ session: NSDraggingSession,
endedAt screenPoint: NSPoint,
operation: NSDragOperation
) {
isTracking = false
onDragStateChanged?(false)
}
}
}

View File

@@ -0,0 +1,41 @@
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
@State private var isHovering: Bool = false
@State private var isDragging: Bool = false
var body: some View {
VStack(spacing: 0) {
Rectangle()
.fill(Color.white.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(.white.opacity(0.5))
}
}
.contentShape(Rectangle())
.overlay {
SurfaceDragSource(
surfaceView: surfaceView,
isDragging: $isDragging,
isHovering: $isHovering
)
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}

View File

@@ -0,0 +1,28 @@
#if canImport(AppKit)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
extension Ghostty.SurfaceView {
#if canImport(AppKit)
/// A snapshot image of the current surface view.
var asImage: NSImage? {
guard let bitmapRep = bitmapImageRepForCachingDisplay(in: bounds) else {
return nil
}
cacheDisplay(in: bounds, to: bitmapRep)
let image = NSImage(size: bounds.size)
image.addRepresentation(bitmapRep)
return image
}
#elseif canImport(UIKit)
/// A snapshot image of the current surface view.
var asImage: UIImage? {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { _ in
drawHierarchy(in: bounds, afterScreenUpdates: true)
}
}
#endif
}

View File

@@ -0,0 +1,58 @@
#if canImport(AppKit)
import AppKit
#endif
import CoreTransferable
import UniformTypeIdentifiers
/// Conformance to `Transferable` enables drag-and-drop.
extension Ghostty.SurfaceView: Transferable {
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(contentType: .ghosttySurfaceId) { surface in
withUnsafeBytes(of: surface.id.uuid) { Data($0) }
} importing: { data in
guard data.count == 16 else {
throw TransferError.invalidData
}
let uuid = data.withUnsafeBytes {
$0.load(as: UUID.self)
}
guard let imported = await Self.find(uuid: uuid) else {
throw TransferError.invalidData
}
return imported
}
}
enum TransferError: Error {
case invalidData
}
@MainActor
static func find(uuid: UUID) -> Self? {
#if canImport(AppKit)
guard let del = NSApp.delegate as? Ghostty.Delegate else { return nil }
return del.ghosttySurface(id: uuid) as? Self
#elseif canImport(UIKit)
// We should be able to use UIApplication here.
return nil
#else
return nil
#endif
}
}
extension UTType {
/// A format that encodes the bare UUID only for the surface. This can be used if you have
/// a way to look up a surface by ID.
static let ghosttySurfaceId = UTType(exportedAs: "com.mitchellh.ghosttySurfaceId")
}
#if canImport(AppKit)
extension NSPasteboard.PasteboardType {
/// Pasteboard type for dragging surface IDs.
static let ghosttySurfaceId = NSPasteboard.PasteboardType(UTType.ghosttySurfaceId.identifier)
}
#endif

View File

@@ -224,6 +224,14 @@ extension Ghostty {
.opacity(overlayOpacity)
}
}
#if canImport(AppKit)
// Grab handle for dragging the window. We want this to appear at the very
// top Z-index os it isn't faded by the unfocused overlay.
//
// This is disabled except on macOS because it uses AppKit drag/drop APIs.
SurfaceGrabHandle(surfaceView: surfaceView)
#endif
}
}

View File

@@ -2216,6 +2216,7 @@ extension Ghostty.SurfaceView {
return NSAttributedString(string: plainString, attributes: attributes)
}
}
/// Caches a value for some period of time, evicting it automatically when that time expires.

View File

@@ -4,8 +4,10 @@ import GhosttyKit
extension Ghostty {
/// The UIView implementation for a terminal surface.
class SurfaceView: UIView, ObservableObject {
typealias ID = UUID
/// Unique ID per surface
let uuid: UUID
let id: UUID
// The current title of the surface as defined by the pty. This can be
// changed with escape codes. This is public because the callbacks go
@@ -63,7 +65,7 @@ extension Ghostty {
private(set) var surface: ghostty_surface_t?
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
self.uuid = uuid ?? .init()
self.id = uuid ?? .init()
// Initialize with some default frame size. The important thing is that this
// is non-zero so that our layer bounds are non-zero so that our renderer

View File

@@ -0,0 +1,58 @@
import AppKit
import CoreTransferable
import UniformTypeIdentifiers
extension Transferable {
/// Converts this Transferable to an NSPasteboardItem with lazy data loading.
/// Data is only fetched when the pasteboard consumer requests it. This allows
/// bridging a Transferable to NSDraggingSource.
func pasteboardItem() -> NSPasteboardItem? {
let itemProvider = NSItemProvider()
itemProvider.register(self)
let types = itemProvider.registeredTypeIdentifiers.compactMap { UTType($0) }
guard !types.isEmpty else { return nil }
let item = NSPasteboardItem()
let dataProvider = TransferableDataProvider(itemProvider: itemProvider)
let pasteboardTypes = types.map { NSPasteboard.PasteboardType($0.identifier) }
item.setDataProvider(dataProvider, forTypes: pasteboardTypes)
return item
}
}
private final class TransferableDataProvider: NSObject, NSPasteboardItemDataProvider {
private let itemProvider: NSItemProvider
init(itemProvider: NSItemProvider) {
self.itemProvider = itemProvider
super.init()
}
func pasteboard(
_ pasteboard: NSPasteboard?,
item: NSPasteboardItem,
provideDataForType type: NSPasteboard.PasteboardType
) {
// NSPasteboardItemDataProvider requires synchronous data return, but
// NSItemProvider.loadDataRepresentation is async. We use a semaphore
// to block until the async load completes. This is safe because AppKit
// calls this method on a background thread during drag operations.
let semaphore = DispatchSemaphore(value: 0)
var result: Data?
itemProvider.loadDataRepresentation(forTypeIdentifier: type.rawValue) { data, _ in
result = data
semaphore.signal()
}
// Wait for the data to load
semaphore.wait()
// Set it. I honestly don't know what happens here if this fails.
if let data = result {
item.setData(data, forType: type)
}
}
}

View File

@@ -0,0 +1,124 @@
import Testing
import AppKit
import CoreTransferable
import UniformTypeIdentifiers
@testable import Ghostty
struct TransferablePasteboardTests {
// MARK: - Test Helpers
/// A simple Transferable type for testing pasteboard conversion.
private struct DummyTransferable: Transferable, Equatable {
let payload: String
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(contentType: .utf8PlainText) { value in
value.payload.data(using: .utf8)!
} importing: { data in
let string = String(data: data, encoding: .utf8)!
return DummyTransferable(payload: string)
}
}
}
/// A Transferable type that registers multiple content types.
private struct MultiTypeTransferable: Transferable {
let text: String
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(contentType: .utf8PlainText) { value in
value.text.data(using: .utf8)!
} importing: { data in
MultiTypeTransferable(text: String(data: data, encoding: .utf8)!)
}
DataRepresentation(contentType: .plainText) { value in
value.text.data(using: .utf8)!
} importing: { data in
MultiTypeTransferable(text: String(data: data, encoding: .utf8)!)
}
}
}
// MARK: - Basic Functionality
@Test func pasteboardItemIsCreated() {
let transferable = DummyTransferable(payload: "hello")
let item = transferable.pasteboardItem()
#expect(item != nil)
}
@Test func pasteboardItemContainsExpectedType() {
let transferable = DummyTransferable(payload: "hello")
guard let item = transferable.pasteboardItem() else {
Issue.record("Expected pasteboard item to be created")
return
}
let expectedType = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
#expect(item.types.contains(expectedType))
}
@Test func pasteboardItemProvidesCorrectData() {
let transferable = DummyTransferable(payload: "test data")
guard let item = transferable.pasteboardItem() else {
Issue.record("Expected pasteboard item to be created")
return
}
let pasteboardType = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
// Write to a pasteboard to trigger data provider
let pasteboard = NSPasteboard(name: .init("test-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.writeObjects([item])
// Read back the data
guard let data = pasteboard.data(forType: pasteboardType) else {
Issue.record("Expected data to be available on pasteboard")
return
}
let string = String(data: data, encoding: .utf8)
#expect(string == "test data")
}
// MARK: - Multiple Content Types
@Test func multipleTypesAreRegistered() {
let transferable = MultiTypeTransferable(text: "multi")
guard let item = transferable.pasteboardItem() else {
Issue.record("Expected pasteboard item to be created")
return
}
let utf8Type = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
let plainType = NSPasteboard.PasteboardType(UTType.plainText.identifier)
#expect(item.types.contains(utf8Type))
#expect(item.types.contains(plainType))
}
@Test func multipleTypesProvideCorrectData() {
let transferable = MultiTypeTransferable(text: "shared content")
guard let item = transferable.pasteboardItem() else {
Issue.record("Expected pasteboard item to be created")
return
}
let pasteboard = NSPasteboard(name: .init("test-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.writeObjects([item])
// Both types should provide the same content
let utf8Type = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
let plainType = NSPasteboard.PasteboardType(UTType.plainText.identifier)
if let utf8Data = pasteboard.data(forType: utf8Type) {
#expect(String(data: utf8Data, encoding: .utf8) == "shared content")
}
if let plainData = pasteboard.data(forType: plainType) {
#expect(String(data: plainData, encoding: .utf8) == "shared content")
}
}
}

View File

@@ -0,0 +1,128 @@
import Testing
import Foundation
@testable import Ghostty
struct TerminalSplitDropZoneTests {
private let standardSize = CGSize(width: 100, height: 100)
// MARK: - Basic Edge Detection
@Test func topEdge() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 5), in: standardSize)
#expect(zone == .top)
}
@Test func bottomEdge() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 95), in: standardSize)
#expect(zone == .bottom)
}
@Test func leftEdge() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 5, y: 50), in: standardSize)
#expect(zone == .left)
}
@Test func rightEdge() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 95, y: 50), in: standardSize)
#expect(zone == .right)
}
// MARK: - Corner Tie-Breaking
// When distances are equal, the check order determines the result:
// left -> right -> top -> bottom
@Test func topLeftCornerSelectsLeft() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 0, y: 0), in: standardSize)
#expect(zone == .left)
}
@Test func topRightCornerSelectsRight() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 0), in: standardSize)
#expect(zone == .right)
}
@Test func bottomLeftCornerSelectsLeft() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 0, y: 100), in: standardSize)
#expect(zone == .left)
}
@Test func bottomRightCornerSelectsRight() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 100), in: standardSize)
#expect(zone == .right)
}
// MARK: - Center Point (All Distances Equal)
@Test func centerSelectsLeft() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 50), in: standardSize)
#expect(zone == .left)
}
// MARK: - Non-Square Aspect Ratio
@Test func rectangularViewTopEdge() {
let size = CGSize(width: 200, height: 100)
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 10), in: size)
#expect(zone == .top)
}
@Test func rectangularViewLeftEdge() {
let size = CGSize(width: 200, height: 100)
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 10, y: 50), in: size)
#expect(zone == .left)
}
@Test func tallRectangleTopEdge() {
let size = CGSize(width: 100, height: 200)
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 10), in: size)
#expect(zone == .top)
}
// MARK: - Out-of-Bounds Points
@Test func pointLeftOfViewSelectsLeft() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: -10, y: 50), in: standardSize)
#expect(zone == .left)
}
@Test func pointAboveViewSelectsTop() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: -10), in: standardSize)
#expect(zone == .top)
}
@Test func pointRightOfViewSelectsRight() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 110, y: 50), in: standardSize)
#expect(zone == .right)
}
@Test func pointBelowViewSelectsBottom() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 110), in: standardSize)
#expect(zone == .bottom)
}
// MARK: - Diagonal Regions (Triangular Zones)
@Test func upperLeftTriangleSelectsLeft() {
// Point in the upper-left triangle, closer to left than top
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 20, y: 30), in: standardSize)
#expect(zone == .left)
}
@Test func upperRightTriangleSelectsRight() {
// Point in the upper-right triangle, closer to right than top
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 80, y: 30), in: standardSize)
#expect(zone == .right)
}
@Test func lowerLeftTriangleSelectsLeft() {
// Point in the lower-left triangle, closer to left than bottom
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 20, y: 70), in: standardSize)
#expect(zone == .left)
}
@Test func lowerRightTriangleSelectsRight() {
// Point in the lower-right triangle, closer to right than bottom
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 80, y: 70), in: standardSize)
#expect(zone == .right)
}
}