From cbe69b6d1fd448c7fabef92baec77d5188911c53 Mon Sep 17 00:00:00 2001 From: winebarrel Date: Mon, 10 Aug 2026 10:53:27 +0900 Subject: [PATCH] Draw the desktop picture as the backdrop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The saver painted a flat grey and nothing else, which is not what "the usual screen with a cat on it" looks like. It now draws the display's own wallpaper, on by default, with the grey slider as the fallback. Reading the file turns out to need no ceremony: legacyScreenSaver.appex holds com.apple.security.temporary-exception.files.absolute-path.read-only for `/` (alongside com.apple.security.assets.pictures.read-only), so a picture anywhere on disk opens without a security-scoped bookmark. `Wallpaper` still returns nil on anything it cannot read — a video wallpaper has no still frame — and the engine falls back to the grey. NSWorkspace also reports how the desktop lays the picture out, so Fill Screen, Fit to Screen, Stretch and Centre are all honoured, fill colour included, and the backdrop matches what was on screen a moment earlier. The wallpaper is per display and only the view knows which display it landed on, so the view supplies it and the engine stays free of NSScreen. It is read in startAnimation, by which point the view is in a window, and only when the setting asks for it — decoding a picture costs megabytes a grey backdrop has no use for. The litter ink used to flip on the background slider alone. It now renders the backdrop into a 16px thumbnail and averages it, so a letterboxed picture's fill colour counts for as much of the answer as it does of the screen. --- NecoSaver.xcodeproj/project.pbxproj | 6 ++ Preview/PreviewApp.swift | 26 +++++-- README.md | 12 ++- Sources/ConfigureSheetController.swift | 2 + Sources/NecoSaverEngine.swift | 33 ++++++-- Sources/NecoSaverSettings.swift | 9 +++ Sources/NecoSaverView.swift | 11 +++ Sources/Wallpaper.swift | 104 +++++++++++++++++++++++++ 8 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 Sources/Wallpaper.swift diff --git a/NecoSaver.xcodeproj/project.pbxproj b/NecoSaver.xcodeproj/project.pbxproj index 642705c..8653309 100644 --- a/NecoSaver.xcodeproj/project.pbxproj +++ b/NecoSaver.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ CE0000000000000000000106 /* NecoSaverEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000006 /* NecoSaverEngine.swift */; }; CE0000000000000000000107 /* ConfigureSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000007 /* ConfigureSheetController.swift */; }; CE0000000000000000000108 /* NecoSaverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000008 /* NecoSaverView.swift */; }; + CE0000000000000000000109 /* Wallpaper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000010 /* Wallpaper.swift */; }; CE0000000000000000000111 /* Sprites.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000001 /* Sprites.swift */; }; CE0000000000000000000112 /* SpriteCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000002 /* SpriteCache.swift */; }; CE0000000000000000000113 /* Neko.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000003 /* Neko.swift */; }; @@ -23,6 +24,7 @@ CE0000000000000000000116 /* NecoSaverEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000006 /* NecoSaverEngine.swift */; }; CE0000000000000000000117 /* ConfigureSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000007 /* ConfigureSheetController.swift */; }; CE0000000000000000000118 /* NecoSaverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000008 /* NecoSaverView.swift */; }; + CE000000000000000000011A /* Wallpaper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000010 /* Wallpaper.swift */; }; CE0000000000000000000119 /* PreviewApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000009 /* PreviewApp.swift */; }; CE0000000000000000000131 /* ScreenSaver.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000031 /* ScreenSaver.framework */; }; CE0000000000000000000132 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000032 /* Cocoa.framework */; }; @@ -39,6 +41,7 @@ CE0000000000000000000006 /* NecoSaverEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NecoSaverEngine.swift; sourceTree = ""; }; CE0000000000000000000007 /* ConfigureSheetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigureSheetController.swift; sourceTree = ""; }; CE0000000000000000000008 /* NecoSaverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NecoSaverView.swift; sourceTree = ""; }; + CE0000000000000000000010 /* Wallpaper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Wallpaper.swift; sourceTree = ""; }; CE0000000000000000000009 /* PreviewApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewApp.swift; sourceTree = ""; }; CE0000000000000000000011 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; CE0000000000000000000012 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -78,6 +81,7 @@ CE0000000000000000000002 /* SpriteCache.swift */, CE0000000000000000000003 /* Neko.swift */, CE0000000000000000000004 /* Litter.swift */, + CE0000000000000000000010 /* Wallpaper.swift */, CE0000000000000000000005 /* NecoSaverSettings.swift */, CE0000000000000000000006 /* NecoSaverEngine.swift */, CE0000000000000000000007 /* ConfigureSheetController.swift */, @@ -228,6 +232,7 @@ CE0000000000000000000106 /* NecoSaverEngine.swift in Sources */, CE0000000000000000000107 /* ConfigureSheetController.swift in Sources */, CE0000000000000000000108 /* NecoSaverView.swift in Sources */, + CE0000000000000000000109 /* Wallpaper.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -243,6 +248,7 @@ CE0000000000000000000116 /* NecoSaverEngine.swift in Sources */, CE0000000000000000000117 /* ConfigureSheetController.swift in Sources */, CE0000000000000000000118 /* NecoSaverView.swift in Sources */, + CE000000000000000000011A /* Wallpaper.swift in Sources */, CE0000000000000000000119 /* PreviewApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Preview/PreviewApp.swift b/Preview/PreviewApp.swift index 306042b..bfd1ae0 100644 --- a/Preview/PreviewApp.swift +++ b/Preview/PreviewApp.swift @@ -5,7 +5,8 @@ import AppKit /// The live window hosts the real `NecoSaverView`, so running it proves the view is /// constructible outside System Settings — the failure that otherwise only shows up /// as an empty preview after installing. Snapshots drive `NecoSaverEngine` directly -/// instead, which is what lets `--cats` and `--speed` apply to a single render. +/// instead, which is what lets `--cats`, `--speed` and `--wallpaper` apply to a +/// single render. /// /// NecoSaverPreview # live window /// NecoSaverPreview --options # live window + Options sheet @@ -14,6 +15,7 @@ import AppKit /// NecoSaverPreview --snapshot out.png --preview # as the System Settings thumbnail /// NecoSaverPreview --snapshot out.png --cats 5 # override the saved cat count /// NecoSaverPreview --snapshot out.png --speed 4 # override the saved speed +/// NecoSaverPreview --snapshot out.png --wallpaper off # ... and the backdrop /// NecoSaverPreview --snapshot out.png --options # render the Options sheet instead /// /// `--size` is in points; snapshots are written at 2x, as on a Retina display. @@ -99,9 +101,9 @@ enum PreviewApp { /// Steps the engine offscreen and writes the result as a PNG. /// - /// This drives `NecoSaverEngine` rather than the view, so `--cats` and - /// `--speed` can override a setting for one render without writing to the - /// preferences the installed saver reads. + /// This drives `NecoSaverEngine` rather than the view, so `--cats`, `--speed` + /// and `--wallpaper` can override a setting for one render without writing to + /// the preferences the installed saver reads. private static func snapshot(to path: String, options: Options) { // Enough of AppKit to make NSColor usable. _ = NSApplication.shared @@ -116,11 +118,19 @@ enum PreviewApp { if let speed = options.speed { settings.speed = speed } + if let wallpaper = options.wallpaper { + settings.wallpaperEnabled = wallpaper + } let size = options.size let rect = NSRect(origin: .zero, size: size) let engine = NecoSaverEngine(settings: settings) engine.reset(bounds: rect) + // The view does this for itself; offscreen there is no window to ask which + // display we are on, so the main one's picture stands in. + if settings.wallpaperEnabled { + engine.wallpaper = Wallpaper.current(for: nil) + } for _ in 0 ..< options.frames { engine.step() } @@ -203,6 +213,7 @@ private struct Options { var isPreview = false var cats: Int? var speed: Double? + var wallpaper: Bool? init(_ arguments: some Sequence) throws { var explicitSize = false @@ -213,7 +224,7 @@ private struct Options { showOptions = true case "--preview": isPreview = true - case "--snapshot", "--frames", "--size", "--cats", "--speed": + case "--snapshot", "--frames", "--size", "--cats", "--speed", "--wallpaper": let raw = try Self.value(after: argument, from: &iterator) try set(argument, to: raw, explicitSize: &explicitSize) default: @@ -247,6 +258,11 @@ private struct Options { case "--size": size = try Self.parseSize(raw) explicitSize = true + case "--wallpaper": + guard let value = ["on": true, "off": false][raw] else { + throw OptionError("\(flag) expects on or off, got \(raw)") + } + wallpaper = value default: guard let value = Double(raw), value > 0 else { throw OptionError("\(flag) expects a positive number, got \(raw)") diff --git a/README.md b/README.md index ebfa351..109ee07 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,19 @@ random point instead of the pointer. | Cats | 1 | 1–8 | Cats on screen. Each one wanders on its own. | | Size | 64 pt | 32–192 pt | Sprite scale. The oneko bitmaps are 32×32, so 64 pt is the 2× cat Neco itself draws. Walking speed and the size of the mess scale with it. | | Speed | 1.00× | 0.2–3.0 | | -| Background | 0% | 0–100% | Grey level behind the cat. Paw prints and scratch marks flip to the opposite end so they stay visible at either extreme. | +| Background | 0% | 0–100% | Grey level behind the cat, when no desktop picture is drawn. | +| Desktop picture | on | | Use the display's own wallpaper as the backdrop, laid out the way the desktop lays it out — Fill Screen, Fit to Screen, Stretch or Centre, fill colour and all. Falls back to the grey when there is no still picture to read. | | Paw prints | on | | Dropped while running; fade out after 12 s. | | Scratch marks | on | | Dropped while the cat works its claws; fade out after 20 s. | +Paw prints and scratch marks are inked light or dark against whatever the backdrop +turns out to be, so they stay visible over a black screen and a bright wallpaper +alike. + +Note that this is the wallpaper *picture*, not the live desktop: a screen saver is +a window covering the screen at `CGShieldingWindowLevel()`, so nothing below it can +show through. Windows, icons and the menu bar are not part of it. + Settings are stored per user through `ScreenSaverDefaults` under `jp.winebarrel.NecoSaver`. @@ -53,6 +62,7 @@ NecoSaverPreview --snapshot out.png --frames 900 --size 1920x1080 NecoSaverPreview --snapshot out.png --preview # as the System Settings thumbnail NecoSaverPreview --snapshot out.png --cats 5 # override the saved cat count NecoSaverPreview --snapshot out.png --speed 4 # override the saved speed +NecoSaverPreview --snapshot out.png --wallpaper off # ... and the backdrop NecoSaverPreview --snapshot out.png --options # render the Options sheet instead ``` diff --git a/Sources/ConfigureSheetController.swift b/Sources/ConfigureSheetController.swift index fd2c087..ebea5e0 100644 --- a/Sources/ConfigureSheetController.swift +++ b/Sources/ConfigureSheetController.swift @@ -91,6 +91,8 @@ final class ConfigureSheetController: NSObject { makeRow(title: "Background:", control: makeSlider(range: settings.backgroundRange), describe: percent, read: { $0.background }, write: { $0.background = $1 }), + makeCheckboxRow(title: "Desktop picture", read: { $0.wallpaperEnabled }, + write: { $0.wallpaperEnabled = $1 }), makeCheckboxRow(title: "Paw prints", read: { $0.pawsEnabled }, write: { $0.pawsEnabled = $1 }), makeCheckboxRow(title: "Scratch marks", read: { $0.scratchEnabled }, diff --git a/Sources/NecoSaverEngine.swift b/Sources/NecoSaverEngine.swift index 77fe3a6..72b9f08 100644 --- a/Sources/NecoSaverEngine.swift +++ b/Sources/NecoSaverEngine.swift @@ -8,6 +8,14 @@ import AppKit @MainActor final class NecoSaverEngine { private(set) var settings: NecoSaverSettings + + /// The desktop picture to sit the cats on. Supplied by the host rather than + /// read here: the wallpaper is per display, and only the view knows which + /// display it landed on. Left nil, the backdrop is `settings.background`. + var wallpaper: Wallpaper? { + didSet { configureLitter() } + } + private var bounds: CGRect = .zero private var nekos: [Neko] = [] private let litter = LitterField() @@ -64,6 +72,9 @@ final class NecoSaverEngine { neko.pos = NSPoint(x: neko.pos.x * sx, y: neko.pos.y * sy) } litter.clear() + // A new aspect ratio crops the wallpaper differently, so the ink is worth + // deciding again. + configureLitter() } /// Advances one 60fps tick — the rate the oneko idle chain was written against, @@ -75,10 +86,14 @@ final class NecoSaverEngine { litter.update(nekos: nekos) } - /// Background, then the mess, then the cats on top of their own mess. + /// Backdrop, then the mess, then the cats on top of their own mess. func draw(in rect: CGRect, context: CGContext) { - context.setFillColor(NSColor(calibratedWhite: settings.background, alpha: 1).cgColor) - context.fill(rect) + if let backdrop { + backdrop.draw(in: rect, context: context) + } else { + context.setFillColor(NSColor(calibratedWhite: settings.background, alpha: 1).cgColor) + context.fill(rect) + } litter.draw(in: context) @@ -97,12 +112,18 @@ final class NecoSaverEngine { Tuning(scale: settings.size, speedFactor: settings.speed) } + /// The wallpaper, if one was supplied and wanted. + private var backdrop: Wallpaper? { + settings.wallpaperEnabled ? wallpaper : nil + } + private func configureLitter() { litter.pawsEnabled = settings.pawsEnabled litter.scratchEnabled = settings.scratchEnabled litter.unit = tuning.unit - // Dark marks vanish on a dark background and vice versa, so the ink takes - // the opposite end from whatever the floor is. - litter.ink = NSColor(calibratedWhite: settings.background < 0.5 ? 0.9 : 0.1, alpha: 1) + // Dark marks vanish on a dark backdrop and vice versa, so the ink takes the + // opposite end from whatever the floor turns out to be. + let floor = backdrop.map { $0.brightness(in: bounds) } ?? CGFloat(settings.background) + litter.ink = NSColor(calibratedWhite: floor < 0.5 ? 0.9 : 0.1, alpha: 1) } } diff --git a/Sources/NecoSaverSettings.swift b/Sources/NecoSaverSettings.swift index 7038ccc..d2c312f 100644 --- a/Sources/NecoSaverSettings.swift +++ b/Sources/NecoSaverSettings.swift @@ -18,7 +18,11 @@ struct NecoSaverSettings: Equatable { var speed: Double /// Background grey level, 0 = black ... 1 = white. The cat is black ink on a /// white body, so it reads at either end; the litter marks flip to suit. + /// Ignored while `wallpaperEnabled` finds a picture to draw. var background: Double + /// Draw the display's desktop picture instead of the flat grey. Falls back to + /// the grey when there is no still picture to read — a video wallpaper, say. + var wallpaperEnabled: Bool var pawsEnabled: Bool var scratchEnabled: Bool @@ -27,6 +31,7 @@ struct NecoSaverSettings: Equatable { size: 2.0, speed: 1.0, background: 0.0, + wallpaperEnabled: true, pawsEnabled: true, scratchEnabled: true ) @@ -41,6 +46,7 @@ struct NecoSaverSettings: Equatable { static let size = "size" static let speed = "speed" static let background = "background" + static let wallpaperEnabled = "wallpaperEnabled" static let pawsEnabled = "pawsEnabled" static let scratchEnabled = "scratchEnabled" } @@ -52,6 +58,7 @@ struct NecoSaverSettings: Equatable { Key.size: standard.size, Key.speed: standard.speed, Key.background: standard.background, + Key.wallpaperEnabled: standard.wallpaperEnabled, Key.pawsEnabled: standard.pawsEnabled, Key.scratchEnabled: standard.scratchEnabled, ]) @@ -67,6 +74,7 @@ struct NecoSaverSettings: Equatable { size: sizeRange.clamping(store.double(forKey: Key.size)), speed: speedRange.clamping(store.double(forKey: Key.speed)), background: backgroundRange.clamping(store.double(forKey: Key.background)), + wallpaperEnabled: store.bool(forKey: Key.wallpaperEnabled), pawsEnabled: store.bool(forKey: Key.pawsEnabled), scratchEnabled: store.bool(forKey: Key.scratchEnabled) ) @@ -87,6 +95,7 @@ struct NecoSaverSettings: Equatable { store.set(size, forKey: Key.size) store.set(speed, forKey: Key.speed) store.set(background, forKey: Key.background) + store.set(wallpaperEnabled, forKey: Key.wallpaperEnabled) store.set(pawsEnabled, forKey: Key.pawsEnabled) store.set(scratchEnabled, forKey: Key.scratchEnabled) store.synchronize() diff --git a/Sources/NecoSaverView.swift b/Sources/NecoSaverView.swift index 932913f..0e637e2 100644 --- a/Sources/NecoSaverView.swift +++ b/Sources/NecoSaverView.swift @@ -18,6 +18,7 @@ final class NecoSaverView: ScreenSaverView { // The oneko idle chain counts in 60fps ticks, so ask for that rate. animationTimeInterval = 1.0 / 60.0 engine.reset(bounds: bounds) + loadWallpaper() } @available(*, unavailable) @@ -33,6 +34,9 @@ final class NecoSaverView: ScreenSaverView { super.startAnimation() // Picks up any change made through the configuration sheet. engine.apply(Self.settings(isPreview: isPreview)) + // By now the view is in a window, so this is the wallpaper of the display + // it actually covers rather than the main one's. + loadWallpaper() } override func animateOneFrame() { @@ -60,6 +64,7 @@ final class NecoSaverView: ScreenSaverView { guard let self else { return } if saved { engine.apply(Self.settings(isPreview: isPreview)) + loadWallpaper() } sheetController = nil } @@ -67,6 +72,12 @@ final class NecoSaverView: ScreenSaverView { return controller.window } + /// Reads the desktop picture only when it is going to be drawn — decoding one + /// costs several megabytes that a grey backdrop has no use for. + private func loadWallpaper() { + engine.wallpaper = engine.settings.wallpaperEnabled ? Wallpaper.current(for: window?.screen) : nil + } + private static func settings(isPreview: Bool) -> NecoSaverSettings { let settings = NecoSaverSettings.load() return isPreview ? settings.previewAdjusted : settings diff --git a/Sources/Wallpaper.swift b/Sources/Wallpaper.swift new file mode 100644 index 0000000..b7b06f1 --- /dev/null +++ b/Sources/Wallpaper.swift @@ -0,0 +1,104 @@ +import AppKit + +/// The desktop picture of one display, laid out the way the system lays it out. +/// +/// Reading the file is allowed: `.saver` bundles run inside the sandbox of +/// `legacyScreenSaver.appex`, which holds a read-only temporary exception for `/` +/// (plus `com.apple.security.assets.pictures.read-only`), so a picture anywhere on +/// disk opens without a security-scoped bookmark. Everything here still degrades +/// to `nil` rather than throwing — a video wallpaper, for one, has no still frame +/// to load — and the engine falls back to a plain grey. +@MainActor +struct Wallpaper { + private let image: NSImage + private let scaling: NSImageScaling + private let allowsClipping: Bool + private let fillColor: NSColor + + /// Reads the picture currently set on `screen`, or on the main display when + /// the caller has no window to ask. + static func current(for screen: NSScreen?) -> Wallpaper? { + guard let screen = screen ?? NSScreen.main, + let url = NSWorkspace.shared.desktopImageURL(for: screen), + let image = NSImage(contentsOf: url) else { return nil } + + let options = NSWorkspace.shared.desktopImageOptions(for: screen) ?? [:] + let raw = options[.imageScaling] as? UInt ?? UInt(NSImageScaling.scaleProportionallyUpOrDown.rawValue) + return Wallpaper( + image: image, + scaling: NSImageScaling(rawValue: raw) ?? .scaleProportionallyUpOrDown, + allowsClipping: options[.allowClipping] as? Bool ?? true, + fillColor: options[.fillColor] as? NSColor ?? .black + ) + } + + func draw(in rect: CGRect, context: CGContext) { + // Painted first either way: in Fit and Center the picture leaves a margin, + // and this is the colour the desktop shows there. + context.setFillColor(fillColor.cgColor) + context.fill(rect) + + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return } + context.saveGState() + defer { context.restoreGState() } + context.clip(to: rect) + context.interpolationQuality = .high + context.draw(cgImage, in: destination(for: cgImage, in: rect)) + } + + /// Where the picture lands, following the display's own scaling mode. + private func destination(for cgImage: CGImage, in rect: CGRect) -> CGRect { + let natural = CGSize(width: cgImage.width, height: cgImage.height) + guard natural.width > 0, natural.height > 0 else { return rect } + + let size: CGSize + switch scaling { + case .scaleAxesIndependently: // Stretch to Fill Screen + size = rect.size + case .scaleNone: // Center + size = natural + default: // Fill Screen (clipping) or Fit to Screen + var factor = allowsClipping + ? max(rect.width / natural.width, rect.height / natural.height) + : min(rect.width / natural.width, rect.height / natural.height) + if scaling == .scaleProportionallyDown { + factor = min(factor, 1) // shrink an oversized picture, never enlarge + } + size = CGSize(width: natural.width * factor, height: natural.height * factor) + } + + return CGRect(x: rect.midX - size.width / 2, y: rect.midY - size.height / 2, + width: size.width, height: size.height) + } + + /// Mean brightness of the backdrop as it would appear in `rect`, 0 ... 1. + /// + /// Rendered rather than estimated from the picture alone, so a letterboxed + /// picture's fill colour counts for as much of the answer as it does of the + /// screen. The engine uses it to pick a litter ink that stays visible. + func brightness(in rect: CGRect) -> CGFloat { + guard rect.width > 0, rect.height > 0 else { return 0 } + let width = 16 + let height = max(1, Int((CGFloat(width) * rect.height / rect.width).rounded())) + guard let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, + bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ), + let pixels = context.data else { return 0 } + + // Map rect onto the thumbnail: translate first, then scale. + context.scaleBy(x: CGFloat(width) / rect.width, y: CGFloat(height) / rect.height) + context.translateBy(x: -rect.minX, y: -rect.minY) + draw(in: rect, context: context) + + let bytes = pixels.bindMemory(to: UInt8.self, capacity: width * height * 4) + var total = 0.0 + for i in 0 ..< (width * height) { + let o = i * 4 + // Rec. 601 luma, the same weighting NSColor.brightnessComponent uses. + total += 0.299 * Double(bytes[o]) + 0.587 * Double(bytes[o + 1]) + 0.114 * Double(bytes[o + 2]) + } + return CGFloat(total / Double(width * height) / 255.0) + } +}