diff --git a/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift b/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift index 7b99712b..0905f073 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift @@ -999,6 +999,7 @@ private final class SelectionFlowBandLayer: CALayer { private let glowPass = FlowPassLayers(alphaScale: 0.24) private let linePass = FlowPassLayers(alphaScale: 1.0) + private let cornerAccentLayer = CAShapeLayer() private var focusRect: CGRect = .null private var theme: CaptureChromeTheme = .dark private var flowAnimating = false @@ -1043,7 +1044,8 @@ private final class SelectionFlowBandLayer: CALayer { theme: CaptureChromeTheme, timestamp _: CFTimeInterval, contentsScale: CGFloat, - animates: Bool + animates: Bool, + roundedExclusions _: [OverlayMaskGeometry.RoundedExclusion] ) { let focusChanged = self.focusRect != focusRect let themeChanged = self.theme != theme @@ -1068,6 +1070,8 @@ private final class SelectionFlowBandLayer: CALayer { } } + func updateRoundedExclusions(_: [OverlayMaskGeometry.RoundedExclusion]) {} + private func configureLayers() { for pass in [glowPass, linePass] { pass.containerLayer.masksToBounds = false @@ -1083,11 +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() + + cornerAccentLayer.fillColor = NSColor.clear.cgColor + cornerAccentLayer.lineCap = .butt + cornerAccentLayer.lineJoin = .miter + cornerAccentLayer.allowsEdgeAntialiasing = true + addSublayer(cornerAccentLayer) } private func updateAppearance() { @@ -1096,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) { @@ -1140,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 { @@ -1162,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) } @@ -1190,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( @@ -1221,6 +1247,68 @@ 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 { + 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] + ) { + guard + self.focusRect != focusRect + || !CFEqual(scrimColor, color) + || self.roundedExclusions != roundedExclusions + else { + return + } + 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 @@ -1228,7 +1316,7 @@ 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 +1446,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 +1461,8 @@ final class LiveOverlayRenderer { { loupeLayer.frame = loupeFrame } + updateLiveScrimExclusions(excluding: chromeExclusions) + updateLiveFlowExclusions(excluding: chromeExclusions) CATransaction.commit() } @@ -1378,7 +1472,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 +1605,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 +1675,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 +1807,8 @@ final class LiveOverlayRenderer { theme: snapshot.theme, timestamp: CACurrentMediaTime(), contentsScale: contentsScale, - animates: animatesFlow + animates: animatesFlow, + roundedExclusions: chromeExclusions ) } @@ -1714,19 +1817,85 @@ 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] = [] + ) { + let effectiveExclusions = Self.visibleScrimExclusions( + roundedExclusions, + bounds: bounds, + focusRect: focusRect + ) scrimLayer.frame = bounds - scrimLayer.path = path - scrimLayer.fillColor = color + scrimLayer.contentsScale = hostView?.window?.screen?.backingScaleFactor ?? 2 + scrimLayer.update( + focusRect: focusRect, + color: color, + 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] { + 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)) + } }