diff --git a/Preview/PreviewApp.swift b/Preview/PreviewApp.swift index bfd1ae0..69026b1 100644 --- a/Preview/PreviewApp.swift +++ b/Preview/PreviewApp.swift @@ -5,7 +5,7 @@ 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`, `--speed` and `--wallpaper` apply to a +/// instead, which is what lets `--cats`, `--speed` and `--picture` apply to a /// single render. /// /// NecoSaverPreview # live window @@ -15,7 +15,8 @@ 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 --picture pic.png # override the backdrop +/// NecoSaverPreview --snapshot out.png --grey # ... or leave the grey showing /// NecoSaverPreview --snapshot out.png --options # render the Options sheet instead /// /// `--size` is in points; snapshots are written at 2x, as on a Retina display. @@ -102,7 +103,7 @@ enum PreviewApp { /// Steps the engine offscreen and writes the result as a PNG. /// /// This drives `NecoSaverEngine` rather than the view, so `--cats`, `--speed` - /// and `--wallpaper` can override a setting for one render without writing to + /// and `--picture` 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. @@ -118,8 +119,12 @@ enum PreviewApp { if let speed = options.speed { settings.speed = speed } - if let wallpaper = options.wallpaper { - settings.wallpaperEnabled = wallpaper + settings.seedPicturePath(with: Wallpaper.desktopPicturePath(for: nil) ?? "") + if let picture = options.picture { + settings.picturePath = picture + } + if options.grey { + settings.pictureEnabled = false } let size = options.size @@ -127,10 +132,8 @@ enum PreviewApp { 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) - } + // display we are on, so the main one answers for it. + engine.wallpaper = Wallpaper.matching(settings, on: nil) for _ in 0 ..< options.frames { engine.step() } @@ -213,7 +216,8 @@ private struct Options { var isPreview = false var cats: Int? var speed: Double? - var wallpaper: Bool? + var picture: String? + var grey = false init(_ arguments: some Sequence) throws { var explicitSize = false @@ -224,7 +228,9 @@ private struct Options { showOptions = true case "--preview": isPreview = true - case "--snapshot", "--frames", "--size", "--cats", "--speed", "--wallpaper": + case "--grey": + grey = true + case "--snapshot", "--frames", "--size", "--cats", "--speed", "--picture": let raw = try Self.value(after: argument, from: &iterator) try set(argument, to: raw, explicitSize: &explicitSize) default: @@ -258,11 +264,8 @@ 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 + case "--picture": + picture = raw 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 109ee07..3c4b1b3 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,35 @@ 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, 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. | +| Picture | on, your wallpaper | | The picture the cats walk on, by path. Type one, or press `Choose…`. Turn it off, or empty the field, for the grey. | +| Grey level | 0% | 0–100% | The colour behind the cats when no picture is drawn, either by choice or because the path could not be 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. | +The picture starts out as whatever the desktop's wallpaper is on first run, and is +an ordinary saved setting from then on: changing your wallpaper later leaves the +saver alone. When the two happen to be the same file the desktop's own layout +applies — Fill Screen, Fit to Screen, Stretch or Centre, fill colour and all — and +any other picture is laid out as Fill Screen. + 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 +turns out to be, so they stay visible over a black screen and a bright picture 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. +### Dynamic and video wallpapers + +The path is seeded from `NSWorkspace.desktopImageURL(for:)`, which predates +WallpaperKit and only knows about still pictures. With a dynamic or video wallpaper +set — anything from the `com.apple.NeptuneOneExtension` provider — it answers with +a placeholder, `/System/Library/CoreServices/DefaultDesktop.heic`, and no layout +options. No public API reports those wallpapers, so if the field starts out naming +that file, point it at a picture you actually want. + +### Not the live desktop + +This is the wallpaper *picture*. 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, and no amount of transparency changes that. Settings are stored per user through `ScreenSaverDefaults` under `jp.winebarrel.NecoSaver`. @@ -62,7 +79,8 @@ 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 --picture pic.png # override the backdrop +NecoSaverPreview --snapshot out.png --grey # ... or leave the grey showing NecoSaverPreview --snapshot out.png --options # render the Options sheet instead ``` diff --git a/Sources/ConfigureSheetController.swift b/Sources/ConfigureSheetController.swift index ebea5e0..4790dad 100644 --- a/Sources/ConfigureSheetController.swift +++ b/Sources/ConfigureSheetController.swift @@ -1,4 +1,5 @@ import AppKit +import UniformTypeIdentifiers /// The "Options…" sheet, built in code. /// @@ -23,6 +24,9 @@ final class ConfigureSheetController: NSObject { private var settings = NecoSaverSettings.load() private let onDismiss: (_ saved: Bool) -> Void private var rows: [Row] = [] + private let pictureCheckbox = NSButton(checkboxWithTitle: "Picture:", target: nil, action: nil) + private let pictureField = NSTextField() + private let pictureButton = NSButton(title: "Choose…", target: nil, action: nil) init(onDismiss: @escaping (_ saved: Bool) -> Void) { self.onDismiss = onDismiss @@ -66,6 +70,9 @@ final class ConfigureSheetController: NSObject { let grid = NSGridView(views: makeRows()) grid.translatesAutoresizingMaskIntoConstraints = false grid.column(at: 0).xPlacement = .trailing + // The picture row's Choose… button shares this column with the narrow value + // labels; right-aligning it keeps the numbers flush with the button's edge. + grid.column(at: 2).xPlacement = .trailing grid.rowSpacing = 10 grid.columnSpacing = 10 return grid @@ -88,11 +95,12 @@ final class ConfigureSheetController: NSObject { makeRow(title: "Speed:", control: makeSlider(range: settings.speedRange), describe: multiplier, read: { $0.speed }, write: { $0.speed = $1 }), - makeRow(title: "Background:", control: makeSlider(range: settings.backgroundRange), + makePictureRow(), + // Named for what it does. It is the whole backdrop only when no + // picture is drawn, so "Background" would overstate it. + makeRow(title: "Grey level:", 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 }, @@ -100,6 +108,25 @@ final class ConfigureSheetController: NSObject { ] } + /// The picture chooser: the checkbox stands in for the row's label, the field + /// holds the path, and the button fills it in from a file panel. Outside the + /// `Row` machinery, which speaks in doubles. + private func makePictureRow() -> [NSView] { + pictureCheckbox.target = self + pictureCheckbox.action = #selector(pictureEnabledChanged) + + pictureField.delegate = self + pictureField.placeholderString = "Path to a picture" + pictureField.lineBreakMode = .byTruncatingHead + pictureField.translatesAutoresizingMaskIntoConstraints = false + pictureField.widthAnchor.constraint(equalToConstant: 220).isActive = true + + pictureButton.target = self + pictureButton.action = #selector(choosePicture) + + return [pictureCheckbox, pictureField, pictureButton] + } + private func makeButtons() -> NSStackView { let restore = NSButton(title: "Restore Defaults", target: self, action: #selector(restoreDefaults)) @@ -189,6 +216,10 @@ final class ConfigureSheetController: NSObject { row.control.doubleValue = value row.valueLabel?.stringValue = row.describe(value) } + pictureCheckbox.state = settings.pictureEnabled ? .on : .off + pictureField.stringValue = settings.picturePath + pictureField.isEnabled = settings.pictureEnabled + pictureButton.isEnabled = settings.pictureEnabled } // MARK: - Actions @@ -199,6 +230,29 @@ final class ConfigureSheetController: NSObject { refresh() } + @objc private func pictureEnabledChanged() { + settings.pictureEnabled = pictureCheckbox.state == .on + refresh() + } + + @objc private func choosePicture() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.image] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.prompt = "Choose" + panel.message = "Pick a picture for the cats to walk on." + if !settings.picturePath.isEmpty { + panel.directoryURL = URL(fileURLWithPath: settings.picturePath).deletingLastPathComponent() + } + + panel.beginSheetModal(for: window) { [weak self] response in + guard let self, response == .OK, let url = panel.url else { return } + settings.picturePath = url.path + refresh() + } + } + @objc private func restoreDefaults() { settings = .standard refresh() @@ -226,3 +280,12 @@ final class ConfigureSheetController: NSObject { onDismiss(saved) } } + +/// Keeps the path in step with the field as it is typed, so OK saves what is on +/// screen whether or not the field was committed with Return first. +extension ConfigureSheetController: NSTextFieldDelegate { + func controlTextDidChange(_ notification: Notification) { + guard notification.object as AnyObject? === pictureField else { return } + settings.picturePath = pictureField.stringValue + } +} diff --git a/Sources/NecoSaverEngine.swift b/Sources/NecoSaverEngine.swift index 72b9f08..1995df4 100644 --- a/Sources/NecoSaverEngine.swift +++ b/Sources/NecoSaverEngine.swift @@ -112,9 +112,10 @@ final class NecoSaverEngine { Tuning(scale: settings.size, speedFactor: settings.speed) } - /// The wallpaper, if one was supplied and wanted. + /// The picture to sit the cats on, if one was supplied. Nil leaves the grey: + /// either it was asked for, or nothing could be read. private var backdrop: Wallpaper? { - settings.wallpaperEnabled ? wallpaper : nil + settings.pictureEnabled ? wallpaper : nil } private func configureLitter() { diff --git a/Sources/NecoSaverSettings.swift b/Sources/NecoSaverSettings.swift index d2c312f..c46587c 100644 --- a/Sources/NecoSaverSettings.swift +++ b/Sources/NecoSaverSettings.swift @@ -17,12 +17,15 @@ struct NecoSaverSettings: Equatable { var size: Double 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. + /// white body, so it reads at either end; the litter marks flip to suit. Used + /// whenever no picture is drawn, either by choice or because none could be read. 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 + /// Draw `picturePath` behind the cats rather than the flat grey. + var pictureEnabled: Bool + /// The picture to draw. Seeded once with the desktop's wallpaper (see + /// `seedPicturePath`), an ordinary saved value from then on; empty, or naming + /// a file that cannot be read, leaves the grey showing. + var picturePath: String var pawsEnabled: Bool var scratchEnabled: Bool @@ -31,7 +34,8 @@ struct NecoSaverSettings: Equatable { size: 2.0, speed: 1.0, background: 0.0, - wallpaperEnabled: true, + pictureEnabled: true, + picturePath: "", pawsEnabled: true, scratchEnabled: true ) @@ -46,7 +50,8 @@ struct NecoSaverSettings: Equatable { static let size = "size" static let speed = "speed" static let background = "background" - static let wallpaperEnabled = "wallpaperEnabled" + static let pictureEnabled = "pictureEnabled" + static let picturePath = "picturePath" static let pawsEnabled = "pawsEnabled" static let scratchEnabled = "scratchEnabled" } @@ -58,7 +63,8 @@ struct NecoSaverSettings: Equatable { Key.size: standard.size, Key.speed: standard.speed, Key.background: standard.background, - Key.wallpaperEnabled: standard.wallpaperEnabled, + Key.pictureEnabled: standard.pictureEnabled, + Key.picturePath: standard.picturePath, Key.pawsEnabled: standard.pawsEnabled, Key.scratchEnabled: standard.scratchEnabled, ]) @@ -74,7 +80,8 @@ 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), + pictureEnabled: store.bool(forKey: Key.pictureEnabled), + picturePath: store.string(forKey: Key.picturePath) ?? standard.picturePath, pawsEnabled: store.bool(forKey: Key.pawsEnabled), scratchEnabled: store.bool(forKey: Key.scratchEnabled) ) @@ -89,13 +96,25 @@ struct NecoSaverSettings: Equatable { return adjusted } + /// Fills in the picture on first run, when nothing has been chosen yet: the + /// desktop's own wallpaper stands in, and is written down. From then on it is + /// an ordinary saved value — changing the wallpaper later leaves the backdrop + /// alone, and clearing the field leaves the grey. + mutating func seedPicturePath(with path: String) { + guard picturePath.isEmpty, !path.isEmpty, let store = Self.store else { return } + picturePath = path + store.set(path, forKey: Key.picturePath) + store.synchronize() + } + func save() { guard let store = Self.store else { return } store.set(catCount, forKey: Key.catCount) 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(pictureEnabled, forKey: Key.pictureEnabled) + store.set(picturePath, forKey: Key.picturePath) 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 0e637e2..e7bd530 100644 --- a/Sources/NecoSaverView.swift +++ b/Sources/NecoSaverView.swift @@ -13,7 +13,10 @@ final class NecoSaverView: ScreenSaverView { private var sheetController: ConfigureSheetController? override init?(frame: NSRect, isPreview: Bool) { - engine = NecoSaverEngine(settings: Self.settings(isPreview: isPreview)) + // Seeding the picture wants a screen, and there is no window yet; the main + // display stands in until startAnimation, which is where the real one shows + // up. Preview thumbnails never get further than this. + engine = NecoSaverEngine(settings: Self.settings(isPreview: isPreview, on: nil)) super.init(frame: frame, isPreview: isPreview) // The oneko idle chain counts in 60fps ticks, so ask for that rate. animationTimeInterval = 1.0 / 60.0 @@ -32,10 +35,10 @@ final class NecoSaverView: ScreenSaverView { override func startAnimation() { 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. + // Picks up any change made through the configuration sheet. By now the view + // is in a window, so the display it actually covers is the one whose + // wallpaper seeds an unset picture. + engine.apply(settings()) loadWallpaper() } @@ -63,7 +66,7 @@ final class NecoSaverView: ScreenSaverView { let controller = ConfigureSheetController { [weak self] saved in guard let self else { return } if saved { - engine.apply(Self.settings(isPreview: isPreview)) + engine.apply(settings()) loadWallpaper() } sheetController = nil @@ -72,14 +75,19 @@ 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. + /// Reads a picture only when one 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 + engine.wallpaper = Wallpaper.matching(engine.settings, on: window?.screen) } - private static func settings(isPreview: Bool) -> NecoSaverSettings { - let settings = NecoSaverSettings.load() + private func settings() -> NecoSaverSettings { + Self.settings(isPreview: isPreview, on: window?.screen) + } + + private static func settings(isPreview: Bool, on screen: NSScreen?) -> NecoSaverSettings { + var settings = NecoSaverSettings.load() + settings.seedPicturePath(with: Wallpaper.desktopPicturePath(for: screen) ?? "") return isPreview ? settings.previewAdjusted : settings } } diff --git a/Sources/Wallpaper.swift b/Sources/Wallpaper.swift index b7b06f1..691f5ab 100644 --- a/Sources/Wallpaper.swift +++ b/Sources/Wallpaper.swift @@ -1,13 +1,13 @@ import AppKit -/// The desktop picture of one display, laid out the way the system lays it out. +/// The picture behind the cats, laid out to fill the view. /// -/// Reading the file is allowed: `.saver` bundles run inside the sandbox of +/// A plain path is enough to read one: `.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. +/// disk opens with no bookmark and no panel. Everything here degrades to `nil` +/// rather than throwing — a path can name a file that has moved, or a video — and +/// the engine falls back to a plain grey. @MainActor struct Wallpaper { private let image: NSImage @@ -15,14 +15,24 @@ struct Wallpaper { 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 } + /// The picture the settings ask for, or nil to leave the grey showing. + static func matching(_ settings: NecoSaverSettings, on screen: NSScreen?) -> Wallpaper? { + guard settings.pictureEnabled else { return nil } + return picture(atPath: settings.picturePath, on: screen) + } - let options = NSWorkspace.shared.desktopImageOptions(for: screen) ?? [:] + /// Loads the picture at `path`. + /// + /// Laid out as Fill Screen, except when it happens to be the very picture the + /// desktop is showing — then the desktop's own scaling, clipping and fill + /// colour apply, and the backdrop matches what was on screen a moment earlier. + /// That is the usual case, since the path is seeded with the wallpaper. + static func picture(atPath path: String, on screen: NSScreen?) -> Wallpaper? { + guard !path.isEmpty else { return nil } + let url = URL(fileURLWithPath: path) + guard let image = NSImage(contentsOf: url) else { return nil } + + let options = desktopOptions(for: url, on: screen) let raw = options[.imageScaling] as? UInt ?? UInt(NSImageScaling.scaleProportionallyUpOrDown.rawValue) return Wallpaper( image: image, @@ -32,6 +42,29 @@ struct Wallpaper { ) } + /// The path of the wallpaper on `screen`, or on the main display when the + /// caller has no window to ask. Used to seed the setting and to fill the + /// Options sheet's field. + /// + /// `NSWorkspace`'s wallpaper API predates WallpaperKit and only reports still + /// pictures. With a dynamic or video wallpaper set it hands back a placeholder + /// (`/System/Library/CoreServices/DefaultDesktop.heic`), which is a picture the + /// user never chose — hence a field they can correct. + static func desktopPicturePath(for screen: NSScreen?) -> String? { + guard let screen = screen ?? NSScreen.main else { return nil } + return NSWorkspace.shared.desktopImageURL(for: screen)?.path + } + + /// How the desktop lays `url` out, when `url` is what the desktop is showing. + private static func desktopOptions( + for url: URL, on screen: NSScreen? + ) -> [NSWorkspace.DesktopImageOptionKey: Any] { + guard let screen = screen ?? NSScreen.main, + let current = NSWorkspace.shared.desktopImageURL(for: screen), + current.standardizedFileURL == url.standardizedFileURL else { return [:] } + return NSWorkspace.shared.desktopImageOptions(for: screen) ?? [:] + } + 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.