Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions NecoSaver.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -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 */; };
Expand All @@ -39,6 +41,7 @@
CE0000000000000000000006 /* NecoSaverEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NecoSaverEngine.swift; sourceTree = "<group>"; };
CE0000000000000000000007 /* ConfigureSheetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigureSheetController.swift; sourceTree = "<group>"; };
CE0000000000000000000008 /* NecoSaverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NecoSaverView.swift; sourceTree = "<group>"; };
CE0000000000000000000010 /* Wallpaper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Wallpaper.swift; sourceTree = "<group>"; };
CE0000000000000000000009 /* PreviewApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewApp.swift; sourceTree = "<group>"; };
CE0000000000000000000011 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
CE0000000000000000000012 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
};
Expand All @@ -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;
Expand Down
26 changes: 21 additions & 5 deletions Preview/PreviewApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
Expand Down Expand Up @@ -203,6 +213,7 @@ private struct Options {
var isPreview = false
var cats: Int?
var speed: Double?
var wallpaper: Bool?

init(_ arguments: some Sequence<String>) throws {
var explicitSize = false
Expand All @@ -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:
Expand Down Expand Up @@ -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)")
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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
```

Expand Down
2 changes: 2 additions & 0 deletions Sources/ConfigureSheetController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
33 changes: 27 additions & 6 deletions Sources/NecoSaverEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand All @@ -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)
}
}
9 changes: 9 additions & 0 deletions Sources/NecoSaverSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -27,6 +31,7 @@ struct NecoSaverSettings: Equatable {
size: 2.0,
speed: 1.0,
background: 0.0,
wallpaperEnabled: true,
pawsEnabled: true,
scratchEnabled: true
)
Expand All @@ -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"
}
Expand All @@ -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,
])
Expand All @@ -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)
)
Expand All @@ -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()
Expand Down
11 changes: 11 additions & 0 deletions Sources/NecoSaverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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() {
Expand Down Expand Up @@ -60,13 +64,20 @@ final class NecoSaverView: ScreenSaverView {
guard let self else { return }
if saved {
engine.apply(Self.settings(isPreview: isPreview))
loadWallpaper()
}
sheetController = nil
}
sheetController = controller
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
Expand Down
Loading
Loading