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) + } +}