| 1 | import AppKit |
| 2 | @_spi(Fixtures) import CallsKit |
| 3 | import SwiftUI |
| 4 | |
| 5 | /// The full-window call's control bar at every window size (2026-10-03: |
| 6 | /// Paul's 0.4.6 window showed it off the left edge, mute and camera cut |
| 7 | /// off). A real, titled, resizable window as the app's — content to the top, |
| 8 | /// the app's 760 × 540 minimum on its content — taken through a run of |
| 9 | /// sizes by resizing the one window live, as a person dragging its edge |
| 10 | /// does, and set below the minimum the way only the system can (tiling, |
| 11 | /// a display change). At each size the bar must be whole inside the |
| 12 | /// window, centred on it, and its buttons inside the bar. A snapshot of |
| 13 | /// each goes to the folder. |
| 14 | @MainActor enum BarLayoutHarness { |
| 15 | struct Step { |
| 16 | var name: String |
| 17 | var size: CGSize |
| 18 | /// Captured as soon as the resize has laid out, not after it settles. |
| 19 | var quick = false |
| 20 | /// Resized as the system's own animation does (zoom, tiling). |
| 21 | var animate = false |
| 22 | } |
| 23 | |
| 24 | static let steps: [Step] = [ |
| 25 | Step(name: "1000x760", size: CGSize(width: 1000, height: 760)), |
| 26 | // Paul's screenshot was 1718 px wide: 859 pt on a Retina display. |
| 27 | Step(name: "859x700", size: CGSize(width: 859, height: 700)), |
| 28 | Step(name: "760x540", size: CGSize(width: 760, height: 540)), |
| 29 | Step(name: "1718x1000", size: CGSize(width: 1718, height: 1000)), |
| 30 | Step(name: "1718x1000-to-800-quick", size: CGSize(width: 800, height: 600), quick: true), |
| 31 | Step(name: "800x600", size: CGSize(width: 800, height: 600)), |
| 32 | // Below the minimum: a window the system made narrower than the app |
| 33 | // asked for. |
| 34 | Step(name: "640x480-forced", size: CGSize(width: 640, height: 480)), |
| 35 | Step(name: "480x400-forced", size: CGSize(width: 480, height: 400)), |
| 36 | Step(name: "360x400-forced", size: CGSize(width: 360, height: 400)), |
| 37 | Step(name: "1200x800-back", size: CGSize(width: 1200, height: 800)), |
| 38 | // Half of a 1728-pt display, as window tiling makes it, animated. |
| 39 | Step(name: "859x1080-tiled", size: CGSize(width: 859, height: 1080), animate: true), |
| 40 | Step(name: "1718x1080-zoomed", size: CGSize(width: 1718, height: 1080), animate: true), |
| 41 | Step(name: "859x1080-tiled-quick", size: CGSize(width: 859, height: 1080), quick: true, animate: true), |
| 42 | ] |
| 43 | |
| 44 | static func run(to folder: URL) async -> Bool { |
| 45 | NSApplication.shared.setActivationPolicy(.prohibited) |
| 46 | try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| 47 | var failures: [String] = [] |
| 48 | // The last two: no 760 × 540 minimum, as a window the system has made |
| 49 | // narrower than the app asked for — the widths only ⋯ can serve. |
| 50 | let variants: [(CallFixture, Bool, Bool, Bool)] = [ |
| 51 | (.alone, false, false, true), (.alone, true, false, true), (.alone, false, true, true), |
| 52 | (.grid, false, false, true), (.grid, true, false, true), (.grid, false, true, true), |
| 53 | (.alone, false, false, false), (.grid, false, true, false), |
| 54 | ] |
| 55 | for (fixture, chat, devices, minimum) in variants { |
| 56 | do { |
| 57 | let call = Call(playAudio: false) |
| 58 | fixture.apply(to: call) |
| 59 | let content = MacCallView( |
| 60 | call: call, grid: false, showingChat: chat, showingDevices: devices, style: .fullWindow, |
| 61 | controlsShown: true, leave: {}, rejoin: {} |
| 62 | ) |
| 63 | .tint(CallsAppearance.accent) |
| 64 | // As the app's window has it (CallsMac.swift). |
| 65 | .frame(minWidth: minimum ? 760 : 0, minHeight: minimum ? 540 : 0) |
| 66 | let view = NSHostingView(rootView: content.environment(\.colorScheme, .dark)) |
| 67 | let window = NSWindow( |
| 68 | contentRect: NSRect(origin: CGPoint(x: 40, y: 40), size: steps[0].size), |
| 69 | styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], |
| 70 | backing: .buffered, defer: false) |
| 71 | window.isReleasedWhenClosed = false |
| 72 | window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) |
| 73 | window.appearance = NSAppearance(named: .darkAqua) |
| 74 | window.contentView = view |
| 75 | if !minimum { window.contentMinSize = CGSize(width: 200, height: 200) } |
| 76 | window.orderBack(nil) |
| 77 | try? await Task.sleep(for: .milliseconds(700)) |
| 78 | let variant = |
| 79 | "\(fixture.rawValue)\(chat ? "-chat" : "")\(devices ? "-devices" : "")\(minimum ? "" : "-nominimum")" |
| 80 | for step in steps { |
| 81 | window.setFrame( |
| 82 | NSRect(origin: window.frame.origin, size: step.size), display: true, animate: step.animate) |
| 83 | view.layoutSubtreeIfNeeded() |
| 84 | try? await Task.sleep(for: .milliseconds(step.quick ? 30 : 500)) |
| 85 | let width = view.bounds.width |
| 86 | let pane = CallBarProbe.pane |
| 87 | let row = CallBarProbe.row |
| 88 | let name = "\(variant)-\(step.name)" |
| 89 | var problems: [String] = [] |
| 90 | if pane.minX < -0.5 || pane.maxX > width + 0.5 { |
| 91 | problems.append("bar \(fmt(pane)) leaves the window (width \(fmt(width)))") |
| 92 | } |
| 93 | if row.minX < pane.minX - 0.5 || row.maxX > pane.maxX + 0.5 { |
| 94 | problems.append("buttons \(fmt(row)) spill out of the bar \(fmt(pane))") |
| 95 | } |
| 96 | let off = abs(pane.midX - width / 2) |
| 97 | if off > 1 { problems.append("bar off centre by \(fmt(off)) (bar \(fmt(pane)), window \(fmt(width)))") } |
| 98 | if pane.width < 1 { problems.append("bar not laid out") } |
| 99 | let line = "\(name): window \(fmt(width)) bar \(fmt(pane)) buttons \(fmt(row))" |
| 100 | print(problems.isEmpty ? "ok \(line)" : "FAIL \(line): \(problems.joined(separator: "; "))") |
| 101 | failures += problems.map { "\(name): \($0)" } |
| 102 | let file = folder.appendingPathComponent("bar-\(name).png") |
| 103 | capture(window, to: file) |
| 104 | // What was drawn, not only what was laid out: the red of Leave |
| 105 | // in the picture must be where the layout put Leave, and whole. |
| 106 | if let red = leaveRed(in: file, windowWidth: window.frame.width) { |
| 107 | let expected = CallBarProbe.last |
| 108 | if abs(red.midX - expected.midX) > 3 || red.width < expected.width - 4 { |
| 109 | let problem = |
| 110 | "Leave drawn at \(fmt(red)), laid out at \(fmt(expected))" |
| 111 | print("FAIL \(name): \(problem)") |
| 112 | failures.append("\(name): \(problem)") |
| 113 | } |
| 114 | } else { |
| 115 | print("FAIL \(name): no Leave in the picture") |
| 116 | failures.append("\(name): no Leave in the picture") |
| 117 | } |
| 118 | } |
| 119 | window.close() |
| 120 | } |
| 121 | } |
| 122 | print(failures.isEmpty ? "bar layout: all sizes ok" : "bar layout: \(failures.count) failures") |
| 123 | return failures.isEmpty |
| 124 | } |
| 125 | |
| 126 | private static func fmt(_ value: CGFloat) -> String { String(format: "%.1f", value) } |
| 127 | private static func fmt(_ rect: CGRect) -> String { |
| 128 | "\(fmt(rect.minX))…\(fmt(rect.maxX)) (\(fmt(rect.width)))" |
| 129 | } |
| 130 | |
| 131 | /// The horizontal extent, in points, of Leave's red in a capture of a |
| 132 | /// window `windowWidth` points wide. |
| 133 | private static func leaveRed(in file: URL, windowWidth: CGFloat) -> CGRect? { |
| 134 | guard let image = NSImage(contentsOf: file), |
| 135 | let cg = image.cgImage(forProposedRect: nil, context: nil, hints: nil), |
| 136 | let data = cg.dataProvider?.data, let bytes = CFDataGetBytePtr(data) |
| 137 | else { return nil } |
| 138 | let width = cg.width |
| 139 | let height = cg.height |
| 140 | let row = cg.bytesPerRow |
| 141 | let step = cg.bitsPerPixel / 8 |
| 142 | let scale = CGFloat(width) / windowWidth |
| 143 | let bgr = cg.bitmapInfo.contains(.byteOrder32Little) |
| 144 | var minX = Int.max |
| 145 | var maxX = Int.min |
| 146 | // The bottom 140 points: the bar and its margin. |
| 147 | let top = max(0, height - Int(140 * scale)) |
| 148 | for y in top..<height { |
| 149 | for x in 0..<width { |
| 150 | let p = bytes + y * row + x * step |
| 151 | let (r, g, b) = bgr ? (p[2], p[1], p[0]) : (p[0], p[1], p[2]) |
| 152 | // leaveFill is (0.84, 0.16, 0.14): about (214, 41, 36). |
| 153 | if r > 170, g < 90, b < 90 { |
| 154 | minX = min(minX, x) |
| 155 | maxX = max(maxX, x) |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | guard minX <= maxX else { return nil } |
| 160 | return CGRect(x: CGFloat(minX) / scale, y: 0, width: CGFloat(maxX - minX + 1) / scale, height: 1) |
| 161 | } |
| 162 | |
| 163 | private static func capture(_ window: NSWindow, to file: URL) { |
| 164 | let capture = Process() |
| 165 | capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") |
| 166 | capture.arguments = ["-x", "-o", "-l", String(window.windowNumber), file.path] |
| 167 | try? capture.run() |
| 168 | capture.waitUntilExit() |
| 169 | } |
| 170 | } |
| 171 | |