From 6ce4501eb95cba21ad70436c3c29247df3fe89d1 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 7 May 2026 13:09:50 +0800 Subject: [PATCH 1/2] {"schema":"maestro/commit/1","summary":"Fix live HUD overlay masking","authority":"manual"} --- .../LiveOverlayRenderer.swift | 194 ++++++++++++++++-- .../RsnapNativeHostKit/NativeHostApp.swift | 42 +++- .../OverlayMaskGeometry.swift | 99 +++++++++ .../RsnapNativeHostKitProbe/main.swift | 129 ++++++++++++ 4 files changed, 439 insertions(+), 25 deletions(-) create mode 100644 native/macos-host/Sources/RsnapNativeHostKit/OverlayMaskGeometry.swift diff --git a/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift b/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift index 7b99712b..8aea9c8f 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift @@ -999,9 +999,11 @@ private final class SelectionFlowBandLayer: CALayer { private let glowPass = FlowPassLayers(alphaScale: 0.24) private let linePass = FlowPassLayers(alphaScale: 1.0) + private let occlusionMaskLayer = CAShapeLayer() private var focusRect: CGRect = .null private var theme: CaptureChromeTheme = .dark private var flowAnimating = false + private var roundedExclusions: [OverlayMaskGeometry.RoundedExclusion] = [] override init() { super.init() @@ -1018,6 +1020,7 @@ private final class SelectionFlowBandLayer: CALayer { focusRect = layer.focusRect theme = layer.theme flowAnimating = layer.flowAnimating + roundedExclusions = layer.roundedExclusions } configureLayers() } @@ -1034,6 +1037,8 @@ private final class SelectionFlowBandLayer: CALayer { isHidden = true focusRect = .null flowAnimating = false + roundedExclusions = [] + mask = nil removeFlowAnimation() } @@ -1043,22 +1048,32 @@ private final class SelectionFlowBandLayer: CALayer { theme: CaptureChromeTheme, timestamp _: CFTimeInterval, contentsScale: CGFloat, - animates: Bool + animates: Bool, + roundedExclusions: [OverlayMaskGeometry.RoundedExclusion] ) { + let localizedExclusions = Self.localRoundedExclusions( + roundedExclusions, + in: frame + ) let focusChanged = self.focusRect != focusRect let themeChanged = self.theme != theme let frameChanged = self.frame != frame let scaleChanged = self.contentsScale != contentsScale let animationChanged = flowAnimating != animates + let exclusionsChanged = self.roundedExclusions != localizedExclusions let wasHidden = isHidden self.frame = frame self.contentsScale = contentsScale self.focusRect = focusRect self.theme = theme flowAnimating = animates + self.roundedExclusions = localizedExclusions if wasHidden || focusChanged || themeChanged || frameChanged || scaleChanged { updateAppearance() } + if frameChanged || scaleChanged || exclusionsChanged { + updateOcclusionMask() + } if animates { isHidden = false installFlowAnimation(restartsAnimation: wasHidden || animationChanged) @@ -1068,7 +1083,25 @@ private final class SelectionFlowBandLayer: CALayer { } } + func updateRoundedExclusions(_ roundedExclusions: [OverlayMaskGeometry.RoundedExclusion]) { + let localizedExclusions = Self.localRoundedExclusions( + roundedExclusions, + in: frame + ) + guard + self.roundedExclusions != localizedExclusions + || (localizedExclusions.isEmpty && mask != nil) + || (!localizedExclusions.isEmpty && mask == nil) + else { + return + } + self.roundedExclusions = localizedExclusions + updateOcclusionMask() + } + private func configureLayers() { + occlusionMaskLayer.fillRule = .evenOdd + occlusionMaskLayer.fillColor = NSColor.black.cgColor for pass in [glowPass, linePass] { pass.containerLayer.masksToBounds = false pass.containerLayer.allowsEdgeAntialiasing = true @@ -1090,6 +1123,29 @@ private final class SelectionFlowBandLayer: CALayer { glowPass.containerLayer.opacity = selectionFlowGlowOpacity() } + private static func localRoundedExclusions( + _ roundedExclusions: [OverlayMaskGeometry.RoundedExclusion], + in frame: CGRect + ) -> [OverlayMaskGeometry.RoundedExclusion] { + roundedExclusions.map { + $0.offsetBy(dx: -frame.minX, dy: -frame.minY) + } + } + + private func updateOcclusionMask() { + guard !roundedExclusions.isEmpty else { + mask = nil + return + } + occlusionMaskLayer.frame = bounds + occlusionMaskLayer.contentsScale = contentsScale + occlusionMaskLayer.path = OverlayMaskGeometry.evenOddMaskPath( + bounds: bounds, + roundedExclusions: roundedExclusions + ) + mask = occlusionMaskLayer + } + private func updateAppearance() { CATransaction.begin() CATransaction.setDisableActions(true) @@ -1223,12 +1279,63 @@ private final class SelectionFlowBandLayer: CALayer { } } +private final class LiveScrimLayer: CALayer { + private var focusRect = CGRect.null + private var roundedExclusions: [OverlayMaskGeometry.RoundedExclusion] = [] + var scrimColor: CGColor = + NSColor(calibratedWhite: 0, alpha: CGFloat(CaptureChrome.liveScrimAlpha)).cgColor + + override init() { + super.init() + isOpaque = false + needsDisplayOnBoundsChange = true + } + + override init(layer: Any) { + if let layer = layer as? LiveScrimLayer { + focusRect = layer.focusRect + roundedExclusions = layer.roundedExclusions + scrimColor = layer.scrimColor + } + super.init(layer: layer) + isOpaque = false + needsDisplayOnBoundsChange = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update( + focusRect: CGRect, + color: CGColor, + roundedExclusions: [OverlayMaskGeometry.RoundedExclusion] + ) { + self.focusRect = focusRect + self.scrimColor = color + self.roundedExclusions = roundedExclusions + setNeedsDisplay() + } + + override func draw(in context: CGContext) { + context.clear(bounds) + OverlayMaskGeometry.drawScrim( + in: context, + bounds: bounds, + focusRect: focusRect, + color: scrimColor, + roundedExclusions: roundedExclusions + ) + } +} + @MainActor final class LiveOverlayRenderer { private weak var hostView: NSView? private let rootLayer = CALayer() private let frozenDisplayLayer = CALayer() - private let scrimLayer = CAShapeLayer() + private let scrimLayer = LiveScrimLayer() private let topScrimLayer = CALayer() private let leftScrimLayer = CALayer() private let rightScrimLayer = CALayer() @@ -1358,7 +1465,11 @@ final class LiveOverlayRenderer { renderChromeSnapshot() } - func moveLiveChrome(hudFrame: CGRect?, loupeFrame: CGRect?) { + func moveLiveChrome( + hudFrame: CGRect?, + loupeFrame: CGRect?, + chromeExclusions: [OverlayMaskGeometry.RoundedExclusion] + ) { CATransaction.begin() CATransaction.setDisableActions(true) if let hudFrame, !hudLayer.isHidden, layerFrameNeedsUpdate(hudLayer.frame, hudFrame) { @@ -1369,6 +1480,8 @@ final class LiveOverlayRenderer { { loupeLayer.frame = loupeFrame } + updateLiveScrimExclusions(excluding: chromeExclusions) + updateLiveFlowExclusions(excluding: chromeExclusions) CATransaction.commit() } @@ -1378,7 +1491,6 @@ final class LiveOverlayRenderer { frozenDisplayLayer.isHidden = true frozenDisplayLayer.zPosition = LayerZ.frozenDisplay rootLayer.addSublayer(frozenDisplayLayer) - scrimLayer.fillRule = .evenOdd scrimLayer.isHidden = true scrimLayer.zPosition = LayerZ.scrim rootLayer.addSublayer(scrimLayer) @@ -1512,6 +1624,9 @@ final class LiveOverlayRenderer { CATransaction.setDisableActions(true) rootLayer.isHidden = false rootLayer.frame = snapshot.bounds + let chromeExclusions = liveChromeRoundedExclusions(for: snapshot) + updateLiveScrimExclusions(excluding: chromeExclusions) + updateLiveFlowExclusions(excluding: chromeExclusions) renderHud(snapshot) renderLoupe(snapshot) CATransaction.commit() @@ -1579,10 +1694,16 @@ final class LiveOverlayRenderer { let scrimAlpha = CGFloat(CaptureChrome.liveScrimAlpha) let scrimColor = NSColor(calibratedWhite: 0, alpha: scrimAlpha).cgColor let bounds = snapshot.bounds + let chromeExclusions = liveChromeRoundedExclusions(for: snapshot) for legacyScrimLayer in [topScrimLayer, leftScrimLayer, rightScrimLayer, bottomScrimLayer] { legacyScrimLayer.isHidden = true } - updateScrimLayer(bounds: bounds, focusRect: focusRect, color: scrimColor) + updateScrimLayer( + bounds: bounds, + focusRect: focusRect, + color: scrimColor, + excluding: chromeExclusions + ) if snapshot.frozenPending { hoverGlowLayer.isHidden = true @@ -1705,7 +1826,8 @@ final class LiveOverlayRenderer { theme: snapshot.theme, timestamp: CACurrentMediaTime(), contentsScale: contentsScale, - animates: animatesFlow + animates: animatesFlow, + roundedExclusions: chromeExclusions ) } @@ -1714,19 +1836,61 @@ final class LiveOverlayRenderer { return borderRect.insetBy(dx: -padding, dy: -padding) } - private func updateScrimLayer(bounds: CGRect, focusRect: CGRect, color: CGColor) { - let path = CGMutablePath() - path.addRect(bounds) - let visibleFocusRect = focusRect.intersection(bounds) - if !visibleFocusRect.isNull, visibleFocusRect.width > 0, visibleFocusRect.height > 0 { - path.addRect(visibleFocusRect) - } + private func updateScrimLayer( + bounds: CGRect, + focusRect: CGRect, + color: CGColor, + excluding roundedExclusions: [OverlayMaskGeometry.RoundedExclusion] = [] + ) { scrimLayer.frame = bounds - scrimLayer.path = path - scrimLayer.fillColor = color + scrimLayer.contentsScale = hostView?.window?.screen?.backingScaleFactor ?? 2 + scrimLayer.update( + focusRect: focusRect, + color: color, + roundedExclusions: roundedExclusions + ) scrimLayer.isHidden = false } + private func liveChromeRoundedExclusions( + for snapshot: LivePreviewSnapshot + ) -> [OverlayMaskGeometry.RoundedExclusion] { + guard snapshot.settings.hudGlassEnabled else { + return [] + } + return [snapshot.hudFrame, snapshot.loupeFrame].compactMap { frame in + frame.map { + OverlayMaskGeometry.RoundedExclusion( + rect: $0, + cornerRadius: CaptureChrome.hudCornerRadius + ) + } + } + } + + private func updateLiveScrimExclusions( + excluding exclusions: [OverlayMaskGeometry.RoundedExclusion] + ) { + guard !scrimLayer.isHidden, let focusRect = lastRenderedFocusRect else { + return + } + updateScrimLayer( + bounds: rootLayer.bounds, + focusRect: focusRect, + color: scrimLayer.scrimColor, + excluding: exclusions + ) + } + + private func updateLiveFlowExclusions( + excluding exclusions: [OverlayMaskGeometry.RoundedExclusion] + ) { + guard !hoverFlowLayer.isHidden else { + return + } + hoverFlowLayer.updateRoundedExclusions(exclusions) + } + private func shouldAnimateSelectionFlow(_ snapshot: LivePreviewSnapshot) -> Bool { guard snapshot.dragSelectionLocal == nil, snapshot.hoverSelectionLocal != nil, !snapshot.frozenPending diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift index d8edcfbc..3e53b89c 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift @@ -5826,16 +5826,14 @@ final class CaptureHostView: NSView { return } - let path = CGMutablePath() - path.addRect(bounds) - path.addRect(visibleFocusRect) - if let exclusionPath { - path.addPath(exclusionPath) - } context.saveGState() - context.setFillColor(scrimColor.cgColor) - context.addPath(path) - context.drawPath(using: .eoFill) + OverlayMaskGeometry.drawScrim( + in: context, + bounds: bounds, + focusRect: visibleFocusRect, + color: scrimColor.cgColor, + pathExclusions: [exclusionPath].compactMap { $0 } + ) context.restoreGState() } @@ -6911,7 +6909,31 @@ final class CaptureHostView: NSView { let frames = currentLiveChromeLayerFrames() updateLiveChromeBackdrops(hudFrame: frames.hud, loupeFrame: frames.loupe) moveExistingLiveLiquidGlassViews(hudFrame: frames.hud, loupeFrame: frames.loupe) - liveRenderer.moveLiveChrome(hudFrame: frames.hud, loupeFrame: frames.loupe) + liveRenderer.moveLiveChrome( + hudFrame: frames.hud, + loupeFrame: frames.loupe, + chromeExclusions: liveChromeRoundedExclusions( + hudFrame: frames.hud, + loupeFrame: frames.loupe + ) + ) + } + + private func liveChromeRoundedExclusions( + hudFrame: CGRect?, + loupeFrame: CGRect? + ) -> [OverlayMaskGeometry.RoundedExclusion] { + guard settings.hudGlassEnabled else { + return [] + } + return [hudFrame, loupeFrame].compactMap { frame in + frame.map { + OverlayMaskGeometry.RoundedExclusion( + rect: $0, + cornerRadius: CaptureChrome.hudCornerRadius + ) + } + } } private func currentLiveChromeLayerFrames() -> (hud: CGRect?, loupe: CGRect?) { diff --git a/native/macos-host/Sources/RsnapNativeHostKit/OverlayMaskGeometry.swift b/native/macos-host/Sources/RsnapNativeHostKit/OverlayMaskGeometry.swift new file mode 100644 index 00000000..f8981832 --- /dev/null +++ b/native/macos-host/Sources/RsnapNativeHostKit/OverlayMaskGeometry.swift @@ -0,0 +1,99 @@ +import CoreGraphics + +package enum OverlayMaskGeometry { + package struct RoundedExclusion: Equatable { + package let rect: CGRect + package let cornerRadius: CGFloat + + package init(rect: CGRect, cornerRadius: CGFloat) { + self.rect = rect + self.cornerRadius = cornerRadius + } + + package func offsetBy(dx: CGFloat, dy: CGFloat) -> Self { + Self(rect: rect.offsetBy(dx: dx, dy: dy), cornerRadius: cornerRadius) + } + } + + package static func drawScrim( + in context: CGContext, + bounds: CGRect, + focusRect: CGRect, + color: CGColor, + roundedExclusions: [RoundedExclusion] = [], + pathExclusions: [CGPath] = [] + ) { + context.saveGState() + context.setFillColor(color) + context.fill(bounds) + context.clip(to: bounds) + context.setBlendMode(.clear) + clearRect(focusRect, in: context) + for exclusion in roundedExclusions { + clearRoundedRect(exclusion, in: context) + } + for path in pathExclusions { + context.addPath(path) + context.fillPath() + } + context.restoreGState() + } + + package static func evenOddMaskPath( + bounds: CGRect, + roundedExclusions: [RoundedExclusion] + ) -> CGPath { + let maskPath = CGMutablePath() + guard bounds.isRenderableMaskRect else { + return maskPath + } + maskPath.addRect(bounds) + for exclusion in roundedExclusions { + if let path = roundedPath(for: exclusion) { + maskPath.addPath(path) + } + } + return maskPath + } + + private static func clearRect(_ rect: CGRect, in context: CGContext) { + guard rect.isRenderableMaskRect else { + return + } + context.fill(rect) + } + + private static func clearRoundedRect( + _ exclusion: RoundedExclusion, + in context: CGContext + ) { + guard let path = roundedPath(for: exclusion) else { + return + } + context.addPath(path) + context.fillPath() + } + + private static func roundedPath(for exclusion: RoundedExclusion) -> CGPath? { + guard exclusion.rect.isRenderableMaskRect else { + return nil + } + let radius = min( + max(0, exclusion.cornerRadius), + exclusion.rect.width / 2, + exclusion.rect.height / 2 + ) + return CGPath( + roundedRect: exclusion.rect, + cornerWidth: radius, + cornerHeight: radius, + transform: nil + ) + } +} + +extension CGRect { + fileprivate var isRenderableMaskRect: Bool { + !isNull && !isInfinite && width > 0 && height > 0 + } +} diff --git a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift index c9cb17e8..43f867ce 100644 --- a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift +++ b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift @@ -40,6 +40,8 @@ enum RsnapNativeHostKitProbe { selection: selection, imageSize: imageSize ) + assertScrimRoundedExclusionKeepsCornersMasked() + assertRoundedExclusionMaskKeepsCornersFilled() let minimapExportSize = CGSize(width: 100, height: 200) guard let rightMinimap = scrollCaptureMinimapFrame( @@ -170,6 +172,107 @@ enum RsnapNativeHostKitProbe { } } + private static func assertScrimRoundedExclusionKeepsCornersMasked() { + let width = 80 + let height = 80 + let byteCount = width * height * 4 + let data = UnsafeMutablePointer.allocate(capacity: byteCount) + data.initialize(repeating: 0, count: byteCount) + defer { + data.deinitialize(count: byteCount) + data.deallocate() + } + guard + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: data, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + fatalError("could not create scrim geometry probe context") + } + + OverlayMaskGeometry.drawScrim( + in: context, + bounds: CGRect(x: 0, y: 0, width: width, height: height), + focusRect: CGRect(x: 48, y: 48, width: 16, height: 16), + color: CGColor(red: 0, green: 0, blue: 0, alpha: 1), + roundedExclusions: [ + OverlayMaskGeometry.RoundedExclusion( + rect: CGRect(x: 10, y: 10, width: 40, height: 24), + cornerRadius: 12 + ) + ] + ) + + guard clearPixel(in: data, width: width, height: height, x: 30, yFromBottom: 22) + else { + fatalError("rounded scrim exclusion did not clear the HUD body") + } + guard opaquePixel(in: data, width: width, height: height, x: 10, yFromBottom: 10) + else { + fatalError("rounded scrim exclusion cleared a square corner") + } + guard clearPixel(in: data, width: width, height: height, x: 56, yFromBottom: 56) + else { + fatalError("selection focus rect was not cleared") + } + } + + private static func assertRoundedExclusionMaskKeepsCornersFilled() { + let width = 80 + let height = 80 + let byteCount = width * height * 4 + let data = UnsafeMutablePointer.allocate(capacity: byteCount) + data.initialize(repeating: 0, count: byteCount) + defer { + data.deinitialize(count: byteCount) + data.deallocate() + } + guard + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: data, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + fatalError("could not create rounded mask probe context") + } + + context.setFillColor(CGColor(red: 0, green: 0, blue: 0, alpha: 1)) + context.addPath( + OverlayMaskGeometry.evenOddMaskPath( + bounds: CGRect(x: 0, y: 0, width: width, height: height), + roundedExclusions: [ + OverlayMaskGeometry.RoundedExclusion( + rect: CGRect(x: 10, y: 10, width: 40, height: 24), + cornerRadius: 12 + ) + ] + ) + ) + context.drawPath(using: .eoFill) + + guard clearPixel(in: data, width: width, height: height, x: 30, yFromBottom: 22) + else { + fatalError("rounded mask did not exclude the HUD body") + } + guard opaquePixel(in: data, width: width, height: height, x: 11, yFromBottom: 11) + else { + fatalError("rounded mask excluded a square corner") + } + } + private static func redPixel( in data: UnsafePointer, width: Int, @@ -182,4 +285,30 @@ enum RsnapNativeHostKitProbe { && data[offset + 2] < 20 && data[offset + 3] > 200 } + + private static func opaquePixel( + in data: UnsafePointer, + width: Int, + height: Int, + x: Int, + yFromBottom: Int + ) -> Bool { + let offset = (rowIndex(fromBottom: yFromBottom, height: height) * width + x) * 4 + return data[offset + 3] > 200 + } + + private static func clearPixel( + in data: UnsafePointer, + width: Int, + height: Int, + x: Int, + yFromBottom: Int + ) -> Bool { + let offset = (rowIndex(fromBottom: yFromBottom, height: height) * width + x) * 4 + return data[offset + 3] < 20 + } + + private static func rowIndex(fromBottom y: Int, height: Int) -> Int { + max(0, min(height - 1, height - y - 1)) + } } From 5ea91862475a5b0c460a051657267e57738dc4d2 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 7 May 2026 14:05:21 +0800 Subject: [PATCH 2/2] {"schema":"maestro/commit/1","summary":"Restore straight selection flow corners","authority":"manual"} --- .../LiveOverlayRenderer.swift | 165 +++++++++--------- 1 file changed, 85 insertions(+), 80 deletions(-) diff --git a/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift b/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift index 8aea9c8f..0905f073 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift @@ -999,11 +999,10 @@ private final class SelectionFlowBandLayer: CALayer { private let glowPass = FlowPassLayers(alphaScale: 0.24) private let linePass = FlowPassLayers(alphaScale: 1.0) - private let occlusionMaskLayer = CAShapeLayer() + private let cornerAccentLayer = CAShapeLayer() private var focusRect: CGRect = .null private var theme: CaptureChromeTheme = .dark private var flowAnimating = false - private var roundedExclusions: [OverlayMaskGeometry.RoundedExclusion] = [] override init() { super.init() @@ -1020,7 +1019,6 @@ private final class SelectionFlowBandLayer: CALayer { focusRect = layer.focusRect theme = layer.theme flowAnimating = layer.flowAnimating - roundedExclusions = layer.roundedExclusions } configureLayers() } @@ -1037,8 +1035,6 @@ private final class SelectionFlowBandLayer: CALayer { isHidden = true focusRect = .null flowAnimating = false - roundedExclusions = [] - mask = nil removeFlowAnimation() } @@ -1049,31 +1045,22 @@ private final class SelectionFlowBandLayer: CALayer { timestamp _: CFTimeInterval, contentsScale: CGFloat, animates: Bool, - roundedExclusions: [OverlayMaskGeometry.RoundedExclusion] + roundedExclusions _: [OverlayMaskGeometry.RoundedExclusion] ) { - let localizedExclusions = Self.localRoundedExclusions( - roundedExclusions, - in: frame - ) let focusChanged = self.focusRect != focusRect let themeChanged = self.theme != theme let frameChanged = self.frame != frame let scaleChanged = self.contentsScale != contentsScale let animationChanged = flowAnimating != animates - let exclusionsChanged = self.roundedExclusions != localizedExclusions let wasHidden = isHidden self.frame = frame self.contentsScale = contentsScale self.focusRect = focusRect self.theme = theme flowAnimating = animates - self.roundedExclusions = localizedExclusions if wasHidden || focusChanged || themeChanged || frameChanged || scaleChanged { updateAppearance() } - if frameChanged || scaleChanged || exclusionsChanged { - updateOcclusionMask() - } if animates { isHidden = false installFlowAnimation(restartsAnimation: wasHidden || animationChanged) @@ -1083,25 +1070,9 @@ private final class SelectionFlowBandLayer: CALayer { } } - func updateRoundedExclusions(_ roundedExclusions: [OverlayMaskGeometry.RoundedExclusion]) { - let localizedExclusions = Self.localRoundedExclusions( - roundedExclusions, - in: frame - ) - guard - self.roundedExclusions != localizedExclusions - || (localizedExclusions.isEmpty && mask != nil) - || (!localizedExclusions.isEmpty && mask == nil) - else { - return - } - self.roundedExclusions = localizedExclusions - updateOcclusionMask() - } + func updateRoundedExclusions(_: [OverlayMaskGeometry.RoundedExclusion]) {} private func configureLayers() { - occlusionMaskLayer.fillRule = .evenOdd - occlusionMaskLayer.fillColor = NSColor.black.cgColor for pass in [glowPass, linePass] { pass.containerLayer.masksToBounds = false pass.containerLayer.allowsEdgeAntialiasing = true @@ -1116,34 +1087,17 @@ private final class SelectionFlowBandLayer: CALayer { pass.maskLayer.fillColor = NSColor.clear.cgColor pass.maskLayer.strokeColor = NSColor.white.cgColor - pass.maskLayer.lineCap = .round - pass.maskLayer.lineJoin = .round + pass.maskLayer.lineCap = .butt + pass.maskLayer.lineJoin = .miter pass.maskLayer.allowsEdgeAntialiasing = true } glowPass.containerLayer.opacity = selectionFlowGlowOpacity() - } - private static func localRoundedExclusions( - _ roundedExclusions: [OverlayMaskGeometry.RoundedExclusion], - in frame: CGRect - ) -> [OverlayMaskGeometry.RoundedExclusion] { - roundedExclusions.map { - $0.offsetBy(dx: -frame.minX, dy: -frame.minY) - } - } - - private func updateOcclusionMask() { - guard !roundedExclusions.isEmpty else { - mask = nil - return - } - occlusionMaskLayer.frame = bounds - occlusionMaskLayer.contentsScale = contentsScale - occlusionMaskLayer.path = OverlayMaskGeometry.evenOddMaskPath( - bounds: bounds, - roundedExclusions: roundedExclusions - ) - mask = occlusionMaskLayer + cornerAccentLayer.fillColor = NSColor.clear.cgColor + cornerAccentLayer.lineCap = .butt + cornerAccentLayer.lineJoin = .miter + cornerAccentLayer.allowsEdgeAntialiasing = true + addSublayer(cornerAccentLayer) } private func updateAppearance() { @@ -1152,20 +1106,17 @@ private final class SelectionFlowBandLayer: CALayer { let strokeRect = focusRect.insetBy(dx: -Self.pathOutset, dy: -Self.pathOutset) update(glowPass, strokeRect: strokeRect, lineWidth: selectionFlowGlowLineWidth()) update(linePass, strokeRect: strokeRect, lineWidth: selectionFlowLineWidth()) + updateCornerAccent(strokeRect: strokeRect) CATransaction.commit() } private func installFlowAnimation(restartsAnimation: Bool) { - let hasAnimations = [glowPass, linePass].allSatisfy { - $0.gradientLayer.animation(forKey: Self.flowAnimationKey) != nil - } + let hasAnimations = linePass.gradientLayer.animation(forKey: Self.flowAnimationKey) != nil if !restartsAnimation, hasAnimations { return } removeFlowAnimation() - for pass in [glowPass, linePass] { - installFlowAnimation(on: pass.gradientLayer) - } + installFlowAnimation(on: linePass.gradientLayer) } private func installFlowAnimation(on layer: CALayer) { @@ -1196,16 +1147,10 @@ private final class SelectionFlowBandLayer: CALayer { pass.gradientLayer.colors = gradientColors(alphaScale: pass.alphaScale) pass.gradientLayer.locations = gradientLocations() - let cornerRadius = selectionFlowCornerRadius(for: strokeRect) pass.maskLayer.frame = layerBounds pass.maskLayer.contentsScale = contentsScale pass.maskLayer.lineWidth = lineWidth - pass.maskLayer.path = - NSBezierPath( - roundedRect: strokeRect, - xRadius: cornerRadius, - yRadius: cornerRadius - ).cgPath + pass.maskLayer.path = NSBezierPath(rect: strokeRect).cgPath } private func conicGradientFrame(in layerBounds: CGRect) -> CGRect { @@ -1218,6 +1163,41 @@ private final class SelectionFlowBandLayer: CALayer { ) } + private func updateCornerAccent(strokeRect: CGRect) { + cornerAccentLayer.frame = bounds + cornerAccentLayer.contentsScale = contentsScale + cornerAccentLayer.lineWidth = selectionFlowLineWidth() + cornerAccentLayer.opacity = theme == .dark ? 0.86 : 0.72 + cornerAccentLayer.strokeColor = cgColor( + from: (theme == .dark ? Self.darkPalette[0] : Self.lightPalette[0]), + alphaScale: 0.90 + ) + cornerAccentLayer.path = selectionFlowCornerAccentPath(for: strokeRect) + } + + private func selectionFlowCornerAccentPath(for rect: CGRect) -> CGPath { + let overhang = selectionFlowCornerOverhang() + let inset = overhang * 1.4 + let path = CGMutablePath() + path.move(to: CGPoint(x: rect.minX - overhang, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX + inset, y: rect.minY)) + path.move(to: CGPoint(x: rect.maxX, y: rect.minY - overhang)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + inset)) + path.move(to: CGPoint(x: rect.maxX + overhang, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX - inset, y: rect.maxY)) + path.move(to: CGPoint(x: rect.minX, y: rect.maxY + overhang)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - inset)) + path.move(to: CGPoint(x: rect.maxX + overhang, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - inset, y: rect.minY)) + path.move(to: CGPoint(x: rect.maxX, y: rect.maxY + overhang)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - inset)) + path.move(to: CGPoint(x: rect.minX - overhang, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX + inset, y: rect.maxY)) + path.move(to: CGPoint(x: rect.minX, y: rect.minY - overhang)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + inset)) + return path + } + private func gradientColors(alphaScale: CGFloat) -> [CGColor] { let palette = theme == .dark ? Self.darkPalette : Self.lightPalette var colors = palette.map { cgColor(from: $0, alphaScale: alphaScale) } @@ -1246,16 +1226,6 @@ private final class SelectionFlowBandLayer: CALayer { ).cgColor } - private func selectionFlowCornerRadius(for rect: CGRect) -> CGFloat { - max( - 0, - min( - CaptureChrome.liveSelectionCornerRadius + Self.pathOutset, - min(rect.width / 2 - 0.25, rect.height / 2 - 0.25) - ) - ) - } - private func pixelAligned(_ rect: CGRect) -> CGRect { let scale = max(contentsScale, 1) return CGRect( @@ -1277,6 +1247,10 @@ private final class SelectionFlowBandLayer: CALayer { private func selectionFlowGlowOpacity() -> Float { theme == .dark ? 0.30 : 0.34 } + + private func selectionFlowCornerOverhang() -> CGFloat { + max(selectionFlowGlowLineWidth() / 2, 3) + } } private final class LiveScrimLayer: CALayer { @@ -1312,6 +1286,13 @@ private final class LiveScrimLayer: CALayer { color: CGColor, roundedExclusions: [OverlayMaskGeometry.RoundedExclusion] ) { + guard + self.focusRect != focusRect + || !CFEqual(scrimColor, color) + || self.roundedExclusions != roundedExclusions + else { + return + } self.focusRect = focusRect self.scrimColor = color self.roundedExclusions = roundedExclusions @@ -1842,16 +1823,40 @@ final class LiveOverlayRenderer { color: CGColor, excluding roundedExclusions: [OverlayMaskGeometry.RoundedExclusion] = [] ) { + let effectiveExclusions = Self.visibleScrimExclusions( + roundedExclusions, + bounds: bounds, + focusRect: focusRect + ) scrimLayer.frame = bounds scrimLayer.contentsScale = hostView?.window?.screen?.backingScaleFactor ?? 2 scrimLayer.update( focusRect: focusRect, color: color, - roundedExclusions: roundedExclusions + roundedExclusions: effectiveExclusions ) scrimLayer.isHidden = false } + private static func visibleScrimExclusions( + _ roundedExclusions: [OverlayMaskGeometry.RoundedExclusion], + bounds: CGRect, + focusRect: CGRect + ) -> [OverlayMaskGeometry.RoundedExclusion] { + roundedExclusions.compactMap { exclusion in + let visibleRect = exclusion.rect.intersection(bounds) + guard !visibleRect.isNull, visibleRect.width > 0, visibleRect.height > 0, + !focusRect.contains(visibleRect) + else { + return nil + } + return OverlayMaskGeometry.RoundedExclusion( + rect: visibleRect, + cornerRadius: exclusion.cornerRadius + ) + } + } + private func liveChromeRoundedExclusions( for snapshot: LivePreviewSnapshot ) -> [OverlayMaskGeometry.RoundedExclusion] {