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