Merge fix/window-resize-move: drag the picture to move the call window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+467
−8
native/DESIGN.md+6 −1native/apple/Sources/CallsKit/MacFullWindowCall.swift+3 −7native/apple/Sources/CallsKit/MacVideoTile.swift+6 −0native/apple/Sources/CallsKit/WindowMove.swift+226 −0native/apple/Tests/CallsKitTests/WindowMoveTests.swift+226 −0
diff --git a/native/DESIGN.md b/native/DESIGN.md| index 1dab73a..91d7222 100644 |
| --- a/native/DESIGN.md |
| +++ b/native/DESIGN.md |
| @@ -109,7 +109,12 @@ Full window is the default. The mini window and tab are unchanged. |
| over it and fade with the controls (not in full screen, where the system |
| shows them), a shade behind them keeps them legible, and a 32 pt strip |
| across the top drags the window and double-clicks as the person's title-bar |
| - setting says (`WindowDragStrip`, `TitleBarDoubleClick`). The title pill sits |
| + setting says (`WindowDragStrip`, `TitleBarDoubleClick`). Since 0.3.7 the |
| + picture does too, as in FaceTime (`WindowMove`): a drag anywhere on the |
| + stage that is not a control, a panel or the self view moves the window |
| + once it has gone 4 pt (less is a click, which shows the controls), and a |
| + double click there does what the title bar's would. Resizing is AppKit's |
| + own, from every edge and corner, the top included. The title pill sits |
| to the right of the traffic lights and the notices in a column beneath it. |
| `CallWindowChrome` sets the window's title bar, watches it so SwiftUI cannot |
| quietly restore it mid-call, and puts it back for the lobby and Classic. |
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift| index 0816cf8..b8a6f0e 100644 |
| --- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| +++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| @@ -34,9 +34,9 @@ |
| ZStack { |
| Color.black |
| if let fixtureBackdrop { |
| - fixtureBackdrop.view.accessibilityHidden(true) |
| + fixtureBackdrop.view.accessibilityHidden(true).movesWindow() |
| } else { |
| - fullWindowStage(in: size) |
| + fullWindowStage(in: size).movesWindow() |
| } |
| if !grid { selfView(in: size) } |
| topShade |
| @@ -611,11 +611,7 @@ |
| override func mouseDown(with event: NSEvent) { |
| guard let window else { return } |
| if event.clickCount == 2 { |
| - switch TitleBarDoubleClick.current() { |
| - case .zoom: window.zoom(nil) |
| - case .minimize: window.miniaturize(nil) |
| - case .none: break |
| - } |
| + TitleBarDoubleClick.current().perform(on: window) |
| } else { |
| window.performDrag(with: event) |
| } |
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift| index 38f9380..2bb58cd 100644 |
| --- a/native/apple/Sources/CallsKit/MacVideoTile.swift |
| +++ b/native/apple/Sources/CallsKit/MacVideoTile.swift |
| @@ -112,6 +112,12 @@ |
| } |
| required init?(coder: NSCoder) { nil } |
| + // A picture takes no clicks: they go to the SwiftUI view it is in, so |
| + // a drag on it moves the window (WindowMove) or the self view, as a |
| + // drag on a tile with no picture does, the click that brings the |
| + // window forward included. |
| + override func hitTest(_ point: NSPoint) -> NSView? { nil } |
| + |
| func attach(_ next: RTCVideoTrack) { |
| guard track !== next else { return } |
| detach() |
diff --git a/native/apple/Sources/CallsKit/WindowMove.swift b/native/apple/Sources/CallsKit/WindowMove.swift| new file mode 100644 |
| index 0000000..5255f8e |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/WindowMove.swift |
| @@ -0,0 +1,226 @@ |
| +#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 |
diff --git a/native/apple/Tests/CallsKitTests/WindowMoveTests.swift b/native/apple/Tests/CallsKitTests/WindowMoveTests.swift| new file mode 100644 |
| index 0000000..43ce68b |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/WindowMoveTests.swift |
| @@ -0,0 +1,226 @@ |
| +#if os(macOS) |
| + import AppKit |
| + import SwiftUI |
| + import XCTest |
| + |
| + import LiveKitWebRTC |
| + |
| + @testable @_spi(Fixtures) import CallsKit |
| + |
| + /// Paul, on 0.3.6: "I should be able to click anywhere on the window to |
| + /// resize it." The full-window call has had no title bar since 0.3.5, so |
| + /// moving it is the picture's job, and resizing must still work from |
| + /// every edge, the top included. |
| + final class WindowMoveRuleTests: XCTestCase { |
| + func testAShortPressIsAClickAndALongerOneADrag() { |
| + XCTAssertEqual(WindowMove.threshold, 4) |
| + let start = CGPoint(x: 100, y: 100) |
| + XCTAssertFalse(WindowMove.isDrag(from: start, to: start)) |
| + XCTAssertFalse(WindowMove.isDrag(from: start, to: CGPoint(x: 102, y: 102)), "a shaky click") |
| + XCTAssertTrue(WindowMove.isDrag(from: start, to: CGPoint(x: 104, y: 100))) |
| + XCTAssertTrue(WindowMove.isDrag(from: start, to: CGPoint(x: 97, y: 97)), "any direction") |
| + } |
| + |
| + func testThePressFollowsThePointerOnceItIsADrag() { |
| + var press = WindowMove.Press(origin: CGPoint(x: 200, y: 300), grabbed: CGPoint(x: 700, y: 650)) |
| + XCTAssertNil(press.moved(to: CGPoint(x: 702, y: 651)), "still a click") |
| + XCTAssertFalse(press.moving) |
| + XCTAssertEqual(press.moved(to: CGPoint(x: 740, y: 625)), CGPoint(x: 240, y: 275)) |
| + XCTAssertTrue(press.moving) |
| + // Once moving, it goes wherever the pointer goes, back to where it |
| + // started included: the window is not left 4 pt short. |
| + XCTAssertEqual(press.moved(to: CGPoint(x: 701, y: 650)), CGPoint(x: 201, y: 300)) |
| + XCTAssertEqual(press.moved(to: CGPoint(x: 700, y: 650)), CGPoint(x: 200, y: 300)) |
| + } |
| + |
| + func testOnlyAWindowThatCanMoveIsMoved() { |
| + XCTAssertTrue(WindowMove.canMove(fullScreen: false, movable: true)) |
| + XCTAssertFalse(WindowMove.canMove(fullScreen: true, movable: true), "full screen is the screen") |
| + XCTAssertFalse(WindowMove.canMove(fullScreen: false, movable: false)) |
| + } |
| + } |
| + |
| + /// The real full-window call in a titled, resizable window like the app's, |
| + /// driven with mouse events sent to it: what a press on each part of it |
| + /// does to the window. The window is never made key or active: a call |
| + /// window behind another app's must move by its picture too. |
| + @MainActor final class CallWindowMouseTests: XCTestCase { |
| + private var window: NSWindow! |
| + private let start = NSRect(x: 200, y: 200, width: 1000, height: 700) |
| + |
| + override func setUp() async throws { |
| + NSApplication.shared.setActivationPolicy(.prohibited) |
| + UserDefaults.standard.removeObject(forKey: PiPCorner.key) |
| + } |
| + |
| + override func tearDown() async throws { |
| + window?.close() |
| + window = nil |
| + UserDefaults.standard.removeObject(forKey: PiPCorner.key) |
| + } |
| + |
| + private func open(_ fixture: CallFixture = .focus, grid: Bool = false, controls: Bool = false) { |
| + let call = Call(playAudio: false) |
| + fixture.apply(to: call) |
| + window = NSWindow( |
| + contentRect: start, styleMask: [.titled, .closable, .miniaturizable, .resizable], |
| + backing: .buffered, defer: false) |
| + window.isReleasedWhenClosed = false |
| + window.contentView = NSHostingView( |
| + rootView: MacCallView( |
| + call: call, grid: grid, style: .fullWindow, controlsShown: controls, leave: {}, rejoin: {})) |
| + window.orderFrontRegardless() |
| + pump(0.8) |
| + window.setFrame(start, display: true) |
| + pump(0.2) |
| + } |
| + |
| + private func pump(_ seconds: Double) { |
| + let until = Date(timeIntervalSinceNow: seconds) |
| + while let event = NSApp.nextEvent( |
| + matching: .any, until: until, inMode: .default, dequeue: true) |
| + { |
| + NSApp.sendEvent(event) |
| + } |
| + } |
| + |
| + private func event(_ type: NSEvent.EventType, at point: NSPoint, clicks: Int = 1) -> NSEvent { |
| + NSEvent.mouseEvent( |
| + with: type, location: point, modifierFlags: [], |
| + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, |
| + context: nil, eventNumber: 0, clickCount: clicks, pressure: type == .leftMouseUp ? 0 : 1)! |
| + } |
| + |
| + /// Presses at `point` (window coordinates, y up), drags by `by` in |
| + /// steps as a hand would, and lets go; returns how far the window went. |
| + private func drag(from point: NSPoint, by: CGVector) -> CGVector { |
| + let before = window.frame.origin |
| + let from = window.convertPoint(toScreen: point) |
| + NSApp.sendEvent(event(.leftMouseDown, at: point)) |
| + pump(0.05) |
| + for step in 1...6 { |
| + let part = CGFloat(step) / 6 |
| + let there = NSPoint(x: from.x + by.dx * part, y: from.y + by.dy * part) |
| + NSApp.sendEvent(event(.leftMouseDragged, at: window.convertPoint(fromScreen: there))) |
| + pump(0.03) |
| + } |
| + let end = NSPoint(x: from.x + by.dx, y: from.y + by.dy) |
| + NSApp.sendEvent(event(.leftMouseUp, at: window.convertPoint(fromScreen: end))) |
| + pump(0.2) |
| + return CGVector(dx: window.frame.minX - before.x, dy: window.frame.minY - before.y) |
| + } |
| + |
| + private var bounds: NSRect { window.contentView!.bounds } |
| + |
| + func testTheCallLeavesTheWindowResizableWithNoTitleBar() { |
| + open() |
| + XCTAssertTrue(window.styleMask.contains(.resizable)) |
| + XCTAssertTrue(window.styleMask.contains(.fullSizeContentView)) |
| + XCTAssertEqual(window.titleVisibility, .hidden) |
| + XCTAssertTrue(window.titlebarAppearsTransparent) |
| + } |
| + |
| + /// AppKit keeps a few points inside each edge, and more at the corners, |
| + /// for resizing, whatever the content — the drag strip along the top |
| + /// included. Only past them do the call's own views get the click. |
| + func testTheEdgesAndCornersBelongToTheWindow() { |
| + open() |
| + let frameView = window.contentView!.superview! |
| + let edges: [(String, NSPoint)] = [ |
| + ("top", NSPoint(x: bounds.midX, y: bounds.maxY - 1)), |
| + ("bottom", NSPoint(x: bounds.midX, y: 1)), |
| + ("left", NSPoint(x: 1, y: bounds.midY)), |
| + ("right", NSPoint(x: bounds.maxX - 1, y: bounds.midY)), |
| + ("top left", NSPoint(x: 4, y: bounds.maxY - 4)), |
| + ("top right", NSPoint(x: bounds.maxX - 4, y: bounds.maxY - 4)), |
| + ("bottom left", NSPoint(x: 4, y: 4)), |
| + ("bottom right", NSPoint(x: bounds.maxX - 4, y: 4)), |
| + ] |
| + for (edge, point) in edges { |
| + XCTAssertTrue(frameView.hitTest(point) === frameView, "\(edge) is the window's") |
| + } |
| + XCTAssertTrue( |
| + frameView.hitTest(NSPoint(x: bounds.midX, y: bounds.maxY - 12)) is WindowDragStrip.StripView, |
| + "below the edge, the strip") |
| + } |
| + |
| + func testEveryEdgeAndCornerResizesTheWindow() { |
| + open() |
| + // (where, drag, which edges should move: minX, maxX, minY, maxY) |
| + let cases: [(String, NSPoint, CGVector, [Bool])] = [ |
| + ("top", NSPoint(x: bounds.midX, y: bounds.maxY - 1), CGVector(dx: 0, dy: 30), [false, false, false, true]), |
| + ("bottom", NSPoint(x: bounds.midX, y: 1), CGVector(dx: 0, dy: -30), [false, false, true, false]), |
| + ("left", NSPoint(x: 1, y: bounds.midY), CGVector(dx: -30, dy: 0), [true, false, false, false]), |
| + ("right", NSPoint(x: bounds.maxX - 1, y: bounds.midY), CGVector(dx: 30, dy: 0), [false, true, false, false]), |
| + ("top left", NSPoint(x: 1, y: bounds.maxY - 1), CGVector(dx: -30, dy: 30), [true, false, false, true]), |
| + ("top right", NSPoint(x: bounds.maxX - 1, y: bounds.maxY - 1), CGVector(dx: 30, dy: 30), [false, true, false, true]), |
| + ] |
| + for (edge, point, by, moves) in cases { |
| + window.setFrame(start, display: true) |
| + pump(0.1) |
| + // AppKit's resize reads the events that follow the press itself, |
| + // so they are queued first. |
| + let from = window.convertPoint(toScreen: point) |
| + let to = window.convertPoint(fromScreen: NSPoint(x: from.x + by.dx, y: from.y + by.dy)) |
| + NSApp.postEvent(event(.leftMouseDragged, at: to), atStart: false) |
| + NSApp.postEvent(event(.leftMouseUp, at: to), atStart: false) |
| + NSApp.sendEvent(event(.leftMouseDown, at: point)) |
| + pump(0.1) |
| + let now = window.frame |
| + let moved = [now.minX != start.minX, now.maxX != start.maxX, now.minY != start.minY, now.maxY != start.maxY] |
| + XCTAssertEqual(moved, moves, "\(edge): \(start) became \(now)") |
| + XCTAssertGreaterThan(now.width * now.height, start.width * start.height, "\(edge) grew") |
| + } |
| + } |
| + |
| + func testDraggingThePictureMovesTheWindow() { |
| + open() |
| + let moved = drag(from: NSPoint(x: bounds.midX, y: bounds.midY), by: CGVector(dx: 40, dy: -25)) |
| + XCTAssertEqual(moved, CGVector(dx: 40, dy: -25)) |
| + XCTAssertEqual(window.frame.size, start.size) |
| + let again = drag(from: NSPoint(x: 150, y: 300), by: CGVector(dx: -30, dy: 20)) |
| + XCTAssertEqual(again, CGVector(dx: -30, dy: 20), "anywhere on it") |
| + } |
| + |
| + func testAClickOnThePictureDoesNotMoveTheWindow() { |
| + open() |
| + let moved = drag(from: NSPoint(x: bounds.midX, y: bounds.midY), by: CGVector(dx: 2, dy: 1)) |
| + XCTAssertEqual(moved, CGVector(dx: 0, dy: 0)) |
| + } |
| + |
| + /// A picture arriving is an AppKit view over the tile, which took |
| + /// the press for itself before 0.3.7, so the stage's gesture never |
| + /// saw it. Behind another app's window too. |
| + func testDraggingLiveVideoMovesTheWindow() { |
| + let factory = RTCPeerConnectionFactory() |
| + let track = factory.videoTrack(with: factory.videoSource(), trackId: "window-move") |
| + window = NSWindow( |
| + contentRect: start, styleMask: [.titled, .closable, .resizable], backing: .buffered, |
| + defer: false) |
| + window.isReleasedWhenClosed = false |
| + window.contentView = NSHostingView( |
| + rootView: MacVideoTile(track: track, label: "Ana", cornerRadius: 0, compact: true) |
| + .movesWindow()) |
| + window.orderFrontRegardless() |
| + pump(0.8) |
| + let point = NSPoint(x: bounds.midX, y: bounds.midY) |
| + XCTAssertFalse( |
| + window.contentView!.superview!.hitTest(point) is RTCMTLNSVideoView, "the picture takes no clicks") |
| + XCTAssertEqual(drag(from: point, by: CGVector(dx: 40, dy: -25)), CGVector(dx: 40, dy: -25)) |
| + } |
| + |
| + /// The self view drags itself to a corner (a gesture of its own, which |
| + /// the picture's does not take from it), and the window stays put. |
| + func testTheSelfViewIsNotAHandle() { |
| + open() |
| + let moved = drag(from: NSPoint(x: bounds.maxX - 100, y: 80), by: CGVector(dx: -750, dy: 450)) |
| + XCTAssertEqual(moved, CGVector(dx: 0, dy: 0)) |
| + } |
| + |
| + func testTheBarIsNotAHandle() { |
| + open(controls: true) |
| + let moved = drag(from: NSPoint(x: bounds.midX, y: 40), by: CGVector(dx: 30, dy: 30)) |
| + XCTAssertEqual(moved, CGVector(dx: 0, dy: 0)) |
| + } |
| + } |
| +#endif |