#if os(macOS) import AppKit import SwiftUI /// Moving the full-window call by its picture, as FaceTime does. With no /// title bar, the 32 pt strip across the top (`WindowDragStrip`) was the /// only place a window could be picked up by; now a press anywhere on the /// stage that is not something of its own — the bar, a panel, a notice, /// the self view, which drags itself, a tile's ⓘ — moves the window once /// the pointer has gone `threshold` points with the button down. Less /// than that is a click, which shows the controls as any click does. /// /// Resizing needs nothing from here: AppKit keeps the few points inside /// every edge (and more at the corners) for itself, above any content, /// as long as the window is `.resizable` — which `CallWindowChrome` /// leaves alone. `WindowMoveTests` holds both. enum WindowMove { /// How far the pointer goes with the button down before a press on /// the picture is a drag rather than a click: a shaky click stays a /// click. The mini window uses the same slack. static let threshold: CGFloat = 4 /// Whether a press that went down at `start` and is now at `now` is a /// drag. static func isDrag(from start: CGPoint, to now: CGPoint) -> Bool { hypot(now.x - start.x, now.y - start.y) >= threshold } /// Whether the window can be moved at all: not in full screen, where it /// is the screen, and not when AppKit says it is not movable. static func canMove(fullScreen: Bool, movable: Bool) -> Bool { !fullScreen && movable } /// The window's frame origin for the pointer at `pointer`, when it was /// at `origin` with the pointer at `grabbed` (all in screen points). static func origin(from origin: CGPoint, grabbed: CGPoint, pointer: CGPoint) -> CGPoint { CGPoint(x: origin.x + pointer.x - grabbed.x, y: origin.y + pointer.y - grabbed.y) } /// One press on the picture: a click until it passes the threshold, /// then where the window should be for each position of the pointer. struct Press: Equatable { let origin: CGPoint let grabbed: CGPoint private(set) var moving = false init(origin: CGPoint, grabbed: CGPoint) { self.origin = origin self.grabbed = grabbed } /// Nil while it is still a click. mutating func moved(to pointer: CGPoint) -> CGPoint? { if !moving, !WindowMove.isDrag(from: grabbed, to: pointer) { return nil } moving = true return WindowMove.origin(from: origin, grabbed: grabbed, pointer: pointer) } } } extension View { /// A press and drag on this view moves its window (see `WindowMove`). /// Views above it with gestures or controls of their own keep them. func movesWindow() -> some View { modifier(WindowMoveModifier()) } /// Lets the click that brings the window forward reach this view's /// gestures, as it reaches a title bar (macOS 15 and later). Given to /// the stage by `movesWindow`; a scroll view's content needs it again, /// because it does not reach through one. func takesActivatingClicks() -> some View { modifier(ActivatingClicks()) } } private struct WindowMoveModifier: ViewModifier { @State private var mover = WindowMover() func body(content: Content) -> some View { content // Every point of it, the gaps between tiles included, not only // where something is drawn. .contentShape(Rectangle()) .gesture( // Only a drag: a click on the picture is left alone, and shows // the controls as it always has (CallWindowActivity). DragGesture(minimumDistance: WindowMove.threshold, coordinateSpace: .global) .onChanged { _ in mover.begin() } .onEnded { _ in mover.end() } ) // What a double click on the title bar does, as on the strip that // stands in for it: zoom, by default. .onTapGesture(count: 2) { mover.doubleClicked() } .takesActivatingClicks() .background(WindowFinder(mover: mover)) } } private struct ActivatingClicks: ViewModifier { func body(content: Content) -> some View { if #available(macOS 15, *) { content.allowsWindowActivationEvents(true) } else { content } } } /// Moves the window for a drag on the picture. The gesture is used only /// to say that one has begun, on the picture and not on anything over /// it: its values are in the view's coordinates, which shift as the /// window does, and once the window followed the pointer SwiftUI stopped /// reporting the drag after its first step. So the window's own mouse /// events are watched instead, where the pointer is on screen, and /// passed on untouched. @MainActor final class WindowMover { weak var window: NSWindow? { didSet { if window !== oldValue { watch() } } } /// Where on screen the button last went down in the window. private var down: CGPoint? private var press: WindowMove.Press? private var monitor: Any? /// A drag on the picture has passed the threshold. func begin() { guard press == nil, let window, let down, WindowMove.canMove( fullScreen: window.styleMask.contains(.fullScreen), movable: window.isMovable) else { return } // The window has not moved since the button went down. It follows // from the next drag event on. press = WindowMove.Press(origin: window.frame.origin, grabbed: down) } func doubleClicked() { guard let window, !window.styleMask.contains(.fullScreen) else { return } TitleBarDoubleClick.current().perform(on: window) } func end() { press = nil } func stop() { if let monitor { NSEvent.removeMonitor(monitor) } monitor = nil press = nil } private func watch() { stop() guard window != nil else { return } monitor = NSEvent.addLocalMonitorForEvents( matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp] ) { [weak self] event in nonisolated(unsafe) let event = event MainActor.assumeIsolated { guard let self, event.window === self.window else { return } switch event.type { case .leftMouseDown: self.down = Self.screenLocation(of: event) case .leftMouseDragged: self.move(to: Self.screenLocation(of: event)) default: self.end() } } return event } } private func move(to pointer: CGPoint) { guard let window, let origin = press?.moved(to: pointer) else { return } let screen = NSScreen.screens.first { $0.frame.contains(pointer) } ?? window.screen let frame = window.constrainFrameRect( NSRect(origin: origin, size: window.frame.size), to: screen) window.setFrameOrigin(frame.origin) } /// Where an event happened on screen, in AppKit's screen points (y up /// from the bottom of the main display). From the event itself, not /// its place in the window: the window is moving under the pointer, /// and an event queued before a move is placed against where the /// window was. private static func screenLocation(of event: NSEvent) -> CGPoint { guard let global = event.cgEvent?.location, let main = NSScreen.screens.first else { return NSEvent.mouseLocation } return CGPoint(x: global.x, y: main.frame.maxY - global.y) } deinit { if let monitor { NSEvent.removeMonitor(monitor) } } } extension TitleBarDoubleClick { @MainActor func perform(on window: NSWindow) { switch self { case .zoom: window.zoom(nil) case .minimize: window.miniaturize(nil) case .none: break } } } /// Finds the window a SwiftUI view is in, for the mover. Takes no /// clicks. private struct WindowFinder: NSViewRepresentable { let mover: WindowMover func makeNSView(context: Context) -> FinderView { let view = FinderView() view.mover = mover return view } func updateNSView(_ view: FinderView, context: Context) { view.mover = mover mover.window = view.window } static func dismantleNSView(_ view: FinderView, coordinator: ()) { view.mover?.window = nil } final class FinderView: NSView { var mover: WindowMover? override func viewDidMoveToWindow() { super.viewDidMoveToWindow() mover?.window = window } override func hitTest(_ point: NSPoint) -> NSView? { nil } } } #endif