From 12711a9b2d9fbb90a7ced13cd7e07a83812e8a34 Mon Sep 17 00:00:00 2001 From: winebarrel Date: Mon, 10 Aug 2026 10:31:13 +0900 Subject: [PATCH] Implement the screen saver Ports Neco's cat to a .saver bundle, following the layout of winebarrel/Macstify: a `Sources/` target that builds NecoSaver.saver and a `Preview/` host app for development, plus the Makefile and CI that go with them. Sprites.swift, the sprite expansion, the cat's brain and the litter marks are carried over from Neco. Two changes make them work under ScreenSaver.framework: - Neko reads an injected `field` rect instead of NSScreen. A saver gets one view per display and may only draw inside its own bounds. - There is no cursor to chase behind the shield window, so the cat always runs Neco's wander mode. Cursor tracking is gone rather than switchable. The two overlay NSPanels collapse into one ScreenSaverView that paints the background, the mess and the cats in order, and the 60fps Timer becomes animateOneFrame(). Settings live in ScreenSaverDefaults, not UserDefaults.standard, and are edited through a configure sheet built in code: cats, size, speed, background, paw prints, scratch marks. Marks and cat sprites scale together, and the litter ink flips to the opposite end of the greyscale from the background so it stays visible either way. --- .github/workflows/ci.yml | 56 +++ .gitignore | 5 + .swift-version | 1 + .swiftformat | 2 + .swiftlint.yml | 21 + Makefile | 80 +++ NecoSaver.xcodeproj/project.pbxproj | 470 ++++++++++++++++++ .../xcshareddata/xcschemes/NecoSaver.xcscheme | 76 +++ .../xcschemes/NecoSaverPreview.xcscheme | 78 +++ Preview/Info.plist | 24 + Preview/PreviewApp.swift | 292 +++++++++++ README.md | 86 +++- Sources/ConfigureSheetController.swift | 226 +++++++++ Sources/Info.plist | 26 + Sources/Litter.swift | 169 +++++++ Sources/NecoSaverEngine.swift | 108 ++++ Sources/NecoSaverSettings.swift | 100 ++++ Sources/NecoSaverView.swift | 74 +++ Sources/Neko.swift | 288 +++++++++++ Sources/SpriteCache.swift | 45 ++ Sources/Sprites.swift | 80 +++ renovate.json | 10 + 22 files changed, 2316 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .swift-version create mode 100644 .swiftformat create mode 100644 .swiftlint.yml create mode 100644 Makefile create mode 100644 NecoSaver.xcodeproj/project.pbxproj create mode 100644 NecoSaver.xcodeproj/xcshareddata/xcschemes/NecoSaver.xcscheme create mode 100644 NecoSaver.xcodeproj/xcshareddata/xcschemes/NecoSaverPreview.xcscheme create mode 100644 Preview/Info.plist create mode 100644 Preview/PreviewApp.swift create mode 100644 Sources/ConfigureSheetController.swift create mode 100644 Sources/Info.plist create mode 100644 Sources/Litter.swift create mode 100644 Sources/NecoSaverEngine.swift create mode 100644 Sources/NecoSaverSettings.swift create mode 100644 Sources/NecoSaverView.swift create mode 100644 Sources/Neko.swift create mode 100644 Sources/SpriteCache.swift create mode 100644 Sources/Sprites.swift create mode 100644 renovate.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0bd0461 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: Install SwiftLint + run: brew install swiftlint + - name: Run SwiftLint + run: swiftlint --strict + + format: + name: Format + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: Install SwiftFormat + run: brew install swiftformat + # Paths come first: --lint would otherwise swallow the one after it. + - name: Check formatting + run: swiftformat Sources Preview --lint + + build: + name: Build and Analyse + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: Install SwiftLint + run: brew install swiftlint + - name: Build and Analyse + run: | + set -o pipefail + for scheme in NecoSaver NecoSaverPreview; do + xcodebuild build analyze \ + -project NecoSaver.xcodeproj \ + -scheme "$scheme" \ + -destination 'generic/platform=macOS' \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + | tee -a build.log + done + - name: SwiftLint Analyse + run: swiftlint analyze --strict --compiler-log-path build.log diff --git a/.gitignore b/.gitignore index 52fe2f7..f74c8a5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ ## User settings xcuserdata/ +## Build output +build/ +DerivedData/ +dist/ + ## Obj-C/Swift specific *.hmap diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..e0ea36f --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.0 diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..e49f1af --- /dev/null +++ b/.swiftformat @@ -0,0 +1,2 @@ +--swiftversion 6.0 +--exclude Sources/Sprites.swift diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..77b9be4 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,21 @@ +excluded: + - Sources/Sprites.swift # generated + - build # xcodebuild output + +disabled_rules: + - line_length + - nesting + +# Require trailing commas to match SwiftFormat's trailingCommas rule. +trailing_comma: + mandatory_comma: true + +# Pixel/geometry code uses short names like x, y, dx, dy. +identifier_name: + min_length: + warning: 1 + error: 1 + +analyzer_rules: + - unused_declaration + - unused_import diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a17a6ae --- /dev/null +++ b/Makefile @@ -0,0 +1,80 @@ +PROJECT := NecoSaver.xcodeproj +SCHEME := NecoSaver +PREVIEW_SCHEME := NecoSaverPreview +CONFIGURATION := Release +DERIVED_DATA := build +PRODUCTS := $(DERIVED_DATA)/Build/Products/$(CONFIGURATION) +SAVER := $(PRODUCTS)/NecoSaver.saver +PREVIEW := $(PRODUCTS)/NecoSaverPreview.app/Contents/MacOS/NecoSaverPreview +INSTALL_DIR := $(HOME)/Library/Screen Savers +DIST_DIR := dist +DIST := $(DIST_DIR)/NecoSaver.zip +CHECKSUM := $(DIST_DIR)/checksum.txt +SUBMISSION := $(DERIVED_DATA)/submission.zip + +# codesign resolves this by prefix, and there is only one such identity. +CODESIGN_IDENTITY ?= Developer ID Application +NOTARY_PROFILE ?= necosaver + +XCODEBUILD := xcodebuild -project $(PROJECT) -configuration $(CONFIGURATION) -derivedDataPath $(DERIVED_DATA) + +.DEFAULT_GOAL := build + +.PHONY: build +build: + $(XCODEBUILD) -scheme $(SCHEME) build + +.PHONY: install +install: build + mkdir -p "$(INSTALL_DIR)" + rm -rf "$(INSTALL_DIR)/NecoSaver.saver" + cp -R $(SAVER) "$(INSTALL_DIR)/" + @echo + @echo 'Installed. macOS caches the loaded bundle, so if an older build is' + @echo 'still running: killall legacyScreenSaver, then reopen System Settings.' + +.PHONY: uninstall +uninstall: + rm -rf "$(INSTALL_DIR)/NecoSaver.saver" + +.PHONY: preview +preview: + $(XCODEBUILD) -scheme $(PREVIEW_SCHEME) build + $(PREVIEW) + +.PHONY: lint +lint: + swiftlint --strict + +.PHONY: format +format: + swiftformat Sources Preview + +# Signs with Developer ID rather than the ad-hoc signature a plain build +# produces, then notarizes. Gatekeeper rejects anything less once the bundle +# reaches another Mac and picks up a quarantine flag. Stapling attaches the +# ticket to the bundle so it validates without a network round trip. +.PHONY: release +release: + $(XCODEBUILD) -scheme $(SCHEME) \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="$(CODESIGN_IDENTITY)" \ + OTHER_CODE_SIGN_FLAGS="--timestamp" \ + build + codesign --verify --strict --verbose=2 $(SAVER) + rm -f $(SUBMISSION) + ditto -c -k --keepParent $(SAVER) $(SUBMISSION) + xcrun notarytool submit $(SUBMISSION) --keychain-profile "$(NOTARY_PROFILE)" --wait + xcrun stapler staple $(SAVER) + xcrun stapler validate $(SAVER) + mkdir -p $(DIST_DIR) + rm -f $(DIST) $(CHECKSUM) + ditto -c -k --keepParent $(SAVER) $(DIST) + cd $(DIST_DIR) && shasum -a 256 $(notdir $(DIST)) > $(notdir $(CHECKSUM)) + @echo + @echo "Ready to distribute:" + @cat $(CHECKSUM) + +.PHONY: clean +clean: + rm -rf $(DERIVED_DATA) dist diff --git a/NecoSaver.xcodeproj/project.pbxproj b/NecoSaver.xcodeproj/project.pbxproj new file mode 100644 index 0000000..642705c --- /dev/null +++ b/NecoSaver.xcodeproj/project.pbxproj @@ -0,0 +1,470 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + CE0000000000000000000101 /* Sprites.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000001 /* Sprites.swift */; }; + CE0000000000000000000102 /* SpriteCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000002 /* SpriteCache.swift */; }; + CE0000000000000000000103 /* Neko.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000003 /* Neko.swift */; }; + CE0000000000000000000104 /* Litter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000004 /* Litter.swift */; }; + CE0000000000000000000105 /* NecoSaverSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000005 /* NecoSaverSettings.swift */; }; + 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 */; }; + 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 */; }; + CE0000000000000000000114 /* Litter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000004 /* Litter.swift */; }; + CE0000000000000000000115 /* NecoSaverSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000005 /* NecoSaverSettings.swift */; }; + 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 */; }; + 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 */; }; + CE0000000000000000000133 /* ScreenSaver.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000031 /* ScreenSaver.framework */; }; + CE0000000000000000000134 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE0000000000000000000032 /* Cocoa.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + CE0000000000000000000001 /* Sprites.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sprites.swift; sourceTree = ""; }; + CE0000000000000000000002 /* SpriteCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpriteCache.swift; sourceTree = ""; }; + CE0000000000000000000003 /* Neko.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Neko.swift; sourceTree = ""; }; + CE0000000000000000000004 /* Litter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Litter.swift; sourceTree = ""; }; + CE0000000000000000000005 /* NecoSaverSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NecoSaverSettings.swift; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + CE0000000000000000000013 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + CE0000000000000000000031 /* ScreenSaver.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ScreenSaver.framework; path = System/Library/Frameworks/ScreenSaver.framework; sourceTree = SDKROOT; }; + CE0000000000000000000032 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = System/Library/Frameworks/Cocoa.framework; sourceTree = SDKROOT; }; + CE0000000000000000000041 /* NecoSaver.saver */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NecoSaver.saver; sourceTree = BUILT_PRODUCTS_DIR; }; + CE0000000000000000000042 /* NecoSaverPreview.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NecoSaverPreview.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + CE0000000000000000000201 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CE0000000000000000000131 /* ScreenSaver.framework in Frameworks */, + CE0000000000000000000132 /* Cocoa.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CE0000000000000000000202 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CE0000000000000000000133 /* ScreenSaver.framework in Frameworks */, + CE0000000000000000000134 /* Cocoa.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + CE0000000000000000000301 /* Sources */ = { + isa = PBXGroup; + children = ( + CE0000000000000000000001 /* Sprites.swift */, + CE0000000000000000000002 /* SpriteCache.swift */, + CE0000000000000000000003 /* Neko.swift */, + CE0000000000000000000004 /* Litter.swift */, + CE0000000000000000000005 /* NecoSaverSettings.swift */, + CE0000000000000000000006 /* NecoSaverEngine.swift */, + CE0000000000000000000007 /* ConfigureSheetController.swift */, + CE0000000000000000000008 /* NecoSaverView.swift */, + CE0000000000000000000011 /* Info.plist */, + ); + path = Sources; + sourceTree = ""; + }; + CE0000000000000000000303 /* Preview */ = { + isa = PBXGroup; + children = ( + CE0000000000000000000009 /* PreviewApp.swift */, + CE0000000000000000000012 /* Info.plist */, + ); + path = Preview; + sourceTree = ""; + }; + CE0000000000000000000304 /* Frameworks */ = { + isa = PBXGroup; + children = ( + CE0000000000000000000031 /* ScreenSaver.framework */, + CE0000000000000000000032 /* Cocoa.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + CE0000000000000000000305 /* Products */ = { + isa = PBXGroup; + children = ( + CE0000000000000000000041 /* NecoSaver.saver */, + CE0000000000000000000042 /* NecoSaverPreview.app */, + ); + name = Products; + sourceTree = ""; + }; + CE0000000000000000000300 = { + isa = PBXGroup; + children = ( + CE0000000000000000000301 /* Sources */, + CE0000000000000000000303 /* Preview */, + CE0000000000000000000013 /* README.md */, + CE0000000000000000000304 /* Frameworks */, + CE0000000000000000000305 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + CE0000000000000000000401 /* NecoSaver */ = { + isa = PBXNativeTarget; + buildConfigurationList = CE0000000000000000000601 /* Build configuration list for PBXNativeTarget "NecoSaver" */; + buildPhases = ( + CE0000000000000000000501 /* Sources */, + CE0000000000000000000201 /* Frameworks */, + CE0000000000000000000701 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NecoSaver; + productName = NecoSaver; + productReference = CE0000000000000000000041 /* NecoSaver.saver */; + productType = "com.apple.product-type.bundle"; + }; + CE0000000000000000000402 /* NecoSaverPreview */ = { + isa = PBXNativeTarget; + buildConfigurationList = CE0000000000000000000602 /* Build configuration list for PBXNativeTarget "NecoSaverPreview" */; + buildPhases = ( + CE0000000000000000000502 /* Sources */, + CE0000000000000000000202 /* Frameworks */, + CE0000000000000000000702 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NecoSaverPreview; + productName = NecoSaverPreview; + productReference = CE0000000000000000000042 /* NecoSaverPreview.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + CE0000000000000000000900 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastUpgradeCheck = 2660; + TargetAttributes = { + CE0000000000000000000401 = { + CreatedOnToolsVersion = 26.3; + }; + CE0000000000000000000402 = { + CreatedOnToolsVersion = 26.3; + }; + }; + }; + buildConfigurationList = CE0000000000000000000600 /* Build configuration list for PBXProject "NecoSaver" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = CE0000000000000000000300; + productRefGroup = CE0000000000000000000305 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + CE0000000000000000000401 /* NecoSaver */, + CE0000000000000000000402 /* NecoSaverPreview */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + CE0000000000000000000701 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CE0000000000000000000702 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + CE0000000000000000000501 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CE0000000000000000000101 /* Sprites.swift in Sources */, + CE0000000000000000000102 /* SpriteCache.swift in Sources */, + CE0000000000000000000103 /* Neko.swift in Sources */, + CE0000000000000000000104 /* Litter.swift in Sources */, + CE0000000000000000000105 /* NecoSaverSettings.swift in Sources */, + CE0000000000000000000106 /* NecoSaverEngine.swift in Sources */, + CE0000000000000000000107 /* ConfigureSheetController.swift in Sources */, + CE0000000000000000000108 /* NecoSaverView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CE0000000000000000000502 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CE0000000000000000000111 /* Sprites.swift in Sources */, + CE0000000000000000000112 /* SpriteCache.swift in Sources */, + CE0000000000000000000113 /* Neko.swift in Sources */, + CE0000000000000000000114 /* Litter.swift in Sources */, + CE0000000000000000000115 /* NecoSaverSettings.swift in Sources */, + CE0000000000000000000116 /* NecoSaverEngine.swift in Sources */, + CE0000000000000000000117 /* ConfigureSheetController.swift in Sources */, + CE0000000000000000000118 /* NecoSaverView.swift in Sources */, + CE0000000000000000000119 /* PreviewApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + CE0000000000000000000801 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = "$(ARCHS_STANDARD)"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = NO; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = NO; + MACOSX_DEPLOYMENT_TARGET = 13.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + CE0000000000000000000802 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = "$(ARCHS_STANDARD)"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = NO; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = NO; + MACOSX_DEPLOYMENT_TARGET = 13.0; + ONLY_ACTIVE_ARCH = NO; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + CE0000000000000000000811 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 97A8B2WE2P; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = Sources/Info.plist; + INSTALL_PATH = "$(HOME)/Library/Screen Savers"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = jp.winebarrel.NecoSaver; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + WRAPPER_EXTENSION = saver; + }; + name = Debug; + }; + CE0000000000000000000812 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 97A8B2WE2P; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = Sources/Info.plist; + INSTALL_PATH = "$(HOME)/Library/Screen Savers"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = jp.winebarrel.NecoSaver; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + WRAPPER_EXTENSION = saver; + }; + name = Release; + }; + CE0000000000000000000821 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 97A8B2WE2P; + INFOPLIST_FILE = Preview/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = jp.winebarrel.NecoSaverPreview; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + }; + name = Debug; + }; + CE0000000000000000000822 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 97A8B2WE2P; + INFOPLIST_FILE = Preview/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = jp.winebarrel.NecoSaverPreview; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + CE0000000000000000000600 /* Build configuration list for PBXProject "NecoSaver" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CE0000000000000000000801 /* Debug */, + CE0000000000000000000802 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CE0000000000000000000601 /* Build configuration list for PBXNativeTarget "NecoSaver" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CE0000000000000000000811 /* Debug */, + CE0000000000000000000812 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CE0000000000000000000602 /* Build configuration list for PBXNativeTarget "NecoSaverPreview" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CE0000000000000000000821 /* Debug */, + CE0000000000000000000822 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = CE0000000000000000000900 /* Project object */; +} diff --git a/NecoSaver.xcodeproj/xcshareddata/xcschemes/NecoSaver.xcscheme b/NecoSaver.xcodeproj/xcshareddata/xcschemes/NecoSaver.xcscheme new file mode 100644 index 0000000..04a7e94 --- /dev/null +++ b/NecoSaver.xcodeproj/xcshareddata/xcschemes/NecoSaver.xcscheme @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/NecoSaver.xcodeproj/xcshareddata/xcschemes/NecoSaverPreview.xcscheme b/NecoSaver.xcodeproj/xcshareddata/xcschemes/NecoSaverPreview.xcscheme new file mode 100644 index 0000000..80737bd --- /dev/null +++ b/NecoSaver.xcodeproj/xcshareddata/xcschemes/NecoSaverPreview.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Preview/Info.plist b/Preview/Info.plist new file mode 100644 index 0000000..26207a9 --- /dev/null +++ b/Preview/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + NSPrincipalClass + NSApplication + + diff --git a/Preview/PreviewApp.swift b/Preview/PreviewApp.swift new file mode 100644 index 0000000..306042b --- /dev/null +++ b/Preview/PreviewApp.swift @@ -0,0 +1,292 @@ +import AppKit + +/// A development harness for the screen saver. +/// +/// 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. +/// +/// NecoSaverPreview # live window +/// NecoSaverPreview --options # live window + Options sheet +/// NecoSaverPreview --snapshot out.png # render frames, write a PNG +/// 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 --options # render the Options sheet instead +/// +/// `--size` is in points; snapshots are written at 2x, as on a Retina display. +@main +@MainActor +enum PreviewApp { + private static var delegate: PreviewDelegate? + + // Called by the Swift runtime through @main, never from code. + // swiftlint:disable:next unused_declaration + static func main() { + let options: Options + do { + options = try Options(CommandLine.arguments.dropFirst()) + } catch { + FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) + exit(1) + } + + if let path = options.snapshotPath { + if options.showOptions { + snapshotOptionsSheet(to: path) + } else { + snapshot(to: path, options: options) + } + } else { + runLive(size: options.size, showOptions: options.showOptions, + isPreview: options.isPreview) + } + } + + // MARK: - Live window + + private static func runLive(size: NSSize, showOptions: Bool, isPreview: Bool) { + let app = NSApplication.shared + app.setActivationPolicy(.regular) + + let delegate = PreviewDelegate() + Self.delegate = delegate + app.delegate = delegate + app.mainMenu = makeMenu() + + let frame = NSRect(origin: .zero, size: size) + guard let view = NecoSaverView(frame: frame, isPreview: isPreview) else { + FileHandle.standardError.write(Data("failed to create NecoSaverView\n".utf8)) + exit(1) + } + + let window = NSWindow( + contentRect: frame, + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "NecoSaver Preview" + window.contentView = view + window.center() + window.makeKeyAndOrderFront(nil) + + view.startAnimation() + + if showOptions, let sheet = view.configureSheet { + window.beginSheet(sheet) + } + + app.activate(ignoringOtherApps: true) + app.run() + } + + private static func makeMenu() -> NSMenu { + let menu = NSMenu() + let appItem = NSMenuItem() + let appMenu = NSMenu() + appMenu.addItem(withTitle: "Quit NecoSaver Preview", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q") + appItem.submenu = appMenu + menu.addItem(appItem) + return menu + } + + // MARK: - Snapshot + + /// 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. + private static func snapshot(to path: String, options: Options) { + // Enough of AppKit to make NSColor usable. + _ = NSApplication.shared + + var settings = NecoSaverSettings.load() + if options.isPreview { + settings = settings.previewAdjusted + } + if let cats = options.cats { + settings.catCount = cats + } + if let speed = options.speed { + settings.speed = speed + } + + let size = options.size + let rect = NSRect(origin: .zero, size: size) + let engine = NecoSaverEngine(settings: settings) + engine.reset(bounds: rect) + for _ in 0 ..< options.frames { + engine.step() + } + + // `size` is in points; render at 2x as a Retina display would. + let scale = 2 + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(size.width) * scale, + pixelsHigh: Int(size.height) * scale, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ), let context = NSGraphicsContext(bitmapImageRep: rep) else { + FileHandle.standardError.write(Data("failed to allocate a bitmap\n".utf8)) + exit(1) + } + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.cgContext.scaleBy(x: CGFloat(scale), y: CGFloat(scale)) + engine.draw(in: rect, context: context.cgContext) + NSGraphicsContext.restoreGraphicsState() + + write(rep, to: path) + print("wrote \(path) (\(rep.pixelsWide)x\(rep.pixelsHigh) px, \(options.frames) frames)") + } + + /// Renders the Options sheet offscreen, so its layout can be checked without a + /// screen-recording entitlement. + private static func snapshotOptionsSheet(to path: String) { + _ = NSApplication.shared + + let controller = ConfigureSheetController { _ in } + guard let content = controller.window.contentView else { + FileHandle.standardError.write(Data("the sheet has no content view\n".utf8)) + exit(1) + } + content.layoutSubtreeIfNeeded() + + guard let rep = content.bitmapImageRepForCachingDisplay(in: content.bounds) else { + FileHandle.standardError.write(Data("failed to allocate a bitmap\n".utf8)) + exit(1) + } + content.cacheDisplay(in: content.bounds, to: rep) + + write(rep, to: path) + print("wrote \(path) (Options sheet, \(Int(content.bounds.width))x\(Int(content.bounds.height)))") + } + + private static func write(_ rep: NSBitmapImageRep, to path: String) { + guard let png = rep.representation(using: .png, properties: [:]) else { + FileHandle.standardError.write(Data("failed to encode PNG\n".utf8)) + exit(1) + } + do { + try png.write(to: URL(fileURLWithPath: path)) + } catch { + FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) + exit(1) + } + } +} + +private final class PreviewDelegate: NSObject, NSApplicationDelegate { + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + true + } +} + +private struct Options { + var snapshotPath: String? + var frames = 600 + var size = NSSize(width: 1280, height: 800) + var showOptions = false + var isPreview = false + var cats: Int? + var speed: Double? + + init(_ arguments: some Sequence) throws { + var explicitSize = false + var iterator = arguments.makeIterator() + while let argument = iterator.next() { + switch argument { + case "--options": + showOptions = true + case "--preview": + isPreview = true + case "--snapshot", "--frames", "--size", "--cats", "--speed": + let raw = try Self.value(after: argument, from: &iterator) + try set(argument, to: raw, explicitSize: &explicitSize) + default: + // Xcode launches the app with NSUserDefaults-style arguments such as + // `-NSDocumentRevisionsDebugMode YES`. Rejecting those means Run + // quits before the window ever appears, so skip any single-dash flag + // and the value that follows it. A misspelt `--option` still fails + // loudly. + guard argument.hasPrefix("--") else { + _ = iterator.next() + continue + } + throw OptionError("unknown argument: \(argument)") + } + } + + // Match the System Settings thumbnail unless a size was asked for. + if isPreview, !explicitSize { + size = NSSize(width: 320, height: 200) + } + } + + private mutating func set(_ flag: String, to raw: String, explicitSize: inout Bool) throws { + switch flag { + case "--snapshot": + snapshotPath = raw + case "--frames": + frames = try Self.positiveInt(raw, flag: flag) + case "--cats": + cats = try Self.positiveInt(raw, flag: flag) + case "--size": + size = try Self.parseSize(raw) + explicitSize = true + default: + guard let value = Double(raw), value > 0 else { + throw OptionError("\(flag) expects a positive number, got \(raw)") + } + speed = value + } + } + + private static func positiveInt(_ raw: String, flag: String) throws -> Int { + guard let value = Int(raw), value > 0 else { + throw OptionError("\(flag) expects a positive integer, got \(raw)") + } + return value + } + + private static func parseSize(_ raw: String) throws -> NSSize { + let parts = raw.lowercased().split(separator: "x") + guard parts.count == 2, + let width = Double(parts[0]), let height = Double(parts[1]), + width > 0, height > 0 + else { + throw OptionError("--size expects WIDTHxHEIGHT, got \(raw)") + } + return NSSize(width: width, height: height) + } + + private static func value( + after flag: String, + from iterator: inout some IteratorProtocol + ) throws -> String { + guard let value = iterator.next() else { + throw OptionError("\(flag) requires a value") + } + return value + } +} + +private struct OptionError: LocalizedError { + let errorDescription: String? + init(_ message: String) { + errorDescription = message + } +} diff --git a/README.md b/README.md index 4187dbf..ebfa351 100644 --- a/README.md +++ b/README.md @@ -1 +1,85 @@ -# NecoSaver \ No newline at end of file +# NecoSaver + +A macOS screen saver starring the cat from +[Neco](https://github.com/winebarrel/Neco): it trots to a random spot on the +screen, and once there runs the original oneko idle chain — sit, groom at a wall, +paw, scratch behind the ear, yawn, curl up to sleep — until the next destination +pulls it away again. It leaves paw prints and scratch marks behind as it goes. + +The desktop pet chases your mouse cursor; a screen saver cannot, since moving the +mouse dismisses it. So the cat runs Neco's wander mode the whole time, chasing a +random point instead of the pointer. + +## Options + +| Option | Default | Range | | +| --- | --- | --- | --- | +| 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. | +| 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. | + +Settings are stored per user through `ScreenSaverDefaults` under +`jp.winebarrel.NecoSaver`. + +## Install + +```sh +make install +``` + +macOS caches the loaded bundle, so after replacing an installed copy: + +```sh +killall legacyScreenSaver +``` + +then reopen System Settings. + +## Development + +`make preview` builds and runs `NecoSaverPreview`, a small host app that puts the +real `NecoSaverView` in an ordinary window — running it proves the view is +constructible outside System Settings, which is otherwise only visible as an empty +preview after installing. It also renders offscreen: + +```sh +NecoSaverPreview # live window +NecoSaverPreview --options # live window + Options sheet +NecoSaverPreview --snapshot out.png # render frames, write a PNG +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 --options # render the Options sheet instead +``` + +The saver itself cannot be run from Xcode: attach to the `legacyScreenSaver` +process instead (Debug > Attach to Process). + +`make release` signs with Developer ID, notarizes and staples, then writes a zip +and its checksum to `dist/`. + +## Sprites & license + +NecoSaver's own code is released under **CC0** (see `LICENSE`). + +The cat sprites are the **public-domain oneko `neko` bitmaps**, converted from XBM +to embedded data in `Sources/Sprites.swift`, and copied unchanged from Neco. The +cat has a history worth keeping: + +- The cat was drawn by **Juan Gotoh (後藤寿庵)** for *neko DA*, a Macintosh desk + accessory. The design is his original. +- **Masayuki Koba (古場正行)** turned it into the bitmaps for the X11 program + *xneko* (1990). +- **Tatsuya Kato** derived *oneko* from xneko, reusing its cat bitmaps almost + unchanged. + +Gotoh, Koba, and Kato each confirmed the artwork and program are free to use, +modify, and redistribute; oneko / xneko are treated as public domain by Debian, +the FSF, and Fedora. + +- Provenance: . +- Bitmap source: (oneko-1.2.sakura.5, `bitmaps/neko/`). diff --git a/Sources/ConfigureSheetController.swift b/Sources/ConfigureSheetController.swift new file mode 100644 index 0000000..fd2c087 --- /dev/null +++ b/Sources/ConfigureSheetController.swift @@ -0,0 +1,226 @@ +import AppKit + +/// The "Options…" sheet, built in code. +/// +/// A nib would have to be located inside the loaded bundle at runtime, which is a +/// common source of breakage for screen savers; assembling the controls directly +/// avoids that failure mode entirely. +@MainActor +final class ConfigureSheetController: NSObject { + /// A control bound to one field of `NecoSaverSettings`. Every value travels as + /// a `Double`: `NSControl.doubleValue` is the state of a checkbox as much as it + /// is the position of a slider, so one binding covers both. + private struct Row { + let control: NSControl + let valueLabel: NSTextField? + let describe: (Double) -> String + let read: (NecoSaverSettings) -> Double + let write: (inout NecoSaverSettings, Double) -> Void + } + + let window: NSWindow + + private var settings = NecoSaverSettings.load() + private let onDismiss: (_ saved: Bool) -> Void + private var rows: [Row] = [] + + init(onDismiss: @escaping (_ saved: Bool) -> Void) { + self.onDismiss = onDismiss + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + super.init() + window.title = "NecoSaver" + buildInterface() + refresh() + } + + // MARK: - Interface + + private func buildInterface() { + let root = NSStackView(views: [makeGrid(), makeButtons()]) + root.orientation = .vertical + root.alignment = .width + root.spacing = 20 + root.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + root.translatesAutoresizingMaskIntoConstraints = false + + let content = NSView() + content.addSubview(root) + NSLayoutConstraint.activate([ + root.leadingAnchor.constraint(equalTo: content.leadingAnchor), + root.trailingAnchor.constraint(equalTo: content.trailingAnchor), + root.topAnchor.constraint(equalTo: content.topAnchor), + root.bottomAnchor.constraint(equalTo: content.bottomAnchor), + ]) + + window.contentView = content + content.layoutSubtreeIfNeeded() + window.setContentSize(content.fittingSize) + } + + private func makeGrid() -> NSGridView { + let grid = NSGridView(views: makeRows()) + grid.translatesAutoresizingMaskIntoConstraints = false + grid.column(at: 0).xPlacement = .trailing + grid.rowSpacing = 10 + grid.columnSpacing = 10 + return grid + } + + private func makeRows() -> [[NSView]] { + let integer: (Double) -> String = { String(Int($0.rounded())) } + let multiplier: (Double) -> String = { String(format: "%.2f×", $0) } + let points: (Double) -> String = { String(format: "%.0f pt", $0 * Double(Sprites.width)) } + let percent: (Double) -> String = { String(format: "%.0f%%", $0 * 100) } + let settings = NecoSaverSettings.self + + return [ + makeRow(title: "Cats:", control: makeStepper(range: settings.catCountRange), + describe: integer, read: { Double($0.catCount) }, + write: { $0.catCount = Int($1.rounded()) }), + makeRow(title: "Size:", control: makeSlider(range: settings.sizeRange), + describe: points, read: { $0.size }, + write: { $0.size = $1 }), + 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), + describe: percent, read: { $0.background }, + write: { $0.background = $1 }), + makeCheckboxRow(title: "Paw prints", read: { $0.pawsEnabled }, + write: { $0.pawsEnabled = $1 }), + makeCheckboxRow(title: "Scratch marks", read: { $0.scratchEnabled }, + write: { $0.scratchEnabled = $1 }), + ] + } + + private func makeButtons() -> NSStackView { + let restore = NSButton(title: "Restore Defaults", target: self, + action: #selector(restoreDefaults)) + let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancel)) + cancel.keyEquivalent = "\u{1b}" + let ok = NSButton(title: "OK", target: self, action: #selector(confirm)) + ok.keyEquivalent = "\r" + + let buttons = NSStackView() + buttons.orientation = .horizontal + buttons.spacing = 12 + buttons.setViews([restore], in: .leading) + buttons.setViews([cancel, ok], in: .trailing) + return buttons + } + + private func makeRow( + title: String, + control: NSControl, + describe: @escaping (Double) -> String, + read: @escaping (NecoSaverSettings) -> Double, + write: @escaping (inout NecoSaverSettings, Double) -> Void + ) -> [NSView] { + let valueLabel = NSTextField(labelWithString: "") + valueLabel.alignment = .right + // Monospaced digits and a fixed width keep the grid from twitching as the + // numbers change under the slider. + valueLabel.font = .monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + valueLabel.translatesAutoresizingMaskIntoConstraints = false + valueLabel.widthAnchor.constraint(equalToConstant: 56).isActive = true + + addRow(control, valueLabel: valueLabel, describe: describe, read: read, write: write) + return [NSTextField(labelWithString: title), control, valueLabel] + } + + /// A checkbox carries its own label, so it takes the control column and leaves + /// the title and value columns empty. + private func makeCheckboxRow( + title: String, + read: @escaping (NecoSaverSettings) -> Bool, + write: @escaping (inout NecoSaverSettings, Bool) -> Void + ) -> [NSView] { + let checkbox = NSButton(checkboxWithTitle: title, target: nil, action: nil) + addRow(checkbox, valueLabel: nil, describe: { _ in "" }, + read: { read($0) ? 1 : 0 }, write: { write(&$0, $1 != 0) }) + return [NSGridCell.emptyContentView, checkbox, NSGridCell.emptyContentView] + } + + private func addRow( + _ control: NSControl, + valueLabel: NSTextField?, + describe: @escaping (Double) -> String, + read: @escaping (NecoSaverSettings) -> Double, + write: @escaping (inout NecoSaverSettings, Double) -> Void + ) { + control.target = self + control.action = #selector(controlChanged(_:)) + rows.append(Row(control: control, valueLabel: valueLabel, + describe: describe, read: read, write: write)) + } + + private func makeStepper(range: ClosedRange) -> NSStepper { + let stepper = NSStepper() + stepper.minValue = Double(range.lowerBound) + stepper.maxValue = Double(range.upperBound) + stepper.increment = 1 + stepper.valueWraps = false + stepper.autorepeat = true + return stepper + } + + private func makeSlider(range: ClosedRange) -> NSSlider { + let slider = NSSlider() + slider.minValue = range.lowerBound + slider.maxValue = range.upperBound + slider.isContinuous = true + slider.translatesAutoresizingMaskIntoConstraints = false + slider.widthAnchor.constraint(equalToConstant: 220).isActive = true + return slider + } + + /// Pushes `settings` back out to every control, so one refresh path serves both + /// the initial load and "Restore Defaults". + private func refresh() { + for row in rows { + let value = row.read(settings) + row.control.doubleValue = value + row.valueLabel?.stringValue = row.describe(value) + } + } + + // MARK: - Actions + + @objc private func controlChanged(_ sender: NSControl) { + guard let row = rows.first(where: { $0.control === sender }) else { return } + row.write(&settings, sender.doubleValue) + refresh() + } + + @objc private func restoreDefaults() { + settings = .standard + refresh() + } + + @objc private func confirm() { + settings.save() + dismiss(saved: true) + } + + @objc private func cancel() { + dismiss(saved: false) + } + + private func dismiss(saved: Bool) { + if let parent = window.sheetParent { + parent.endSheet(window, returnCode: saved ? .OK : .cancel) + } else { + // Reachable from the preview app, which shows the sheet standalone. + if NSApp.modalWindow === window { + NSApp.stopModal() + } + window.orderOut(nil) + } + onDismiss(saved) + } +} diff --git a/Sources/Info.plist b/Sources/Info.plist new file mode 100644 index 0000000..e35d62f --- /dev/null +++ b/Sources/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + NSHumanReadableCopyright + Released under CC0 1.0 Universal. Cat sprites from the public-domain oneko bitmaps. + NSPrincipalClass + NecoSaverView + + diff --git a/Sources/Litter.swift b/Sources/Litter.swift new file mode 100644 index 0000000..29ebdc2 --- /dev/null +++ b/Sources/Litter.swift @@ -0,0 +1,169 @@ +import AppKit + +// The mess the cats leave behind. A cat drops paw prints while it runs and scratch +// marks while it works its claws (grooming or clawing a wall). Marks live in the +// saver view's coordinates, fade out over their lifetime, and are drawn underneath +// the cats. + +/// Accumulates, ages and draws the marks. `update(nekos:)` is called once per 60fps +/// tick; it emits new marks from each cat's motion and culls faded ones. +@MainActor +final class LitterField { + /// One mark on screen. + private struct Mark { + enum Kind { case paw, scratch } + let kind: Kind + let pos: NSPoint + let angle: CGFloat // radians: paw points along travel, scratch is tilted + let birth: Int // tick when created + } + + /// Per-cat emission state. Paw spacing and the scratch cadence are measured per + /// cat, so two cats never drop their prints in lockstep. + private struct Emitter { + var prevPos: NSPoint? + var lastPawPos: NSPoint? // nil until the first running frame seeds it + var pawSide: CGFloat = 1 // flip each print for a left/right foot + var lastScratch = -9999 // safely in the past; never Int.min (subtraction would overflow) + } + + var pawsEnabled = false + var scratchEnabled = false + /// How much bigger than Neco's own cat these marks belong to (Tuning.unit), so + /// the mess grows with the sprite. + var unit: CGFloat = 1 + /// Mark colour, picked against the background by the engine. + var ink: NSColor = .black + + private var marks: [Mark] = [] + private var emitters: [Emitter] = [] + private var tick = 0 + + // Tunables. Distances are for unit == 1; times are in ticks (60 per second). + private let pawSpacing: CGFloat = 26 // travel distance between paw prints + private let scratchInterval = 9 // ~7 scratch marks per second while clawing + private let pawLife = 60 * 12 // 12s + private let scratchLife = 60 * 20 // 20s + private let maxMarks = 400 // hard cap: drop the oldest beyond this + + private func life(of kind: Mark.Kind) -> Int { + kind == .paw ? pawLife : scratchLife + } + + /// Advance one tick. + func update(nekos: [Neko]) { + tick += 1 + if emitters.count != nekos.count { + emitters = Array(repeating: Emitter(), count: nekos.count) + } + marks.removeAll { tick - $0.birth > life(of: $0.kind) } + for (index, neko) in nekos.enumerated() { + emit(from: neko, index: index) + } + } + + private func emit(from neko: Neko, index: Int) { + var emitter = emitters[index] + defer { emitters[index] = emitter } + + let pos = neko.pos + let previous = emitter.prevPos + emitter.prevPos = pos + + if pawsEnabled, neko.isRunning { + if let last = emitter.lastPawPos, hypot(pos.x - last.x, pos.y - last.y) >= pawSpacing * unit { + let heading = previous.map { atan2(pos.y - $0.y, pos.x - $0.x) } ?? 0 + // Step sideways from the path so prints alternate like real feet. + let off = 6 * unit * emitter.pawSide + let p = NSPoint(x: pos.x + cos(heading + .pi / 2) * off, + y: pos.y + sin(heading + .pi / 2) * off) + add(Mark(kind: .paw, pos: p, angle: heading, birth: tick)) + emitter.lastPawPos = pos + emitter.pawSide *= -1 + } else if emitter.lastPawPos == nil { + emitter.lastPawPos = pos // seed on the first running frame; the first step drops no print + } + } else { + emitter.lastPawPos = pos // measure spacing fresh from where the next run starts + } + + if scratchEnabled, neko.isClawing, tick - emitter.lastScratch >= scratchInterval { + // Deterministic scatter (no RNG): derive jitter and tilt from the tick, + // offset per cat so two cats clawing at once do not stamp the same marks. + let seed = tick + index * 17 + let jitter = CGFloat((seed * 37) % 18 - 9) * unit + let tilt = CGFloat((seed * 53) % 50 - 25) * .pi / 180 + let p = NSPoint(x: pos.x + jitter, y: pos.y + jitter * 0.5) + add(Mark(kind: .scratch, pos: p, angle: tilt, birth: tick)) + emitter.lastScratch = tick + } + } + + private func add(_ mark: Mark) { + marks.append(mark) + if marks.count > maxMarks { + marks.removeFirst(marks.count - maxMarks) + } + } + + /// Marks are held in view coordinates, so a resize would strand them; the cats + /// start over on a clean floor instead. + func clear() { + marks.removeAll() + emitters.removeAll() + } + + func draw(in context: CGContext) { + for mark in marks { + let a = alpha(of: mark) + switch mark.kind { + case .paw: drawPaw(context, at: mark.pos, angle: mark.angle, alpha: a) + case .scratch: drawScratch(context, at: mark.pos, angle: mark.angle, alpha: a) + } + } + } + + /// Opacity for a mark: full for most of its life, then fading over the last quarter. + private func alpha(of mark: Mark) -> CGFloat { + let age = CGFloat(tick - mark.birth) + let life = CGFloat(life(of: mark.kind)) + let fadeStart = life * 0.75 + guard age > fadeStart else { return 1 } + return max(0, 1 - (age - fadeStart) / (life - fadeStart)) + } + + /// A paw print: one big pad plus four toe beans, drawn pointing +y then rotated + /// so it points along the direction of travel. + private func drawPaw(_ ctx: CGContext, at p: NSPoint, angle: CGFloat, alpha: CGFloat) { + ctx.saveGState() + defer { ctx.restoreGState() } + ctx.translateBy(x: p.x, y: p.y) + ctx.rotate(by: angle - .pi / 2) // shape points +y; align +y with travel + ctx.setFillColor(ink.withAlphaComponent(0.5 * alpha).cgColor) + let s: CGFloat = 1.2 * unit + ctx.fillEllipse(in: CGRect(x: -5 * s, y: -6 * s, width: 10 * s, height: 8 * s)) + let toes = [CGPoint(x: -5, y: 3), CGPoint(x: -2, y: 6.5), CGPoint(x: 2, y: 6.5), CGPoint(x: 5, y: 3)] + for t in toes { + ctx.fillEllipse(in: CGRect(x: (t.x - 1.6) * s, y: (t.y - 1.6) * s, + width: 3.2 * s, height: 3.2 * s)) + } + } + + /// A scratch mark: three parallel claw streaks with a slight slant. + private func drawScratch(_ ctx: CGContext, at p: NSPoint, angle: CGFloat, alpha: CGFloat) { + ctx.saveGState() + defer { ctx.restoreGState() } + ctx.translateBy(x: p.x, y: p.y) + ctx.rotate(by: angle) + ctx.setStrokeColor(ink.withAlphaComponent(0.45 * alpha).cgColor) + ctx.setLineWidth(1.6 * unit) + ctx.setLineCap(.round) + let len: CGFloat = 16 * unit + for i in 0 ..< 3 { + let x = CGFloat(i - 1) * 4 * unit + ctx.move(to: CGPoint(x: x, y: -len / 2)) + ctx.addLine(to: CGPoint(x: x + 1.5 * unit, y: len / 2)) + } + ctx.strokePath() + } +} diff --git a/Sources/NecoSaverEngine.swift b/Sources/NecoSaverEngine.swift new file mode 100644 index 0000000..77fe3a6 --- /dev/null +++ b/Sources/NecoSaverEngine.swift @@ -0,0 +1,108 @@ +import AppKit + +/// Simulation and drawing: a field of cats plus the mess they leave. +/// +/// Deliberately free of any `NSView` dependency — it takes a rect and a +/// `CGContext` — so the same code drives the screen saver, the preview app's live +/// window and the offscreen PNG snapshots. +@MainActor +final class NecoSaverEngine { + private(set) var settings: NecoSaverSettings + private var bounds: CGRect = .zero + private var nekos: [Neko] = [] + private let litter = LitterField() + + init(settings: NecoSaverSettings = .load()) { + self.settings = settings + } + + /// Applies new settings, restarting only when the number of cats changed. Size + /// and speed are picked up in place, so a tweak never teleports anyone. + func apply(_ newSettings: NecoSaverSettings) { + guard newSettings != settings else { return } + let needsRestart = newSettings.catCount != settings.catCount + settings = newSettings + + if needsRestart { + reset(bounds: bounds) + return + } + + configureLitter() + for neko in nekos { + neko.tuning = tuning + } + } + + /// Rebuilds the cast, each cat dropped somewhere random with a clean floor. + func reset(bounds newBounds: CGRect) { + bounds = newBounds + litter.clear() + configureLitter() + + guard bounds.width > 0, bounds.height > 0 else { + nekos = [] + return + } + nekos = (0 ..< settings.catCount).map { _ in Neko(field: bounds, tuning: tuning) } + } + + /// Rescales in place so a resize never teleports the cats. + func resize(to newBounds: CGRect) { + guard bounds.width > 0, bounds.height > 0, !nekos.isEmpty else { + reset(bounds: newBounds) + return + } + guard newBounds.width > 0, newBounds.height > 0 else { return } + + let sx = newBounds.width / bounds.width + let sy = newBounds.height / bounds.height + bounds = newBounds + + for neko in nekos { + neko.field = newBounds + neko.pos = NSPoint(x: neko.pos.x * sx, y: neko.pos.y * sy) + } + litter.clear() + } + + /// Advances one 60fps tick — the rate the oneko idle chain was written against, + /// and what `animationTimeInterval` asks the host for. + func step() { + for neko in nekos { + neko.update() + } + litter.update(nekos: nekos) + } + + /// Background, 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) + + litter.draw(in: context) + + // Nearest neighbour: these are 32x32 dot-art sprites and scaling them + // smoothly turns the cat into a smudge. + context.interpolationQuality = .none + let side = CGFloat(Sprites.width) * settings.size + for neko in nekos { + guard let image = SpriteCache.image(neko.frame) else { continue } + context.draw(image, in: CGRect(x: neko.pos.x - side / 2, y: neko.pos.y - side / 2, + width: side, height: side)) + } + } + + private var tuning: Tuning { + Tuning(scale: settings.size, speedFactor: settings.speed) + } + + 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) + } +} diff --git a/Sources/NecoSaverSettings.swift b/Sources/NecoSaverSettings.swift new file mode 100644 index 0000000..7038ccc --- /dev/null +++ b/Sources/NecoSaverSettings.swift @@ -0,0 +1,100 @@ +import Foundation +import ScreenSaver + +/// User-tunable parameters, persisted through `ScreenSaverDefaults`. +/// +/// A screen saver bundle is loaded by several different host applications +/// (System Settings, `legacyScreenSaver`, ...), so `UserDefaults.standard` +/// would resolve to a different domain depending on who loaded us. +/// `ScreenSaverDefaults` pins the domain to our bundle identifier instead. +struct NecoSaverSettings: Equatable { + /// Must match `CFBundleIdentifier` in `Sources/Info.plist`. + static let moduleName = "jp.winebarrel.NecoSaver" + + var catCount: Int + /// Sprite scale. The oneko bitmaps are 32x32, so 2.0 draws a 64pt cat — the + /// size Neco itself uses. + 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. + var background: Double + var pawsEnabled: Bool + var scratchEnabled: Bool + + static let standard = NecoSaverSettings( + catCount: 1, + size: 2.0, + speed: 1.0, + background: 0.0, + pawsEnabled: true, + scratchEnabled: true + ) + + static let catCountRange = 1 ... 8 + static let sizeRange = 1.0 ... 6.0 + static let speedRange = 0.2 ... 3.0 + static let backgroundRange = 0.0 ... 1.0 + + private enum Key { + static let catCount = "catCount" + static let size = "size" + static let speed = "speed" + static let background = "background" + static let pawsEnabled = "pawsEnabled" + static let scratchEnabled = "scratchEnabled" + } + + private static var store: ScreenSaverDefaults? { + let defaults = ScreenSaverDefaults(forModuleWithName: moduleName) + defaults?.register(defaults: [ + Key.catCount: standard.catCount, + Key.size: standard.size, + Key.speed: standard.speed, + Key.background: standard.background, + Key.pawsEnabled: standard.pawsEnabled, + Key.scratchEnabled: standard.scratchEnabled, + ]) + return defaults + } + + /// Reads the saved settings, clamping every value so that a hand-edited + /// preference file can never produce an invisible or motionless cat. + static func load() -> NecoSaverSettings { + guard let store else { return standard } + return NecoSaverSettings( + catCount: catCountRange.clamping(store.integer(forKey: Key.catCount)), + size: sizeRange.clamping(store.double(forKey: Key.size)), + speed: speedRange.clamping(store.double(forKey: Key.speed)), + background: backgroundRange.clamping(store.double(forKey: Key.background)), + pawsEnabled: store.bool(forKey: Key.pawsEnabled), + scratchEnabled: store.bool(forKey: Key.scratchEnabled) + ) + } + + /// Trimmed for the System Settings thumbnail, which is only a couple of hundred + /// points wide — a 64pt cat fills a third of it, and a crowd has nowhere to run. + var previewAdjusted: NecoSaverSettings { + var adjusted = self + adjusted.size = min(size, 1.0) + adjusted.catCount = min(catCount, 2) + return adjusted + } + + 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(pawsEnabled, forKey: Key.pawsEnabled) + store.set(scratchEnabled, forKey: Key.scratchEnabled) + store.synchronize() + } +} + +private extension ClosedRange where Bound: Comparable { + func clamping(_ value: Bound) -> Bound { + Swift.min(Swift.max(value, lowerBound), upperBound) + } +} diff --git a/Sources/NecoSaverView.swift b/Sources/NecoSaverView.swift new file mode 100644 index 0000000..932913f --- /dev/null +++ b/Sources/NecoSaverView.swift @@ -0,0 +1,74 @@ +import ScreenSaver + +/// The screen saver's principal class. +/// +/// `@objc(NecoSaverView)` strips the Swift module prefix so the name matches +/// `NSPrincipalClass` in `Sources/Info.plist`; without it the host process looks up +/// `NecoSaver.NecoSaverView` and finds nothing. +@objc(NecoSaverView) +final class NecoSaverView: ScreenSaverView { + private let engine: NecoSaverEngine + /// Held strongly: the host releases its reference once the sheet is up, and a + /// deallocated controller takes the sheet's targets with it. + private var sheetController: ConfigureSheetController? + + override init?(frame: NSRect, isPreview: Bool) { + engine = NecoSaverEngine(settings: Self.settings(isPreview: isPreview)) + super.init(frame: frame, isPreview: isPreview) + // The oneko idle chain counts in 60fps ticks, so ask for that rate. + animationTimeInterval = 1.0 / 60.0 + engine.reset(bounds: bounds) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var isOpaque: Bool { + true + } + + override func startAnimation() { + super.startAnimation() + // Picks up any change made through the configuration sheet. + engine.apply(Self.settings(isPreview: isPreview)) + } + + override func animateOneFrame() { + super.animateOneFrame() + engine.step() + setNeedsDisplay(bounds) + } + + override func draw(_: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + engine.draw(in: bounds, context: context) + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + engine.resize(to: bounds) + } + + override var hasConfigureSheet: Bool { + true + } + + override var configureSheet: NSWindow? { + let controller = ConfigureSheetController { [weak self] saved in + guard let self else { return } + if saved { + engine.apply(Self.settings(isPreview: isPreview)) + } + sheetController = nil + } + sheetController = controller + return controller.window + } + + private static func settings(isPreview: Bool) -> NecoSaverSettings { + let settings = NecoSaverSettings.load() + return isPreview ? settings.previewAdjusted : settings + } +} diff --git a/Sources/Neko.swift b/Sources/Neko.swift new file mode 100644 index 0000000..78b2fc9 --- /dev/null +++ b/Sources/Neko.swift @@ -0,0 +1,288 @@ +import Foundation + +// The cat's brain, taken from Neco (the desktop-pet app) and cut down for a screen +// saver. Behind the shield window there is no cursor to chase — moving the mouse +// dismisses the saver — so the cat always runs Neco's wander mode: it picks a +// random point in `field`, runs to it, and on arrival falls into the original oneko +// idle chain (sit -> groom at a wall -> paw -> scratch -> yawn -> sleep), waking +// with a startle when the next destination pulls the target away. +// +// The two places that read `NSScreen` in the app read `field` here instead: a saver +// gets one view per display and may only draw inside its own bounds. + +/// Motion constants. Distances are quoted for `baseScale` and scaled from there, so +/// a bigger cat takes proportionally bigger steps and stops the same distance short +/// of its target. +struct Tuning { + /// Neco's own sprite scale. The sprite is 32x32, so that is a 64pt cat. + static let baseScale: CGFloat = 2 + + /// Sprite scale, from the sheet's "Size". + var scale: CGFloat = 2 + /// Multiplier on the walking speed, from the sheet's "Speed". + var speedFactor: CGFloat = 1 + + /// How much bigger than Neco's cat this one is. Also scales the litter marks. + var unit: CGFloat { + scale / Self.baseScale + } + + /// Pixels per 60fps tick. + var speed: CGFloat { + 6.0 * unit * speedFactor + } + + /// How close counts as "reached the target". + var stopDistance: CGFloat { + 24 * unit + } + + /// The target must move this far to wake a sleeping cat. + var wakeDistance: CGFloat { + 40 * unit + } + + /// How close to an edge counts as "at the wall". + var edgeMargin: CGFloat { + 24 * unit + } + + /// When not at a wall, the chance of sharpening claws (togi) instead of + /// pawing (jare) on stop. + let togiChance = 0.5 + + // Idle-chain durations, in seconds. Mirror oneko.h (there 1 tick ~= 0.125s): + // STOP 4, JARE 10, KAKI 4, AKUBI 6, AWAKE 3, TOGI 10. + let stopTime = 0.5 + let jareTime = 1.25 + let kakiTime = 0.5 + let akubiTime = 0.75 + let awakeTime = 0.375 + let togiTime = 1.25 + + let animTicks = 7 // run/idle frame swap cadence (~8fps, like the original) + let sleepAnimTicks = 30 // slower sleeping breath + + /// Once the cat reaches its destination, linger this long (seconds) before + /// picking a new one and running off again. + let dwellRange = 0.4 ... 2.0 +} + +@MainActor +final class Neko { + enum Motion { case running, awake, stop, jare, kaki, akubi, sleep, togi } + + private static let dirs = ["right", "upright", "up", "upleft", "left", "dwleft", "down", "dwright"] + + /// The rectangle the cat lives in: the saver view's bounds. + var field: CGRect + /// Re-read every frame, so the sheet's Size and Speed apply without a restart. + var tuning: Tuning + + var pos: NSPoint + private(set) var motion: Motion = .running + private var dir = "down" // 8-way facing while running + private var togiDir = "d" // u/d/l/r wall being groomed + private var headingDx: CGFloat = 0 + private var headingDy: CGFloat = 0 + private var tick = 0 + private var stateStart = 0 + + private var target = NSPoint.zero + private var dwell = 0.0 // seconds to linger at the current target before moving on + private var idleSince = 0 // last tick the cat was moving; the dwell timer counts from here + + init(field: CGRect, tuning: Tuning) { + self.field = field + self.tuning = tuning + pos = Neko.randomPoint(in: field, margin: tuning.edgeMargin) + pickTarget() + } + + /// Ticks are the unit of time throughout: `animateOneFrame` is driven at 60fps, + /// the rate the idle chain was written against. + private var stateAge: Double { + Double(tick - stateStart) / 60.0 + } + + /// The cat is on the move; drops paw prints behind it (see LitterField). + var isRunning: Bool { + if case .running = motion { + true + } else { + false + } + } + + /// The cat is sharpening its claws (togi): at an edge of the view, or — per + /// Tuning.togiChance — at a random stop anywhere. Scratch marks drop in this state. + var isClawing: Bool { + switch motion { + case .togi: true + default: false + } + } + + private func setState(_ m: Motion) { + motion = m + stateStart = tick + } + + /// The cat is heading somewhere: running toward a target, or waking to do so. + private var isMoving: Bool { + switch motion { + case .running, .awake: true + default: false + } + } + + func update() { + tick += 1 + let aim = advanceTarget() + + let dx = aim.x - pos.x + let dy = aim.y - pos.y + let dist = hypot(dx, dy) + + switch motion { + case .running: + chase(dx: dx, dy: dy, dist: dist) + + case .awake: + if stateAge >= tuning.awakeTime { + dir = Neko.direction(dx: dx, dy: dy) + setState(.running) + } + + case .stop: + if dist > tuning.stopDistance { + setState(.awake) + } else if stateAge >= tuning.stopTime { + chooseAfterStop() + } + + case .jare, .togi, .kaki, .akubi: + advanceIdleChain(dist: dist) + + case .sleep: + if dist > tuning.wakeDistance { + setState(.awake) + } + } + } + + /// Move toward the target, or sit down once close enough. + private func chase(dx: CGFloat, dy: CGFloat, dist: CGFloat) { + guard dist > tuning.stopDistance else { + setState(.stop) + return + } + dir = Neko.direction(dx: dx, dy: dy) + headingDx = dx + headingDy = dy + let step = min(tuning.speed, dist) + pos.x += dx / dist * step + pos.y += dy / dist * step + } + + /// One idle-chain step: wake if the target moved away, else advance the chain + /// (jare -> kaki -> akubi -> sleep; togi rejoins at kaki) after its time. + private func advanceIdleChain(dist: CGFloat) { + if dist > tuning.stopDistance { + setState(.awake) + return + } + let until: Double + let next: Motion + switch motion { + case .jare: (until, next) = (tuning.jareTime, .kaki) + case .togi: (until, next) = (tuning.togiTime, .kaki) + case .kaki: (until, next) = (tuning.kakiTime, .akubi) + default: (until, next) = (tuning.akubiTime, .sleep) // .akubi + } + if stateAge >= until { + setState(next) + } + } + + /// Leaving STOP: groom if the cat has run up against an edge of the view. + /// Otherwise, at random, sharpen claws (togi) facing a random way, else paw (jare). + private func chooseAfterStop() { + let m = tuning.edgeMargin + if headingDx < 0, pos.x - field.minX <= m { + togiDir = "l"; return setState(.togi) + } + if headingDx > 0, field.maxX - pos.x <= m { + togiDir = "r"; return setState(.togi) + } + if headingDy > 0, field.maxY - pos.y <= m { + togiDir = "u"; return setState(.togi) + } + if headingDy < 0, pos.y - field.minY <= m { + togiDir = "d"; return setState(.togi) + } + if Double.random(in: 0 ..< 1) < tuning.togiChance { + togiDir = ["u", "d", "l", "r"].randomElement() ?? "d" + setState(.togi) + } else { + setState(.jare) + } + } + + /// The stand-in for the app's mouse cursor. The cat keeps moving until it + /// settles, lingers for `dwell`, then a fresh destination pulls the target away + /// and wakes it — the same way real cursor movement does. Returns the current + /// target point. + private func advanceTarget() -> NSPoint { + if isMoving { + idleSince = tick + } else if Double(tick - idleSince) / 60.0 >= dwell { + pickTarget() + } + return target + } + + /// Pick a fresh random destination, and reset the dwell clock so the cat runs + /// off before it is eligible to settle again. + private func pickTarget() { + target = Neko.randomPoint(in: field, margin: tuning.edgeMargin) + dwell = .random(in: tuning.dwellRange) + idleSince = tick + } + + /// A random point in `rect`, kept `margin` off the very edges — but never + /// outside a rect too small to hold the margin. + private static func randomPoint(in rect: CGRect, margin: CGFloat) -> NSPoint { + NSPoint( + x: .random(in: (rect.minX + margin) ... max(rect.minX + margin, rect.maxX - margin)), + y: .random(in: (rect.minY + margin) ... max(rect.minY + margin, rect.maxY - margin)) + ) + } + + /// Which sprite to show right now. + var frame: String { + let phase = (tick / tuning.animTicks) % 2 + switch motion { + case .running: return dir + (phase == 0 ? "1" : "2") + case .awake: return "awake" + case .stop: return "mati2" + case .jare: return phase == 0 ? "jare2" : "mati2" // paw out, then settle + case .kaki: return phase == 0 ? "kaki1" : "kaki2" // scratch behind the ear + case .akubi: return "mati3" // yawn reuses mati3, as in oneko + case .sleep: return (tick / tuning.sleepAnimTicks) % 2 == 0 ? "sleep1" : "sleep2" + case .togi: + let n = phase == 0 ? "1" : "2" + switch togiDir { + case "u": return "utogi" + n + case "d": return "dtogi" + n + case "l": return "ltogi" + n + default: return "rtogi" + n + } + } + } + + private static func direction(dx: CGFloat, dy: CGFloat) -> String { + let sector = Int((atan2(dy, dx) / (.pi / 4)).rounded()) + return dirs[((sector % 8) + 8) % 8] + } +} diff --git a/Sources/SpriteCache.swift b/Sources/SpriteCache.swift new file mode 100644 index 0000000..6091d0e --- /dev/null +++ b/Sources/SpriteCache.swift @@ -0,0 +1,45 @@ +import AppKit + +/// Expands the public-domain oneko XBM bitmaps (see Sprites.swift) into images and +/// caches them. Ink pixels become black, body pixels white, everything else clear. +/// +/// Vends `CGImage` rather than `NSImage` so the engine can draw into the +/// `CGContext` it is handed, with no dependency on the current `NSGraphicsContext`. +@MainActor +enum SpriteCache { + private static var cache: [String: CGImage] = [:] + + static func image(_ name: String) -> CGImage? { + if let img = cache[name] { + return img + } + guard let bits = Sprites.image[name], let mask = Sprites.mask[name] else { return nil } + let w = Sprites.width, h = Sprites.height, rowBytes = w / 8 + + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: w, pixelsHigh: h, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: w * 4, bitsPerPixel: 32 + ), let px = rep.bitmapData else { return nil } + + for y in 0 ..< h { + for x in 0 ..< w { + let i = y * rowBytes + x / 8 + let b = Int(x % 8) + let ink = (bits[i] >> b) & 1 + let opaque = (mask[i] >> b) & 1 + let o = (y * w + x) * 4 + if opaque == 1 { + let v: UInt8 = ink == 1 ? 0 : 255 // ink black over a white body + px[o] = v; px[o + 1] = v; px[o + 2] = v; px[o + 3] = 255 + } else { + px[o] = 0; px[o + 1] = 0; px[o + 2] = 0; px[o + 3] = 0 + } + } + } + + guard let img = rep.cgImage else { return nil } + cache[name] = img + return img + } +} diff --git a/Sources/Sprites.swift b/Sources/Sprites.swift new file mode 100644 index 0000000..79a3c5a --- /dev/null +++ b/Sources/Sprites.swift @@ -0,0 +1,80 @@ +// Generated from public-domain oneko neko bitmaps. Do not edit by hand. +// Source: http://www.daidouji.com/oneko/ (oneko-1.2.sakura.5, bitmaps/neko) +// xneko by Masayuki Koba; oneko by Tatsuya Kato et al. Public domain. + +enum Sprites { + static let width = 32 + static let height = 32 + + // XBM image bits: 1 = cat ink (drawn in the foreground color). + static let image: [String: [UInt8]] = [ + "awake": [0, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 4, 64, 16, 16, 2, 128, 40, 40, 1, 0, 73, 36, 0, 6, 68, 68, 96, 24, 132, 66, 24, 96, 130, 131, 6, 0, 2, 128, 0, 0, 34, 136, 0, 15, 34, 136, 120, 0, 34, 136, 0, 0, 2, 128, 0, 0, 58, 185, 0, 0, 4, 64, 0, 0, 8, 32, 0, 0, 112, 28, 2, 0, 64, 4, 5, 0, 32, 136, 4, 0, 16, 80, 2, 0, 8, 32, 1, 0, 11, 160, 1, 128, 12, 97, 2, 64, 24, 49, 4, 64, 16, 17, 4, 192, 17, 17, 7, 96, 144, 19, 12, 224, 255, 254, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "down1": [0, 128, 1, 0, 0, 64, 2, 0, 0, 64, 2, 0, 0, 64, 2, 0, 0, 64, 2, 0, 0, 64, 2, 0, 0, 120, 30, 0, 0, 4, 32, 0, 0, 2, 64, 0, 0, 1, 128, 0, 128, 0, 0, 1, 128, 16, 16, 2, 128, 40, 40, 2, 64, 72, 36, 4, 64, 68, 68, 4, 64, 132, 66, 4, 64, 130, 131, 4, 64, 2, 128, 4, 96, 2, 128, 12, 192, 2, 128, 6, 32, 35, 136, 9, 160, 35, 136, 11, 224, 34, 136, 14, 128, 4, 65, 2, 0, 15, 224, 1, 0, 124, 124, 0, 0, 192, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "down2": [0, 140, 97, 0, 0, 90, 178, 0, 0, 82, 146, 0, 0, 82, 146, 0, 0, 97, 10, 1, 0, 97, 6, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 2, 128, 0, 0, 18, 144, 0, 0, 41, 40, 1, 0, 73, 36, 1, 0, 69, 68, 1, 0, 133, 66, 1, 0, 131, 131, 1, 0, 3, 128, 1, 96, 3, 128, 13, 128, 3, 128, 3, 0, 35, 136, 1, 0, 35, 136, 1, 0, 34, 136, 0, 0, 6, 193, 0, 0, 10, 160, 0, 0, 114, 156, 0, 0, 194, 135, 0, 0, 36, 72, 0, 0, 36, 72, 0, 0, 52, 88, 0, 0, 24, 48, 0, 0, 0, 0, 0], + "dtogi1": [0, 0, 0, 0, 0, 192, 0, 0, 0, 32, 1, 0, 0, 24, 2, 0, 0, 32, 4, 0, 0, 64, 4, 0, 0, 120, 60, 0, 0, 4, 64, 0, 0, 2, 128, 0, 0, 1, 0, 1, 128, 0, 0, 2, 128, 16, 16, 2, 128, 40, 40, 2, 192, 72, 36, 6, 48, 69, 68, 25, 8, 132, 66, 32, 248, 131, 131, 63, 0, 2, 128, 0, 0, 2, 128, 0, 0, 2, 128, 0, 0, 34, 136, 0, 0, 34, 136, 0, 0, 38, 136, 0, 0, 6, 65, 0, 0, 10, 96, 0, 0, 114, 92, 0, 0, 210, 87, 0, 0, 82, 84, 0, 0, 90, 84, 0, 0, 84, 84, 0, 0, 84, 20, 0, 0, 0, 0, 0], + "dtogi2": [0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 18, 0, 0, 0, 17, 0, 0, 128, 8, 0, 0, 128, 4, 0, 0, 240, 60, 0, 0, 8, 64, 0, 0, 4, 128, 0, 0, 2, 0, 1, 0, 1, 0, 2, 128, 16, 16, 2, 128, 40, 40, 2, 192, 72, 36, 6, 48, 69, 68, 25, 8, 132, 66, 32, 248, 131, 131, 63, 0, 2, 128, 0, 0, 2, 128, 0, 0, 2, 128, 0, 0, 34, 136, 0, 0, 34, 136, 0, 0, 34, 200, 0, 0, 4, 193, 0, 0, 12, 160, 0, 0, 116, 156, 0, 0, 212, 151, 0, 0, 84, 148, 0, 0, 84, 180, 0, 0, 84, 84, 0, 0, 80, 84, 0, 0, 0, 0, 0], + "dwleft1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 0, 0, 0, 28, 7, 0, 192, 7, 8, 0, 32, 240, 7, 0, 16, 192, 0, 0, 8, 0, 3, 24, 7, 0, 6, 40, 13, 0, 4, 72, 9, 16, 4, 136, 17, 8, 4, 8, 32, 4, 4, 8, 64, 4, 4, 8, 64, 4, 2, 36, 66, 4, 2, 36, 66, 4, 2, 39, 242, 8, 2, 4, 0, 8, 2, 36, 0, 8, 2, 4, 0, 17, 1, 104, 32, 145, 0, 240, 31, 138, 0, 128, 16, 74, 0, 0, 33, 76, 0, 0, 242, 36, 0, 0, 140, 55, 0, 0, 0, 28, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "dwleft2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 128, 63, 0, 0, 224, 64, 62, 0, 144, 56, 65, 0, 32, 137, 56, 0, 96, 114, 4, 0, 144, 28, 4, 0, 8, 0, 4, 192, 12, 0, 4, 70, 7, 0, 4, 90, 4, 0, 4, 114, 8, 0, 2, 34, 48, 0, 1, 4, 64, 128, 0, 4, 64, 96, 0, 4, 64, 16, 0, 4, 0, 8, 0, 21, 113, 8, 0, 22, 9, 4, 0, 20, 129, 4, 0, 4, 64, 2, 0, 44, 48, 1, 0, 24, 143, 0, 0, 232, 67, 0, 0, 76, 34, 0, 0, 36, 34, 0, 0, 36, 18, 0, 0, 24, 14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "dwright1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 224, 56, 0, 0, 16, 224, 3, 0, 224, 15, 4, 0, 128, 3, 8, 0, 192, 0, 16, 0, 96, 0, 224, 24, 32, 0, 176, 20, 32, 8, 144, 18, 32, 16, 136, 17, 32, 32, 4, 16, 32, 32, 2, 16, 64, 32, 2, 16, 64, 32, 66, 36, 64, 32, 66, 36, 64, 16, 79, 228, 64, 16, 0, 32, 64, 16, 0, 36, 128, 136, 0, 32, 0, 137, 4, 22, 0, 81, 248, 11, 0, 82, 8, 2, 0, 50, 132, 1, 0, 36, 79, 0, 0, 236, 49, 0, 0, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "dwright2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 252, 1, 0, 124, 2, 7, 0, 130, 28, 9, 0, 28, 145, 4, 0, 32, 78, 6, 0, 32, 56, 9, 0, 32, 0, 16, 0, 32, 0, 160, 1, 32, 0, 96, 97, 32, 0, 32, 91, 64, 0, 16, 70, 128, 0, 12, 68, 0, 1, 2, 36, 0, 6, 2, 32, 0, 8, 2, 32, 0, 16, 0, 32, 0, 16, 142, 168, 0, 32, 144, 104, 0, 32, 129, 40, 0, 64, 2, 32, 0, 128, 12, 52, 0, 0, 241, 24, 0, 0, 194, 23, 0, 0, 68, 34, 0, 0, 68, 36, 0, 0, 72, 36, 0, 0, 112, 24, 0, 0, 0, 0, 0, 0, 0, 0], + "jare2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 48, 0, 0, 0, 208, 0, 0, 0, 16, 1, 0, 0, 16, 2, 0, 224, 16, 12, 0, 32, 55, 16, 0, 32, 24, 32, 0, 64, 0, 65, 0, 64, 0, 66, 0, 64, 0, 80, 0, 64, 8, 72, 0, 128, 16, 65, 0, 128, 0, 60, 0, 0, 57, 34, 0, 0, 2, 68, 0, 0, 124, 124, 0, 0, 64, 68, 0, 0, 32, 70, 0, 0, 16, 64, 0, 0, 8, 48, 0, 0, 11, 164, 1, 128, 12, 99, 2, 64, 24, 1, 4, 64, 16, 1, 60, 192, 17, 5, 71, 96, 144, 3, 60, 224, 255, 254, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "kaki1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 26, 0, 0, 0, 98, 0, 0, 0, 130, 0, 0, 0, 2, 3, 0, 60, 2, 52, 0, 196, 3, 88, 0, 8, 0, 88, 0, 8, 32, 88, 0, 16, 16, 216, 0, 32, 8, 24, 1, 32, 200, 24, 2, 224, 33, 24, 4, 32, 0, 52, 8, 224, 5, 36, 8, 192, 96, 56, 8, 0, 31, 16, 8, 0, 8, 32, 8, 0, 11, 32, 8, 128, 12, 33, 12, 64, 24, 49, 4, 64, 16, 17, 2, 192, 17, 145, 127, 96, 144, 243, 128, 224, 255, 254, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "kaki2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 80, 0, 0, 32, 144, 0, 0, 80, 16, 1, 0, 144, 8, 2, 0, 16, 9, 4, 0, 16, 6, 8, 0, 16, 0, 8, 0, 32, 0, 16, 0, 32, 128, 16, 0, 32, 96, 16, 0, 32, 16, 8, 0, 224, 129, 249, 3, 32, 96, 24, 4, 224, 197, 15, 8, 192, 224, 0, 8, 0, 159, 17, 8, 0, 8, 46, 8, 0, 11, 32, 8, 128, 12, 33, 8, 64, 24, 49, 4, 64, 16, 17, 4, 192, 17, 145, 127, 96, 144, 243, 128, 224, 255, 254, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "left1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 14, 0, 0, 128, 49, 0, 0, 96, 192, 0, 0, 16, 0, 1, 0, 8, 0, 1, 12, 4, 0, 2, 20, 4, 0, 4, 100, 2, 0, 8, 136, 3, 1, 18, 8, 132, 0, 35, 8, 128, 0, 69, 4, 64, 0, 73, 18, 64, 0, 146, 18, 64, 0, 164, 18, 135, 0, 194, 195, 129, 0, 1, 1, 64, 3, 1, 2, 128, 158, 1, 4, 3, 145, 1, 248, 14, 78, 2, 0, 52, 200, 2, 0, 200, 47, 3, 0, 144, 56, 0, 0, 224, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "left2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 224, 96, 0, 0, 144, 160, 0, 0, 136, 32, 1, 0, 68, 32, 2, 0, 34, 48, 12, 0, 17, 8, 16, 192, 8, 36, 48, 56, 8, 36, 32, 6, 8, 36, 64, 1, 16, 2, 192, 0, 16, 194, 3, 0, 16, 2, 0, 0, 16, 4, 0, 0, 16, 120, 0, 0, 32, 128, 7, 8, 32, 128, 0, 48, 192, 192, 0, 192, 129, 96, 24, 120, 158, 16, 244, 135, 167, 8, 59, 0, 200, 196, 4, 0, 48, 56, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "ltogi1": [192, 0, 0, 0, 64, 1, 0, 0, 64, 2, 0, 0, 67, 4, 0, 0, 55, 24, 0, 0, 15, 32, 0, 0, 7, 64, 0, 0, 39, 64, 0, 0, 37, 64, 0, 0, 45, 64, 0, 0, 49, 64, 0, 0, 193, 160, 15, 0, 3, 0, 48, 0, 13, 0, 64, 0, 56, 192, 128, 0, 112, 32, 0, 1, 64, 16, 0, 2, 64, 8, 0, 2, 128, 9, 0, 4, 0, 15, 0, 12, 0, 12, 0, 16, 0, 20, 0, 32, 0, 40, 3, 70, 0, 254, 1, 141, 0, 141, 224, 112, 0, 13, 16, 0, 0, 254, 31, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "ltogi2": [192, 0, 0, 0, 64, 1, 0, 0, 64, 2, 0, 0, 64, 4, 0, 0, 65, 24, 0, 0, 49, 32, 0, 0, 9, 64, 0, 0, 37, 64, 0, 0, 37, 64, 0, 0, 35, 64, 0, 0, 131, 65, 0, 0, 101, 160, 15, 0, 5, 0, 48, 0, 233, 1, 64, 0, 49, 192, 128, 0, 15, 32, 0, 1, 1, 16, 0, 194, 126, 8, 0, 162, 242, 9, 0, 148, 12, 15, 0, 140, 0, 12, 0, 64, 0, 24, 0, 32, 0, 48, 3, 30, 0, 254, 1, 1, 0, 141, 224, 0, 0, 13, 16, 0, 0, 254, 31, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "mati2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 16, 0, 0, 40, 40, 0, 0, 72, 36, 0, 0, 68, 68, 0, 0, 132, 66, 0, 0, 130, 131, 0, 0, 2, 128, 0, 0, 34, 136, 0, 0, 34, 136, 0, 0, 34, 136, 0, 0, 2, 128, 0, 0, 58, 185, 0, 0, 4, 64, 0, 0, 8, 32, 0, 0, 112, 28, 0, 0, 64, 4, 0, 0, 32, 8, 0, 0, 16, 16, 0, 0, 8, 32, 0, 0, 11, 160, 1, 128, 12, 97, 2, 64, 24, 49, 4, 64, 16, 17, 4, 192, 17, 17, 127, 96, 144, 19, 132, 224, 255, 254, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "mati3": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 16, 0, 0, 40, 40, 0, 0, 72, 36, 0, 0, 68, 68, 0, 0, 132, 66, 0, 0, 130, 131, 0, 0, 58, 184, 0, 0, 66, 133, 0, 0, 146, 145, 0, 0, 78, 226, 0, 0, 66, 130, 0, 0, 66, 130, 0, 0, 68, 66, 0, 0, 136, 33, 0, 0, 112, 28, 0, 0, 64, 4, 0, 0, 32, 8, 0, 0, 16, 16, 0, 0, 8, 32, 0, 0, 11, 160, 1, 128, 12, 97, 2, 64, 24, 49, 4, 64, 16, 17, 4, 192, 17, 17, 127, 96, 144, 19, 132, 224, 255, 254, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "right1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 0, 0, 0, 140, 1, 0, 0, 3, 6, 0, 128, 0, 8, 0, 128, 0, 16, 0, 64, 0, 32, 48, 32, 0, 32, 40, 16, 0, 64, 38, 72, 128, 192, 17, 196, 0, 33, 16, 162, 0, 1, 16, 146, 0, 2, 32, 73, 0, 2, 72, 37, 0, 2, 72, 67, 0, 225, 72, 128, 0, 145, 195, 128, 192, 2, 128, 128, 121, 1, 64, 128, 137, 192, 32, 64, 114, 112, 31, 64, 19, 44, 0, 192, 244, 19, 0, 0, 28, 9, 0, 0, 24, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "right2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 4, 9, 0, 0, 6, 25, 0, 0, 5, 34, 0, 128, 4, 68, 0, 64, 4, 136, 0, 48, 12, 16, 3, 8, 16, 16, 28, 12, 36, 16, 96, 4, 36, 8, 128, 2, 36, 8, 0, 3, 64, 8, 0, 192, 67, 8, 0, 0, 64, 8, 0, 0, 32, 4, 0, 0, 30, 4, 16, 128, 1, 3, 12, 128, 1, 129, 3, 0, 3, 121, 14, 24, 6, 237, 249, 47, 8, 23, 0, 220, 16, 12, 0, 32, 35, 0, 0, 192, 28, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "rtogi1": [0, 0, 0, 3, 0, 0, 128, 2, 0, 0, 64, 2, 0, 0, 32, 194, 0, 0, 24, 236, 0, 0, 4, 240, 0, 0, 2, 224, 0, 0, 2, 228, 0, 0, 2, 164, 0, 0, 2, 180, 0, 0, 2, 140, 0, 240, 5, 131, 0, 12, 0, 192, 0, 2, 0, 176, 0, 1, 3, 28, 128, 0, 4, 14, 64, 0, 8, 2, 64, 0, 16, 2, 32, 0, 144, 1, 48, 0, 240, 0, 8, 0, 48, 0, 4, 0, 24, 0, 98, 192, 12, 0, 177, 128, 127, 0, 14, 7, 177, 0, 0, 8, 176, 0, 0, 248, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "rtogi2": [0, 0, 0, 3, 0, 0, 128, 2, 0, 0, 64, 2, 0, 0, 32, 2, 0, 0, 24, 130, 0, 0, 4, 140, 0, 0, 2, 144, 0, 0, 2, 164, 0, 0, 2, 164, 0, 0, 2, 196, 0, 0, 130, 193, 0, 240, 5, 166, 0, 12, 0, 160, 0, 2, 128, 151, 0, 1, 3, 140, 128, 0, 4, 240, 67, 0, 8, 128, 69, 0, 16, 126, 41, 0, 144, 79, 49, 0, 240, 48, 2, 0, 48, 0, 4, 0, 24, 0, 120, 192, 12, 0, 128, 128, 127, 0, 0, 7, 209, 0, 0, 8, 208, 0, 0, 248, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "sleep1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 192, 31, 0, 0, 0, 8, 0, 0, 0, 5, 0, 0, 0, 2, 0, 0, 0, 5, 31, 0, 128, 0, 8, 0, 192, 31, 4, 0, 0, 0, 31, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 128, 2, 0, 0, 65, 2, 0, 128, 34, 2, 0, 64, 62, 6, 0, 56, 20, 10, 0, 38, 24, 20, 0, 17, 0, 24, 0, 17, 0, 24, 128, 16, 0, 56, 64, 16, 0, 44, 64, 160, 1, 43, 64, 32, 142, 104, 64, 64, 16, 84, 64, 128, 64, 91, 128, 0, 255, 76, 0, 63, 240, 100, 0, 224, 159, 63, 0, 0, 0, 0, 0, 0, 0, 0], + "sleep2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 63, 0, 0, 0, 16, 60, 0, 0, 12, 16, 0, 0, 4, 136, 1, 0, 63, 124, 1, 0, 128, 96, 1, 0, 192, 33, 2, 0, 120, 63, 6, 0, 38, 18, 10, 0, 33, 12, 20, 128, 16, 0, 24, 128, 16, 0, 24, 128, 16, 0, 56, 64, 16, 0, 42, 64, 160, 3, 41, 64, 32, 140, 104, 64, 64, 16, 84, 64, 128, 64, 91, 128, 0, 255, 76, 0, 63, 240, 100, 0, 224, 159, 63, 0, 0, 0, 0, 0, 0, 0, 0], + "up1": [0, 192, 3, 0, 0, 62, 124, 0, 0, 8, 16, 0, 0, 38, 100, 0, 0, 34, 68, 0, 0, 34, 68, 0, 0, 1, 128, 0, 0, 31, 248, 0, 0, 1, 128, 0, 0, 34, 66, 0, 0, 30, 124, 0, 0, 6, 96, 0, 128, 63, 252, 1, 192, 4, 32, 3, 64, 2, 64, 2, 64, 2, 64, 2, 64, 1, 128, 2, 64, 0, 0, 2, 128, 0, 0, 1, 128, 0, 0, 1, 128, 0, 0, 1, 128, 0, 0, 1, 0, 1, 128, 0, 0, 6, 96, 0, 0, 120, 30, 0, 0, 64, 2, 0, 0, 64, 2, 0, 0, 64, 2, 0, 0, 64, 2, 0, 0, 128, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "up2": [0, 192, 3, 0, 128, 63, 252, 1, 64, 11, 208, 2, 64, 38, 100, 2, 64, 34, 68, 2, 64, 34, 68, 2, 64, 1, 128, 2, 64, 31, 248, 2, 64, 1, 128, 2, 64, 34, 66, 2, 128, 30, 124, 1, 128, 4, 32, 1, 128, 56, 28, 1, 128, 0, 0, 1, 0, 1, 128, 0, 0, 13, 176, 0, 0, 131, 193, 0, 0, 65, 130, 0, 128, 64, 2, 1, 128, 64, 2, 1, 128, 64, 2, 1, 128, 64, 2, 1, 128, 64, 2, 1, 128, 0, 0, 1, 128, 192, 3, 1, 0, 65, 130, 0, 0, 33, 132, 0, 0, 17, 136, 0, 0, 9, 144, 0, 0, 6, 96, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "upleft1": [0, 0, 7, 0, 128, 255, 4, 0, 64, 120, 4, 0, 224, 37, 4, 0, 54, 34, 4, 0, 24, 33, 4, 0, 16, 32, 4, 0, 16, 0, 4, 0, 16, 0, 62, 0, 32, 0, 193, 1, 32, 0, 0, 2, 192, 0, 0, 4, 0, 1, 0, 8, 0, 1, 0, 8, 0, 1, 0, 16, 0, 1, 0, 32, 0, 195, 0, 32, 0, 34, 0, 32, 0, 50, 0, 32, 0, 34, 0, 48, 0, 36, 0, 48, 0, 100, 0, 32, 0, 216, 192, 100, 0, 176, 65, 79, 0, 176, 67, 154, 0, 112, 240, 147, 0, 16, 30, 96, 0, 240, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "upleft2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 128, 176, 1, 0, 64, 7, 62, 0, 96, 192, 32, 0, 88, 184, 16, 0, 64, 136, 16, 0, 94, 128, 8, 0, 242, 128, 8, 0, 198, 0, 20, 0, 140, 3, 36, 0, 24, 0, 66, 14, 48, 80, 129, 9, 64, 0, 0, 9, 128, 1, 128, 4, 0, 2, 128, 12, 0, 4, 64, 10, 0, 8, 0, 50, 0, 16, 0, 64, 0, 16, 32, 64, 0, 16, 64, 64, 0, 16, 128, 64, 0, 32, 128, 135, 0, 32, 128, 136, 0, 64, 128, 144, 0, 128, 143, 144, 0, 0, 144, 160, 0, 0, 144, 192, 0, 0, 144, 0, 0, 0, 144, 0, 0, 0, 96, 0], + "upright1": [0, 224, 0, 0, 0, 32, 255, 1, 0, 32, 30, 2, 0, 32, 164, 7, 0, 32, 68, 108, 0, 32, 132, 24, 0, 32, 4, 8, 0, 32, 0, 8, 0, 124, 0, 8, 128, 131, 0, 4, 64, 0, 0, 4, 32, 0, 0, 3, 16, 0, 128, 0, 16, 0, 128, 0, 8, 0, 128, 0, 4, 0, 128, 0, 4, 0, 195, 0, 4, 0, 68, 0, 4, 0, 76, 0, 12, 0, 68, 0, 12, 0, 36, 0, 4, 0, 38, 0, 38, 3, 27, 0, 242, 130, 9, 0, 89, 194, 13, 0, 201, 15, 14, 0, 6, 120, 8, 0, 0, 192, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "upright2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 128, 13, 1, 0, 124, 224, 2, 0, 4, 3, 6, 0, 8, 29, 26, 0, 8, 17, 2, 0, 16, 1, 122, 0, 16, 1, 79, 0, 40, 0, 99, 0, 36, 192, 49, 112, 66, 0, 24, 144, 129, 10, 12, 144, 0, 0, 2, 32, 1, 128, 1, 48, 1, 64, 0, 80, 2, 32, 0, 76, 0, 16, 0, 2, 0, 8, 0, 2, 4, 8, 0, 2, 2, 8, 0, 2, 1, 8, 0, 225, 1, 4, 0, 17, 1, 4, 0, 9, 1, 2, 0, 9, 241, 1, 0, 5, 9, 0, 0, 3, 9, 0, 0, 0, 9, 0, 0, 0, 9, 0, 0, 0, 6, 0, 0], + "utogi1": [0, 42, 50, 0, 0, 42, 74, 0, 128, 235, 75, 0, 0, 62, 252, 0, 0, 10, 144, 0, 0, 38, 164, 0, 0, 35, 196, 0, 0, 35, 196, 0, 0, 1, 128, 0, 128, 31, 248, 1, 128, 1, 128, 0, 0, 39, 98, 0, 0, 29, 92, 0, 0, 3, 192, 0, 0, 30, 120, 0, 0, 2, 64, 0, 0, 1, 128, 0, 128, 0, 0, 1, 128, 0, 0, 1, 128, 0, 0, 1, 176, 0, 0, 13, 232, 1, 128, 19, 8, 3, 192, 16, 16, 0, 0, 8, 224, 120, 30, 7, 0, 71, 226, 0, 0, 32, 2, 0, 0, 16, 1, 0, 0, 144, 0, 0, 0, 96, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "utogi2": [0, 38, 42, 0, 0, 41, 42, 0, 0, 233, 235, 0, 0, 61, 60, 0, 0, 9, 48, 0, 0, 37, 68, 0, 0, 35, 68, 0, 0, 35, 68, 0, 0, 1, 128, 0, 128, 31, 248, 1, 128, 0, 128, 0, 0, 39, 98, 0, 0, 29, 92, 0, 0, 3, 192, 0, 0, 30, 120, 0, 0, 2, 64, 0, 0, 1, 128, 0, 128, 0, 0, 1, 128, 0, 0, 1, 128, 0, 0, 1, 176, 0, 0, 13, 232, 1, 128, 19, 8, 3, 192, 16, 16, 0, 0, 8, 224, 120, 30, 7, 0, 71, 226, 0, 0, 64, 4, 0, 0, 128, 8, 0, 0, 0, 9, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ] + + // XBM mask bits: 1 = opaque pixel (shape), 0 = transparent. + static let mask: [String: [UInt8]] = [ + "awake": [0, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 4, 64, 16, 16, 2, 128, 56, 56, 1, 0, 121, 60, 0, 6, 124, 124, 96, 24, 252, 126, 24, 96, 254, 255, 6, 0, 254, 255, 0, 0, 254, 255, 0, 15, 254, 255, 120, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 252, 127, 0, 0, 248, 63, 0, 0, 240, 31, 2, 0, 192, 7, 7, 0, 224, 143, 7, 0, 240, 223, 3, 0, 248, 255, 1, 0, 251, 255, 1, 128, 255, 255, 3, 192, 255, 255, 7, 192, 255, 255, 7, 192, 255, 255, 7, 224, 255, 255, 15, 224, 255, 254, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "down1": [0, 128, 1, 0, 0, 192, 3, 0, 0, 192, 3, 0, 0, 192, 3, 0, 0, 192, 3, 0, 0, 192, 3, 0, 0, 248, 31, 0, 0, 252, 63, 0, 0, 254, 127, 0, 0, 255, 255, 0, 128, 255, 255, 1, 128, 255, 255, 3, 128, 255, 255, 3, 192, 255, 255, 7, 192, 255, 255, 7, 192, 255, 255, 7, 192, 255, 255, 7, 192, 255, 255, 7, 224, 255, 255, 15, 192, 255, 255, 7, 224, 255, 255, 15, 224, 255, 255, 15, 224, 255, 255, 15, 128, 255, 255, 3, 0, 255, 255, 1, 0, 252, 127, 0, 0, 192, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "down2": [0, 140, 97, 0, 0, 222, 243, 0, 0, 222, 243, 0, 0, 222, 243, 0, 0, 255, 251, 1, 0, 255, 255, 1, 0, 255, 255, 1, 0, 255, 255, 1, 0, 255, 255, 1, 0, 255, 255, 1, 0, 254, 255, 0, 0, 254, 255, 0, 0, 255, 255, 1, 0, 255, 255, 1, 0, 255, 255, 1, 0, 255, 255, 1, 0, 255, 255, 1, 0, 255, 255, 1, 96, 255, 255, 13, 128, 255, 255, 3, 0, 255, 255, 1, 0, 255, 255, 1, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 60, 120, 0, 0, 60, 120, 0, 0, 60, 120, 0, 0, 24, 48, 0, 0, 0, 0, 0], + "dtogi1": [0, 0, 0, 0, 0, 192, 0, 0, 0, 224, 1, 0, 0, 248, 3, 0, 0, 224, 7, 0, 0, 192, 7, 0, 0, 248, 63, 0, 0, 252, 127, 0, 0, 254, 255, 0, 0, 255, 255, 1, 128, 255, 255, 3, 128, 255, 255, 3, 128, 255, 255, 3, 192, 255, 255, 7, 240, 255, 255, 31, 248, 255, 255, 63, 248, 255, 255, 63, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 127, 0, 0, 254, 127, 0, 0, 254, 95, 0, 0, 222, 87, 0, 0, 94, 84, 0, 0, 94, 84, 0, 0, 84, 84, 0, 0, 84, 20, 0, 0, 0, 0, 0], + "dtogi2": [0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 30, 0, 0, 0, 31, 0, 0, 128, 15, 0, 0, 128, 7, 0, 0, 240, 63, 0, 0, 248, 127, 0, 0, 252, 255, 0, 0, 254, 255, 1, 0, 255, 255, 3, 128, 255, 255, 3, 128, 255, 255, 3, 192, 255, 255, 7, 240, 255, 255, 31, 248, 255, 255, 63, 248, 255, 255, 63, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 252, 255, 0, 0, 252, 255, 0, 0, 244, 255, 0, 0, 212, 247, 0, 0, 84, 244, 0, 0, 84, 244, 0, 0, 84, 84, 0, 0, 80, 84, 0, 0, 0, 0, 0], + "dwleft1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 0, 0, 0, 252, 7, 0, 192, 255, 15, 0, 224, 255, 7, 0, 240, 255, 0, 0, 248, 255, 3, 24, 255, 255, 7, 56, 255, 255, 7, 120, 255, 255, 7, 248, 255, 255, 7, 248, 255, 255, 7, 248, 255, 255, 7, 248, 255, 255, 3, 252, 255, 255, 3, 252, 255, 255, 3, 255, 255, 255, 3, 252, 255, 255, 3, 252, 255, 255, 3, 252, 255, 255, 1, 248, 255, 255, 0, 240, 255, 255, 0, 128, 255, 127, 0, 0, 255, 127, 0, 0, 254, 63, 0, 0, 140, 63, 0, 0, 0, 28, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "dwleft2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 128, 63, 0, 0, 224, 127, 62, 0, 240, 63, 127, 0, 224, 143, 63, 0, 224, 255, 7, 0, 240, 255, 7, 0, 248, 255, 7, 192, 252, 255, 7, 198, 255, 255, 7, 222, 255, 255, 7, 254, 255, 255, 3, 254, 255, 255, 1, 252, 255, 255, 0, 252, 255, 127, 0, 252, 255, 31, 0, 252, 255, 15, 0, 253, 255, 15, 0, 254, 255, 7, 0, 252, 255, 7, 0, 252, 255, 3, 0, 252, 255, 1, 0, 248, 255, 0, 0, 248, 127, 0, 0, 124, 62, 0, 0, 60, 62, 0, 0, 60, 30, 0, 0, 24, 14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "dwright1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 224, 63, 0, 0, 240, 255, 3, 0, 224, 255, 7, 0, 128, 255, 15, 0, 192, 255, 31, 0, 224, 255, 255, 24, 224, 255, 255, 28, 224, 255, 255, 30, 224, 255, 255, 31, 224, 255, 255, 31, 224, 255, 255, 31, 192, 255, 255, 31, 192, 255, 255, 63, 192, 255, 255, 63, 192, 255, 255, 255, 192, 255, 255, 63, 192, 255, 255, 63, 128, 255, 255, 63, 0, 255, 255, 31, 0, 255, 255, 11, 0, 254, 255, 3, 0, 254, 255, 1, 0, 252, 127, 0, 0, 252, 49, 0, 0, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "dwright2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 252, 1, 0, 124, 254, 7, 0, 254, 252, 15, 0, 252, 241, 7, 0, 224, 255, 7, 0, 224, 255, 15, 0, 224, 255, 31, 0, 224, 255, 191, 1, 224, 255, 255, 97, 224, 255, 255, 123, 192, 255, 255, 127, 128, 255, 255, 127, 0, 255, 255, 63, 0, 254, 255, 63, 0, 248, 255, 63, 0, 240, 255, 63, 0, 240, 255, 191, 0, 224, 255, 127, 0, 224, 255, 63, 0, 192, 255, 63, 0, 128, 255, 63, 0, 0, 255, 31, 0, 0, 254, 31, 0, 0, 124, 62, 0, 0, 124, 60, 0, 0, 120, 60, 0, 0, 112, 24, 0, 0, 0, 0, 0, 0, 0, 0], + "jare2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 48, 0, 0, 0, 240, 0, 0, 0, 240, 1, 0, 0, 240, 3, 0, 224, 240, 15, 0, 224, 247, 31, 0, 224, 255, 63, 0, 192, 255, 127, 0, 192, 255, 127, 0, 192, 255, 127, 0, 192, 255, 127, 0, 128, 255, 127, 0, 128, 255, 63, 0, 0, 255, 63, 0, 0, 254, 127, 0, 0, 252, 127, 0, 0, 192, 127, 0, 0, 224, 127, 0, 0, 240, 127, 0, 0, 248, 63, 0, 0, 251, 191, 1, 128, 255, 255, 3, 192, 255, 255, 7, 192, 255, 255, 63, 192, 255, 255, 127, 224, 255, 255, 63, 224, 255, 254, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "kaki1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 30, 0, 0, 0, 126, 0, 0, 0, 254, 0, 0, 0, 254, 3, 0, 60, 254, 55, 0, 252, 255, 127, 0, 248, 255, 127, 0, 248, 255, 127, 0, 240, 255, 255, 0, 224, 255, 255, 1, 224, 255, 255, 3, 224, 255, 255, 7, 224, 255, 247, 15, 224, 255, 231, 15, 192, 255, 255, 15, 0, 255, 255, 15, 0, 248, 255, 15, 0, 251, 255, 15, 128, 255, 255, 15, 192, 255, 255, 7, 192, 255, 255, 3, 192, 255, 255, 127, 224, 255, 255, 255, 224, 255, 254, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "kaki2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 112, 0, 0, 32, 240, 0, 0, 112, 240, 1, 0, 240, 248, 3, 0, 240, 249, 7, 0, 240, 255, 15, 0, 240, 255, 15, 0, 224, 255, 31, 0, 224, 255, 31, 0, 224, 255, 31, 0, 224, 255, 15, 0, 224, 255, 255, 3, 224, 255, 255, 7, 224, 255, 255, 15, 192, 255, 255, 15, 0, 255, 255, 15, 0, 248, 255, 15, 0, 251, 255, 15, 128, 255, 255, 15, 192, 255, 255, 7, 192, 255, 255, 7, 192, 255, 255, 127, 224, 255, 255, 255, 224, 255, 254, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "left1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 14, 0, 0, 128, 63, 0, 0, 224, 255, 0, 0, 240, 255, 1, 0, 248, 255, 1, 12, 252, 255, 3, 28, 252, 255, 7, 124, 254, 255, 15, 248, 255, 255, 31, 248, 255, 255, 63, 248, 255, 255, 125, 252, 255, 255, 121, 254, 255, 255, 243, 254, 255, 255, 231, 254, 255, 255, 195, 255, 255, 255, 1, 255, 255, 255, 1, 254, 255, 255, 1, 252, 255, 255, 1, 248, 254, 255, 3, 0, 252, 255, 3, 0, 248, 63, 3, 0, 240, 56, 0, 0, 224, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "left2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 224, 96, 0, 0, 240, 224, 0, 0, 248, 224, 1, 0, 124, 224, 3, 0, 62, 240, 15, 0, 31, 248, 31, 192, 15, 252, 63, 248, 15, 252, 63, 254, 15, 252, 127, 255, 31, 254, 255, 255, 31, 254, 255, 255, 31, 254, 255, 255, 31, 252, 255, 255, 31, 248, 255, 255, 63, 128, 255, 255, 63, 128, 255, 255, 255, 192, 255, 255, 255, 224, 255, 255, 255, 240, 255, 135, 255, 248, 63, 0, 248, 252, 7, 0, 48, 56, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "ltogi1": [192, 0, 0, 0, 192, 1, 0, 0, 192, 3, 0, 0, 195, 7, 0, 0, 247, 31, 0, 0, 255, 63, 0, 0, 255, 127, 0, 0, 255, 127, 0, 0, 255, 127, 0, 0, 255, 127, 0, 0, 255, 127, 0, 0, 255, 255, 15, 0, 255, 255, 63, 0, 253, 255, 127, 0, 248, 255, 255, 0, 240, 255, 255, 1, 192, 255, 255, 3, 192, 255, 255, 3, 128, 255, 255, 7, 0, 255, 255, 15, 0, 252, 255, 31, 0, 252, 255, 63, 0, 248, 255, 127, 0, 254, 255, 253, 0, 255, 255, 112, 0, 255, 31, 0, 0, 254, 31, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "ltogi2": [192, 0, 0, 0, 192, 1, 0, 0, 192, 3, 0, 0, 192, 7, 0, 0, 193, 31, 0, 0, 241, 63, 0, 0, 249, 127, 0, 0, 253, 127, 0, 0, 253, 127, 0, 0, 255, 127, 0, 0, 255, 127, 0, 0, 253, 255, 15, 0, 253, 255, 63, 0, 249, 255, 127, 0, 241, 255, 255, 0, 255, 255, 255, 1, 255, 255, 255, 195, 254, 255, 255, 227, 254, 255, 255, 247, 12, 255, 255, 255, 0, 252, 255, 127, 0, 248, 255, 63, 0, 240, 255, 31, 0, 254, 255, 1, 0, 255, 255, 0, 0, 255, 31, 0, 0, 254, 31, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "mati2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 16, 0, 0, 56, 56, 0, 0, 120, 60, 0, 0, 124, 124, 0, 0, 252, 126, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 252, 127, 0, 0, 248, 63, 0, 0, 240, 31, 0, 0, 192, 7, 0, 0, 224, 15, 0, 0, 240, 31, 0, 0, 248, 63, 0, 0, 251, 191, 1, 128, 255, 255, 3, 192, 255, 255, 7, 192, 255, 255, 7, 192, 255, 255, 127, 224, 255, 255, 255, 224, 255, 254, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "mati3": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 16, 0, 0, 56, 56, 0, 0, 120, 60, 0, 0, 124, 124, 0, 0, 252, 126, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 252, 127, 0, 0, 248, 63, 0, 0, 240, 31, 0, 0, 192, 7, 0, 0, 224, 15, 0, 0, 240, 31, 0, 0, 248, 63, 0, 0, 251, 191, 1, 128, 255, 255, 3, 192, 255, 255, 7, 192, 255, 255, 7, 192, 255, 255, 127, 224, 255, 255, 255, 224, 255, 254, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "right1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 0, 0, 0, 252, 1, 0, 0, 255, 7, 0, 128, 255, 15, 0, 128, 255, 31, 0, 192, 255, 63, 48, 224, 255, 63, 56, 240, 255, 127, 62, 248, 255, 255, 31, 252, 255, 255, 31, 190, 255, 255, 31, 158, 255, 255, 63, 207, 255, 255, 127, 231, 255, 255, 127, 195, 255, 255, 127, 128, 255, 255, 255, 128, 255, 255, 255, 128, 255, 255, 127, 128, 255, 255, 63, 192, 255, 127, 31, 192, 255, 63, 0, 192, 252, 31, 0, 0, 28, 15, 0, 0, 24, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "right2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 4, 15, 0, 0, 6, 31, 0, 0, 7, 62, 0, 128, 7, 124, 0, 192, 7, 248, 0, 240, 15, 240, 3, 248, 31, 240, 31, 252, 63, 240, 127, 252, 63, 248, 255, 254, 63, 248, 255, 255, 127, 248, 255, 255, 127, 248, 255, 255, 127, 248, 255, 255, 63, 252, 255, 255, 31, 252, 255, 255, 1, 255, 255, 255, 1, 255, 255, 255, 3, 255, 255, 255, 7, 255, 249, 255, 15, 31, 0, 252, 31, 12, 0, 224, 63, 0, 0, 192, 28, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "rtogi1": [0, 0, 0, 3, 0, 0, 128, 3, 0, 0, 192, 3, 0, 0, 224, 195, 0, 0, 248, 239, 0, 0, 252, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 240, 255, 255, 0, 252, 255, 255, 0, 254, 255, 191, 0, 255, 255, 31, 128, 255, 255, 15, 192, 255, 255, 3, 192, 255, 255, 3, 224, 255, 255, 1, 240, 255, 255, 0, 248, 255, 63, 0, 252, 255, 31, 0, 254, 255, 15, 0, 191, 255, 127, 0, 14, 255, 255, 0, 0, 248, 255, 0, 0, 248, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "rtogi2": [0, 0, 0, 3, 0, 0, 128, 3, 0, 0, 192, 3, 0, 0, 224, 3, 0, 0, 248, 131, 0, 0, 252, 143, 0, 0, 254, 159, 0, 0, 254, 191, 0, 0, 254, 191, 0, 0, 254, 255, 0, 0, 254, 255, 0, 240, 255, 255, 0, 252, 255, 255, 0, 254, 255, 255, 0, 255, 255, 255, 128, 255, 255, 255, 195, 255, 255, 255, 199, 255, 255, 127, 239, 255, 255, 127, 255, 255, 255, 48, 254, 255, 63, 0, 252, 255, 31, 0, 248, 255, 15, 0, 128, 255, 127, 0, 0, 255, 255, 0, 0, 248, 255, 0, 0, 248, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "sleep1": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 192, 31, 0, 0, 192, 31, 0, 0, 0, 13, 0, 0, 0, 7, 0, 0, 0, 7, 31, 0, 128, 5, 31, 0, 192, 31, 12, 0, 192, 31, 31, 0, 0, 0, 31, 0, 0, 0, 0, 1, 0, 0, 128, 3, 0, 0, 193, 3, 0, 128, 227, 3, 0, 192, 255, 7, 0, 248, 255, 15, 0, 254, 255, 31, 0, 255, 255, 31, 0, 255, 255, 31, 128, 255, 255, 63, 192, 255, 255, 63, 192, 255, 255, 63, 192, 255, 255, 127, 192, 255, 255, 127, 192, 255, 255, 127, 128, 255, 255, 127, 0, 255, 255, 127, 0, 224, 159, 63, 0, 0, 0, 0, 0, 0, 0, 0], + "sleep2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 63, 0, 0, 0, 63, 60, 0, 0, 28, 60, 0, 0, 12, 152, 1, 0, 63, 252, 1, 0, 191, 252, 1, 0, 192, 225, 3, 0, 248, 255, 7, 0, 254, 255, 15, 0, 255, 255, 31, 128, 255, 255, 31, 128, 255, 255, 31, 128, 255, 255, 63, 192, 255, 255, 63, 192, 255, 255, 63, 192, 255, 255, 127, 192, 255, 255, 127, 192, 255, 255, 127, 128, 255, 255, 127, 0, 255, 255, 127, 0, 224, 159, 63, 0, 0, 0, 0, 0, 0, 0, 0], + "up1": [0, 192, 3, 0, 0, 254, 127, 0, 0, 248, 31, 0, 0, 254, 127, 0, 0, 254, 127, 0, 0, 254, 127, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 254, 127, 0, 0, 254, 127, 0, 0, 254, 127, 0, 128, 255, 255, 1, 192, 255, 255, 3, 192, 255, 255, 3, 192, 255, 255, 3, 192, 255, 255, 3, 192, 255, 255, 3, 128, 255, 255, 1, 128, 255, 255, 1, 128, 255, 255, 1, 128, 255, 255, 1, 0, 255, 255, 0, 0, 254, 127, 0, 0, 248, 31, 0, 0, 192, 3, 0, 0, 192, 3, 0, 0, 192, 3, 0, 0, 192, 3, 0, 0, 128, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "up2": [0, 192, 3, 0, 128, 255, 255, 1, 192, 255, 255, 3, 192, 255, 255, 3, 192, 255, 255, 3, 192, 255, 255, 3, 192, 255, 255, 3, 192, 255, 255, 3, 192, 255, 255, 3, 192, 255, 255, 3, 128, 255, 255, 1, 128, 255, 255, 1, 128, 255, 255, 1, 128, 255, 255, 1, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 128, 255, 255, 1, 128, 255, 255, 1, 128, 255, 255, 1, 128, 255, 255, 1, 128, 255, 255, 1, 128, 255, 255, 1, 128, 255, 255, 1, 0, 127, 254, 0, 0, 63, 252, 0, 0, 31, 248, 0, 0, 15, 240, 0, 0, 6, 96, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "upleft1": [0, 0, 7, 0, 128, 255, 7, 0, 192, 255, 7, 0, 224, 255, 7, 0, 246, 255, 7, 0, 248, 255, 7, 0, 240, 255, 7, 0, 240, 255, 7, 0, 240, 255, 63, 0, 224, 255, 255, 1, 224, 255, 255, 3, 192, 255, 255, 7, 0, 255, 255, 15, 0, 255, 255, 15, 0, 255, 255, 31, 0, 255, 255, 63, 0, 255, 255, 63, 0, 254, 255, 63, 0, 254, 255, 63, 0, 254, 255, 63, 0, 252, 255, 63, 0, 252, 255, 63, 0, 248, 255, 127, 0, 240, 255, 127, 0, 240, 255, 251, 0, 240, 255, 243, 0, 240, 31, 96, 0, 240, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "upleft2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 128, 255, 1, 0, 192, 255, 63, 0, 224, 255, 63, 0, 216, 255, 31, 0, 192, 255, 31, 0, 222, 255, 15, 0, 254, 255, 15, 0, 254, 255, 31, 0, 252, 255, 63, 0, 248, 255, 127, 14, 240, 255, 255, 15, 192, 255, 255, 15, 128, 255, 255, 7, 0, 254, 255, 15, 0, 252, 255, 15, 0, 248, 255, 63, 0, 240, 255, 127, 0, 240, 255, 127, 0, 240, 255, 127, 0, 240, 255, 127, 0, 224, 255, 255, 0, 224, 255, 248, 0, 192, 255, 240, 0, 128, 255, 240, 0, 0, 240, 224, 0, 0, 240, 192, 0, 0, 240, 0, 0, 0, 240, 0, 0, 0, 96, 0], + "upright1": [0, 224, 0, 0, 0, 224, 255, 1, 0, 224, 255, 3, 0, 224, 255, 7, 0, 224, 255, 111, 0, 224, 255, 31, 0, 224, 255, 15, 0, 224, 255, 15, 0, 252, 255, 15, 128, 255, 255, 7, 192, 255, 255, 7, 224, 255, 255, 3, 240, 255, 255, 0, 240, 255, 255, 0, 248, 255, 255, 0, 252, 255, 255, 0, 252, 255, 255, 0, 252, 255, 127, 0, 252, 255, 127, 0, 252, 255, 127, 0, 252, 255, 63, 0, 252, 255, 63, 0, 254, 255, 31, 0, 254, 255, 15, 0, 223, 255, 15, 0, 207, 255, 15, 0, 6, 248, 15, 0, 0, 192, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "upright2": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 128, 255, 1, 0, 252, 255, 3, 0, 252, 255, 7, 0, 248, 255, 31, 0, 248, 255, 31, 0, 240, 255, 127, 0, 240, 255, 127, 0, 248, 255, 127, 0, 252, 255, 63, 112, 254, 255, 31, 240, 255, 255, 15, 240, 255, 255, 3, 224, 255, 255, 1, 240, 255, 127, 0, 240, 255, 63, 0, 252, 255, 31, 0, 254, 255, 15, 0, 254, 255, 15, 0, 254, 255, 15, 0, 254, 255, 15, 0, 255, 255, 7, 0, 31, 255, 7, 0, 15, 255, 3, 0, 15, 255, 1, 0, 7, 15, 0, 0, 3, 15, 0, 0, 0, 15, 0, 0, 0, 15, 0, 0, 0, 6, 0, 0], + "utogi1": [0, 42, 50, 0, 0, 42, 122, 0, 128, 235, 123, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 254, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 128, 255, 255, 1, 128, 255, 255, 0, 0, 255, 127, 0, 0, 255, 127, 0, 0, 255, 255, 0, 0, 254, 127, 0, 0, 254, 127, 0, 0, 255, 255, 0, 128, 255, 255, 1, 128, 255, 255, 1, 128, 255, 255, 1, 176, 255, 255, 13, 248, 255, 255, 31, 248, 255, 255, 31, 240, 255, 255, 15, 224, 255, 255, 7, 0, 199, 227, 0, 0, 224, 3, 0, 0, 240, 1, 0, 0, 240, 0, 0, 0, 96, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "utogi2": [0, 38, 42, 0, 0, 47, 42, 0, 0, 239, 235, 0, 0, 255, 63, 0, 0, 255, 63, 0, 0, 255, 127, 0, 0, 255, 127, 0, 0, 255, 127, 0, 0, 255, 255, 0, 128, 255, 255, 1, 128, 255, 255, 0, 0, 255, 127, 0, 0, 255, 127, 0, 0, 255, 255, 0, 0, 254, 127, 0, 0, 254, 127, 0, 0, 255, 255, 0, 128, 255, 255, 1, 128, 255, 255, 1, 128, 255, 255, 1, 176, 255, 255, 13, 248, 255, 255, 31, 248, 255, 255, 31, 240, 255, 255, 15, 224, 255, 255, 7, 0, 199, 227, 0, 0, 192, 7, 0, 0, 128, 15, 0, 0, 0, 15, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ] +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..84b4b2d --- /dev/null +++ b/renovate.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":disableDependencyDashboard" + ], + "vulnerabilityAlerts": { + "enabled": true + } +}