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
35 changes: 19 additions & 16 deletions Preview/PreviewApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -118,19 +119,21 @@ 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
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)
}
// 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()
}
Expand Down Expand Up @@ -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<String>) throws {
var explicitSize = false
Expand All @@ -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:
Expand Down Expand Up @@ -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)")
Expand Down
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
```

Expand Down
69 changes: 66 additions & 3 deletions Sources/ConfigureSheetController.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import UniformTypeIdentifiers

/// The "Options…" sheet, built in code.
///
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -88,18 +95,38 @@ 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 },
write: { $0.scratchEnabled = $1 }),
]
}

/// 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))
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
}
5 changes: 3 additions & 2 deletions Sources/NecoSaverEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
39 changes: 29 additions & 10 deletions Sources/NecoSaverSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
)
Expand All @@ -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"
}
Expand All @@ -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,
])
Expand All @@ -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)
)
Expand All @@ -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()
Expand Down
Loading
Loading