From 691ea69038766db59572d8c3e80a7919927f7ae4 Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Thu, 6 Aug 2026 09:59:29 +0800 Subject: [PATCH] Initial commit: FlipClockSaver macOS screen saver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 翻页时钟屏保,纯 Core Graphics 手绘、无黑缝翻页动画 - 支持公历日期 / 农历(天干地支+生肖)显示 - 设置面板:整体缩放、显示日期、显示农历 - 跨进程设置同步(直写 plist 绕过 cfprefsd 缓存) --- .gitignore | 23 + FlipClockSaver.xcodeproj/project.pbxproj | 329 +++++++++ .../contents.xcworkspacedata | 7 + .../FlipClockSaver-Bridging-Header.h | 4 + FlipClockSaver/FlipClockSaver.swift | 631 ++++++++++++++++++ 5 files changed, 994 insertions(+) create mode 100644 .gitignore create mode 100644 FlipClockSaver.xcodeproj/project.pbxproj create mode 100644 FlipClockSaver.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 FlipClockSaver/FlipClockSaver-Bridging-Header.h create mode 100644 FlipClockSaver/FlipClockSaver.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f96107 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# macOS +.DS_Store + +# Xcode user state (per-machine, never commit) +*.xcuserstate +xcuserdata/ +UserInterfaceState.xcuserstate + +# Build artifacts +build/ +DerivedData/ +*.hmap +*.ipa +*.xcarchive +*.dSYM.zip +*.dSYM + +# WorkBuddy internal memory (tool data, not project source) +.workbuddy/ + +# Editor / OS junk +*.swp +*~ diff --git a/FlipClockSaver.xcodeproj/project.pbxproj b/FlipClockSaver.xcodeproj/project.pbxproj new file mode 100644 index 0000000..394ddaa --- /dev/null +++ b/FlipClockSaver.xcodeproj/project.pbxproj @@ -0,0 +1,329 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 110; + objects = { + +/* Begin PBXFileReference section */ + 7B3DB23F3022DC2F00B3B568 /* FlipClockSaver.saver */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FlipClockSaver.saver; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 7B3DB24E3022DC6000B3B568 /* Exceptions for "FlipClockSaver" folder in "FlipClockSaver" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "FlipClockSaver-Bridging-Header.h", + ); + target = 7B3DB23E3022DC2F00B3B568 /* FlipClockSaver */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 7B3DB2413022DC2F00B3B568 /* FlipClockSaver */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7B3DB24E3022DC6000B3B568 /* Exceptions for "FlipClockSaver" folder in "FlipClockSaver" target */, + ); + path = FlipClockSaver; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7B3DB23C3022DC2F00B3B568 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + files = ( + ); + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 7B3DB2353022DC2F00B3B568 = { + isa = PBXGroup; + children = ( + 7B3DB2413022DC2F00B3B568 /* FlipClockSaver */, + 7B3DB2403022DC2F00B3B568 /* Products */, + ); + sourceTree = ""; + }; + 7B3DB2403022DC2F00B3B568 /* Products */ = { + isa = PBXGroup; + children = ( + 7B3DB23F3022DC2F00B3B568 /* FlipClockSaver.saver */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 7B3DB23A3022DC2F00B3B568 /* Headers */ = { + isa = PBXHeadersBuildPhase; + files = ( + ); + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 7B3DB23E3022DC2F00B3B568 /* FlipClockSaver */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7B3DB2483022DC2F00B3B568 /* Build configuration list for PBXNativeTarget "FlipClockSaver" */; + buildPhases = ( + 7B3DB23A3022DC2F00B3B568 /* Headers */, + 7B3DB23B3022DC2F00B3B568 /* Sources */, + 7B3DB23C3022DC2F00B3B568 /* Frameworks */, + 7B3DB23D3022DC2F00B3B568 /* Resources */, + ); + buildRules = ( + ); + fileSystemSynchronizedGroups = ( + 7B3DB2413022DC2F00B3B568 /* FlipClockSaver */, + ); + name = FlipClockSaver; + productName = FlipClockSaver; + productReference = 7B3DB23F3022DC2F00B3B568 /* FlipClockSaver.saver */; + productType = "com.apple.product-type.bundle"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7B3DB2363022DC2F00B3B568 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastUpgradeCheck = 2700; + TargetAttributes = { + 7B3DB23E3022DC2F00B3B568 = { + CreatedOnToolsVersion = 27.0; + LastSwiftMigration = 2700; + }; + }; + }; + buildConfigurationList = 7B3DB2393022DC2F00B3B568 /* Build configuration list for PBXProject "FlipClockSaver" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 7B3DB2353022DC2F00B3B568; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 7B3DB2403022DC2F00B3B568 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7B3DB23E3022DC2F00B3B568 /* FlipClockSaver */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7B3DB23D3022DC2F00B3B568 /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + ); + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7B3DB23B3022DC2F00B3B568 /* Sources */ = { + isa = PBXSourcesBuildPhase; + files = ( + ); + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 7B3DB2463022DC2F00B3B568 /* Debug configuration for PBXProject "FlipClockSaver" */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = 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_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = 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_OBJC_ROOT_CLASS = YES_ERROR; + 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_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = WV853442TS; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + }; + name = Debug; + }; + 7B3DB2473022DC2F00B3B568 /* Release configuration for PBXProject "FlipClockSaver" */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = 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_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = 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_OBJC_ROOT_CLASS = YES_ERROR; + 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_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = WV853442TS; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + }; + name = Release; + }; + 7B3DB2493022DC2F00B3B568 /* Debug configuration for PBXNativeTarget "FlipClockSaver" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = WV853442TS; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = FlipClockSaverView; + INSTALL_PATH = "$(HOME)/Library/Screen Savers"; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.liucheng.FlipClockSaver; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "FlipClockSaver/FlipClockSaver-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + WRAPPER_EXTENSION = saver; + }; + name = Debug; + }; + 7B3DB24A3022DC2F00B3B568 /* Release configuration for PBXNativeTarget "FlipClockSaver" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = WV853442TS; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = FlipClockSaverView; + INSTALL_PATH = "$(HOME)/Library/Screen Savers"; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.liucheng.FlipClockSaver; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "FlipClockSaver/FlipClockSaver-Bridging-Header.h"; + SWIFT_VERSION = 6.0; + WRAPPER_EXTENSION = saver; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7B3DB2393022DC2F00B3B568 /* Build configuration list for PBXProject "FlipClockSaver" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7B3DB2463022DC2F00B3B568 /* Debug configuration for PBXProject "FlipClockSaver" */, + 7B3DB2473022DC2F00B3B568 /* Release configuration for PBXProject "FlipClockSaver" */, + ); + defaultConfigurationName = Release; + }; + 7B3DB2483022DC2F00B3B568 /* Build configuration list for PBXNativeTarget "FlipClockSaver" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7B3DB2493022DC2F00B3B568 /* Debug configuration for PBXNativeTarget "FlipClockSaver" */, + 7B3DB24A3022DC2F00B3B568 /* Release configuration for PBXNativeTarget "FlipClockSaver" */, + ); + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 7B3DB2363022DC2F00B3B568 /* Project object */; + validationLevel = 1; +} diff --git a/FlipClockSaver.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/FlipClockSaver.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/FlipClockSaver.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/FlipClockSaver/FlipClockSaver-Bridging-Header.h b/FlipClockSaver/FlipClockSaver-Bridging-Header.h new file mode 100644 index 0000000..1b2cb5d --- /dev/null +++ b/FlipClockSaver/FlipClockSaver-Bridging-Header.h @@ -0,0 +1,4 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + diff --git a/FlipClockSaver/FlipClockSaver.swift b/FlipClockSaver/FlipClockSaver.swift new file mode 100644 index 0000000..a59d68b --- /dev/null +++ b/FlipClockSaver/FlipClockSaver.swift @@ -0,0 +1,631 @@ +import ScreenSaver +import AppKit + +// MARK: - 单个翻页数字(Core Graphics 绘制,无 layer 拼接,彻底无黑缝) +@MainActor +final class FlipDigit { + var current: Int = -1 + private var previous: Int = -1 + private var animStart: TimeInterval = 0 + private var animating = false + let duration: TimeInterval = 0.55 + + func set(_ newValue: Int, at time: TimeInterval, animated: Bool) { + guard newValue != current else { return } + previous = current < 0 ? newValue : current + current = newValue + if animated { + animStart = time + animating = true + } else { + animating = false + } + } + + func advance(to time: TimeInterval) { + if animating && (time - animStart) >= duration { + animating = false + } + } + + func draw(in ctx: CGContext, + rect: CGRect, + radius: CGFloat, + font: NSFont, + cardBg: NSColor, + textColor: NSColor, + now: TimeInterval) { + let t01 = animating ? min(max((now - animStart) / duration, 0), 1) : 1 + let midY = rect.midY + let topRect = CGRect(x: rect.minX, y: midY, width: rect.width, height: rect.height / 2) + let botRect = CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: rect.height / 2) + + let topDigit = current + let botDigit = animating ? previous : current + + // 先铺整卡底色:中缝处底色即卡片色,彻底消除抗锯齿透出的黑缝 + ctx.saveGState() + let basePath = CGMutablePath() + basePath.addRoundedRect(in: rect, cornerWidth: radius, cornerHeight: radius) + ctx.addPath(basePath); ctx.clip() + ctx.setFillColor(cardBg.cgColor) + ctx.fill(rect) + ctx.restoreGState() + + drawCard(in: ctx, half: topRect, full: rect, digit: topDigit, + radius: radius, font: font, cardBg: cardBg, textColor: textColor) + drawCard(in: ctx, half: botRect, full: rect, digit: botDigit, + radius: radius, font: font, cardBg: cardBg, textColor: textColor) + + if animating && t01 < 1 { + if t01 < 0.5 { + // 上半翻片:旧值从直立翻走(0 → -90°),用 cos 模拟 3D 压缩 + let a = -t01 * 2 * (CGFloat.pi / 2) + let sy = max(cos(a), 0.0001) + ctx.saveGState() + ctx.beginPath(); ctx.addRect(topRect); ctx.clip() + ctx.translateBy(x: 0, y: midY) + ctx.scaleBy(x: 1, y: sy) + ctx.translateBy(x: 0, y: -midY) + drawCard(in: ctx, half: topRect, full: rect, digit: previous, + radius: radius, font: font, cardBg: cardBg, textColor: textColor) + ctx.setFillColor(CGColor(gray: 0, alpha: CGFloat(t01) * 0.40)) + ctx.fill(topRect) + ctx.restoreGState() + } else { + // 下半翻片:新值从水平翻下(90° → 0°) + let t2 = (t01 - 0.5) * 2 + let a = (1 - t2) * (CGFloat.pi / 2) + let sy = max(cos(a), 0.0001) + ctx.saveGState() + ctx.beginPath(); ctx.addRect(botRect); ctx.clip() + ctx.translateBy(x: 0, y: midY) + ctx.scaleBy(x: 1, y: sy) + ctx.translateBy(x: 0, y: -midY) + drawCard(in: ctx, half: botRect, full: rect, digit: current, + radius: radius, font: font, cardBg: cardBg, textColor: textColor) + ctx.setFillColor(CGColor(gray: 1, alpha: CGFloat(1 - t2) * 0.30)) + ctx.fill(botRect) + ctx.restoreGState() + } + } + + // 翻页钟铰链:一条细而自然的中缝分割线(位于翻片转轴处,始终可见) + drawHinge(in: ctx, rect: rect, midY: midY) + } + + // 细而自然的中缝:主线为柔和阴影,下方一抹微弱高光模拟折边受光 + private func drawHinge(in ctx: CGContext, rect: CGRect, midY: CGFloat) { + ctx.saveGState() + ctx.setFillColor(CGColor(gray: 0, alpha: 0.45)) + ctx.fill(CGRect(x: rect.minX, y: midY - 0.75, width: rect.width, height: 1.5)) + ctx.setFillColor(CGColor(gray: 1, alpha: 0.06)) + ctx.fill(CGRect(x: rect.minX, y: midY + 0.75, width: rect.width, height: 1)) + ctx.restoreGState() + } + + // 整卡圆角 + 半区域双重裁剪:外缘圆角、接缝直边,且上下连续绘制 → 无黑缝 + private func drawCard(in ctx: CGContext, + half: CGRect, + full: CGRect, + digit: Int, + radius: CGFloat, + font: NSFont, + cardBg: NSColor, + textColor: NSColor) { + ctx.saveGState() + let path = CGMutablePath() + path.addRoundedRect(in: full, cornerWidth: radius, cornerHeight: radius) + ctx.addPath(path); ctx.clip() + ctx.beginPath(); ctx.addRect(half); ctx.clip() + + ctx.setFillColor(cardBg.cgColor) + ctx.fill(full) + + // 数字在整卡内水平 + 垂直精确居中:先测量文字尺寸,再把绘制框对齐卡片中线 + let str = "\(digit)" as NSString + let size = str.size(withAttributes: [.font: font]) + let textRect = CGRect(x: full.minX, + y: full.midY - size.height / 2, + width: full.width, + height: size.height) + let ps = NSMutableParagraphStyle() + ps.alignment = .center + let attrs: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: textColor, + .paragraphStyle: ps + ] + str.draw(in: textRect, withAttributes: attrs) + ctx.restoreGState() + } +} + +// MARK: - 屏保主体 +@MainActor +@objc(FlipClockSaverView) +final class FlipClockSaverView: ScreenSaverView { + + // 顶部可配置项 + static let showSeconds = true + static let use24Hour = true + + // 尺寸 / 缩放(fitScale 可在设置面板中调整) + private let unitW: CGFloat = 108 + private let unitH: CGFloat = 160 + private let radius: CGFloat = 12 + private let spacing: CGFloat = 16 + private let colonW: CGFloat = 14 + private var fitScale: CGFloat = 0.55 + + // 设置面板可开关:日期 / 农历 + private var showDate = true + private var showLunar = true + private let infoRowH: CGFloat = 44 + private let infoGap: CGFloat = 34 + + private let cardBg = NSColor(white: 0.13, alpha: 1) + private let textColor = NSColor.white + private let bgColor = NSColor.black + private lazy var digitFont: NSFont = NSFont.boldSystemFont(ofSize: unitH * 0.74) + + private var digits: [FlipDigit] = [] + private var rects: [CGRect] = [] + private var colonRects: [CGRect] = [] + private var contentW: CGFloat = 1 + private var contentH: CGFloat = 1 + private var ampmRect: CGRect? + private var ampmString = "" + private var lastKey = -1 + private var frameTime: TimeInterval = 0 + private var lastSettingsCheck: TimeInterval = 0 + private var infoRect: CGRect = .zero + private var infoString = "" + private var cachedDayKey = -1 + private var cachedGregorian = "" + private var cachedLunar = "" + + override init?(frame frameRect: NSRect, isPreview: Bool) { + super.init(frame: frameRect, isPreview: isPreview) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + FlipClockSettings.registerDefaults() + loadSettings() + NSLog("[FlipClock] view 初始化: scale=%.2f date=%d lunar=%d", Double(fitScale), showDate, showLunar) + NotificationCenter.default.addObserver(self, selector: #selector(handleSettingsChanged(_:)), + name: .flipClockSettingsDidChange, object: nil) + frameTime = CACurrentMediaTime() + buildLayout() + update(animated: false, at: frameTime) + } + + private func buildLayout() { + // 卡片下方预留日期/农历行(仅在任一开关打开时占位) + let baseY: CGFloat = (showDate || showLunar) ? infoRowH + infoGap : 0 + var cur: CGFloat = 0 + func adv(_ w: CGFloat) -> CGRect { + let r = CGRect(x: cur, y: baseY, width: w, height: unitH) + cur += w + spacing + return r + } + let h0 = FlipDigit(); let h1 = FlipDigit() + let m0 = FlipDigit(); let m1 = FlipDigit() + let s0 = FlipDigit(); let s1 = FlipDigit() + + var ds: [FlipDigit] = [] + var rs: [CGRect] = [] + var cols: [CGRect] = [] + + func addDigit(_ d: FlipDigit) { ds.append(d); rs.append(adv(unitW)) } + func addColon() { cols.append(adv(colonW)) } + + addDigit(h0); addDigit(h1); addColon() + addDigit(m0); addDigit(m1) + if Self.showSeconds { addColon(); addDigit(s0); addDigit(s1) } + + digits = ds; rects = rs; colonRects = cols + + if !Self.use24Hour { + ampmRect = CGRect(x: cur, y: 0, width: 120, height: unitH) + cur += 120 + spacing + } + contentW = max(cur - spacing, 1) + contentH = baseY + unitH + infoRect = CGRect(x: 0, y: 0, width: contentW, height: infoRowH) + } + + private func update(animated: Bool, at time: TimeInterval) { + let now = Calendar.current.dateComponents([.hour, .minute, .second], from: Date()) + var h = now.hour ?? 0 + var ampm = "" + if !Self.use24Hour { + ampm = h >= 12 ? "PM" : "AM" + h = h % 12 + if h == 0 { h = 12 } + } + let hh = String(format: "%02d", h) + let mm = String(format: "%02d", now.minute ?? 0) + let ss = String(format: "%02d", now.second ?? 0) + var vals: [Int] = [ + Int(String(hh.prefix(1))) ?? 0, Int(String(hh.suffix(1))) ?? 0, + Int(String(mm.prefix(1))) ?? 0, Int(String(mm.suffix(1))) ?? 0 + ] + if Self.showSeconds { + vals.append(Int(String(ss.prefix(1))) ?? 0) + vals.append(Int(String(ss.suffix(1))) ?? 0) + } + for i in digits.indices { digits[i].set(vals[i], at: time, animated: animated) } + ampmString = ampm + refreshInfo() + } + + override var animationTimeInterval: TimeInterval { + get { 1.0 / 60 } + set {} + } + + override func animateOneFrame() { + let now = CACurrentMediaTime() + frameTime = now + let comps = Calendar.current.dateComponents([.hour, .minute, .second], from: Date()) + let key = (comps.hour ?? 0) * 3600 + (comps.minute ?? 0) * 60 + (comps.second ?? 0) + if key != lastKey { + lastKey = key + update(animated: true, at: now) + } + for d in digits { d.advance(to: now) } + syncSettingsIfNeeded(now: now) + setNeedsDisplay(bounds) + } + + // 轮询兜底:即使通知未送达(如跨进程/模块缓存),也能在约 0.5 秒内同步设置 + private func syncSettingsIfNeeded(now: TimeInterval) { + guard now - lastSettingsCheck > 0.5 else { return } + lastSettingsCheck = now + let newScale = FlipClockSettings.fitScale + let newShowDate = FlipClockSettings.showDate + let newShowLunar = FlipClockSettings.showLunar + guard newScale != fitScale || newShowDate != showDate || newShowLunar != showLunar else { return } + let togglesChanged = newShowDate != showDate || newShowLunar != showLunar + fitScale = newScale + showDate = newShowDate + showLunar = newShowLunar + if togglesChanged { + buildLayout() + update(animated: false, at: frameTime) + } + setNeedsDisplay(bounds) + } + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + ctx.setFillColor(bgColor.cgColor) + ctx.fill(bounds) + + let s = min(bounds.width / contentW, bounds.height / contentH) * fitScale + ctx.saveGState() + ctx.translateBy(x: bounds.width / 2, y: bounds.height / 2) + ctx.scaleBy(x: s, y: s) + ctx.translateBy(x: -contentW / 2, y: -contentH / 2) + + let now = frameTime + for i in digits.indices { + digits[i].draw(in: ctx, rect: rects[i], radius: radius, font: digitFont, + cardBg: cardBg, textColor: textColor, now: now) + } + for c in colonRects { drawColon(in: ctx, rect: c) } + if !Self.use24Hour, let ar = ampmRect { drawAmPm(in: ctx, rect: ar) } + if showDate || showLunar { drawInfo(in: ctx, rect: infoRect) } + ctx.restoreGState() + } + + private func drawColon(in ctx: CGContext, rect: CGRect) { + ctx.saveGState() + ctx.setFillColor(textColor.cgColor) + let cx = rect.midX + let r = min(rect.width, rect.height) * 0.06 + for cy in [rect.minY + rect.height * 0.62, rect.minY + rect.height * 0.38] { + ctx.fillEllipse(in: CGRect(x: cx - r, y: cy - r, width: r * 2, height: r * 2)) + } + ctx.restoreGState() + } + + private func drawAmPm(in ctx: CGContext, rect: CGRect) { + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.boldSystemFont(ofSize: unitH * 0.18), + .foregroundColor: textColor + ] + (ampmString as NSString).draw(in: rect, withAttributes: attrs) + } + + // MARK: 日期 / 农历 + private func refreshInfo() { + let date = Date() + let dayKey = Calendar.current.ordinality(of: .day, in: .era, for: date) ?? -1 + if dayKey != cachedDayKey { + cachedDayKey = dayKey + let df = DateFormatter() + df.locale = Locale(identifier: "zh_CN") + df.dateFormat = "yyyy年M月d日 EEEE" + cachedGregorian = df.string(from: date) + cachedLunar = Self.lunarString(from: date) + } + var parts: [String] = [] + if showDate { parts.append(cachedGregorian) } + if showLunar { parts.append(cachedLunar) } + infoString = parts.joined(separator: " · ") + } + + private static let tianGan = ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"] + private static let diZhi = ["子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"] + private static let shengXiao = ["鼠", "牛", "虎", "兔", "龙", "蛇", "马", "羊", "猴", "鸡", "狗", "猪"] + private static let lunarMonths = ["正月", "二月", "三月", "四月", "五月", "六月", + "七月", "八月", "九月", "冬月", "十一月", "腊月"] + private static let lunarDays = ["初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十", + "十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十", + "廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十"] + + // 农历:干支 + 生肖 + 月日,如“甲辰龙年正月初一”,闰月自动加“闰” + private static func lunarString(from date: Date) -> String { + let cal = Calendar(identifier: .chinese) + let c = cal.dateComponents([.year, .month, .day, .isLeapMonth], from: date) + let year = max(c.year ?? 1, 1) + let month = min(max(c.month ?? 1, 1), 12) + let day = min(max(c.day ?? 1, 1), 30) + let idx = (year - 1) % 60 + let ganZhi = tianGan[idx % 10] + diZhi[idx % 12] + let zodiac = shengXiao[idx % 12] + let monthName = ((c.isLeapMonth ?? false) ? "闰" : "") + lunarMonths[month - 1] + return "\(ganZhi)\(zodiac)年\(monthName)\(lunarDays[day - 1])" + } + + private func drawInfo(in ctx: CGContext, rect: CGRect) { + guard !infoString.isEmpty else { return } + ctx.saveGState() + let font = NSFont.systemFont(ofSize: unitH * 0.22, weight: .medium) + let size = (infoString as NSString).size(withAttributes: [.font: font]) + let r = CGRect(x: rect.midX - size.width / 2, + y: rect.midY - size.height / 2, + width: size.width, height: size.height) + let attrs: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: textColor.withAlphaComponent(0.72) + ] + (infoString as NSString).draw(in: r, withAttributes: attrs) + ctx.restoreGState() + } + + // MARK: 设置面板 + override var hasConfigureSheet: Bool { true } + // 持久保留所有 controller:NSControl.target 是弱引用, + // controller 一旦释放,已显示窗口里的滑杆/按钮就会失去响应 + private var configControllers: [FlipClockConfigController] = [] + override var configureSheet: NSWindow? { + // 每次打开都新建窗口:避免窗口被关闭后无法再次弹出 + let controller = FlipClockConfigController() + controller.reloadValues() + configControllers.append(controller) + return controller.window + } + + @objc private func handleSettingsChanged(_ note: Notification) { + let oldShowDate = showDate + let oldShowLunar = showLunar + if let info = note.userInfo, !info.isEmpty { + // 直接从通知携带的值应用,不依赖 defaults 读回路径 + if let v = info[FlipClockSettings.keyScale] as? Double { fitScale = CGFloat(v) } + if let v = info[FlipClockSettings.keyShowDate] as? Bool { showDate = v } + if let v = info[FlipClockSettings.keyShowLunar] as? Bool { showLunar = v } + } else { + loadSettings() + } + NSLog("[FlipClock] view 收到设置通知,已应用 scale=%.2f", Double(fitScale)) + if oldShowDate != showDate || oldShowLunar != showLunar { + buildLayout() + update(animated: false, at: frameTime) + } + setNeedsDisplay(bounds) + } + + private func loadSettings() { + fitScale = FlipClockSettings.fitScale + showDate = FlipClockSettings.showDate + showLunar = FlipClockSettings.showLunar + } +} + +// MARK: - 设置存储(ScreenSaverDefaults) +extension Notification.Name { + static let flipClockSettingsDidChange = Notification.Name("FlipClockSettingsDidChange") +} + +@MainActor +enum FlipClockSettings { + static let keyScale = "fitScale" + static let keyShowDate = "showDate" + static let keyShowLunar = "showLunar" + static let defaultScale = 0.55 + + static let defaults: ScreenSaverDefaults = { + let module = (Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String) ?? "FlipClockSaver" + return ScreenSaverDefaults(forModuleWithName: module)! + }() + + static func registerDefaults() { + defaults.register(defaults: [keyScale: defaultScale, keyShowDate: true, keyShowLunar: true]) + } + + // 主存储:直接读写 plist 文件,绕开 cfprefsd 缓存。 + // 壁纸模式下选项面板与屏保视图分属不同进程,只有直读磁盘才能保证跨进程即时可见。 + private static var settingsURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/FlipClockSaver", isDirectory: true) + .appendingPathComponent("settings.plist") + } + + private static func readFile() -> [String: Any] { + guard let data = try? Data(contentsOf: settingsURL), + let dict = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any] + else { return [:] } + return dict + } + + static func save(scale: Double, showDate: Bool, showLunar: Bool) { + let dict: [String: Any] = [keyScale: scale, keyShowDate: showDate, keyShowLunar: showLunar] + var fileOK = false + if let data = try? PropertyListSerialization.data(fromPropertyList: dict, format: .xml, options: 0) { + try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + do { + try data.write(to: settingsURL, options: [.atomic]) + fileOK = true + } catch { + NSLog("[FlipClock] 写设置文件失败: %@", "\(error)") + } + } + // 备份写 ScreenSaverDefaults + let d = defaults + d.set(scale, forKey: keyScale) + d.set(showDate, forKey: keyShowDate) + d.set(showLunar, forKey: keyShowLunar) + NSLog("[FlipClock] 设置已保存(fileOK=%d): scale=%.2f date=%d lunar=%d", fileOK, scale, showDate, showLunar) + } + + static var fitScale: CGFloat { + if let v = readFile()[keyScale] as? Double { return CGFloat(v) } + let d = defaults.double(forKey: keyScale) + return CGFloat(d > 0 ? d : defaultScale) + } + + static var showDate: Bool { + if let v = readFile()[keyShowDate] as? Bool { return v } + return defaults.object(forKey: keyShowDate) as? Bool ?? true + } + + static var showLunar: Bool { + if let v = readFile()[keyShowLunar] as? Bool { return v } + return defaults.object(forKey: keyShowLunar) as? Bool ?? true + } +} + +// MARK: - 设置窗口(纯代码构建,无 xib) +@MainActor +@objc(FlipClockConfigController) +final class FlipClockConfigController: NSWindowController { + + private let scaleSlider = NSSlider(value: 0.55, minValue: 0.30, maxValue: 1.00, target: nil, action: nil) + private let scaleLabel = NSTextField(labelWithString: "55%") + private let showDateBox = NSButton(checkboxWithTitle: "显示公历日期", target: nil, action: nil) + private let showLunarBox = NSButton(checkboxWithTitle: "显示农历", target: nil, action: nil) + + // 打开时的原始值,用于“取消”时回滚 + private var originalScale: Double = 0.55 + private var originalShowDate = true + private var originalShowLunar = true + + init() { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 340, height: 196), + styleMask: [.titled], backing: .buffered, defer: false) + window.title = "翻页时钟设置" + window.isReleasedWhenClosed = false + super.init(window: window) + buildUI() + reloadValues() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func reloadValues() { + originalScale = Double(FlipClockSettings.fitScale) + originalShowDate = FlipClockSettings.showDate + originalShowLunar = FlipClockSettings.showLunar + scaleSlider.doubleValue = originalScale + showDateBox.state = originalShowDate ? .on : .off + showLunarBox.state = originalShowLunar ? .on : .off + updateScaleLabel() + } + + private func updateScaleLabel() { + scaleLabel.stringValue = "\(Int((scaleSlider.doubleValue * 100).rounded()))%" + } + + private func buildUI() { + let content = NSView(frame: NSRect(x: 0, y: 0, width: 340, height: 196)) + window?.contentView = content + let left: CGFloat = 20 + + let titleLabel = NSTextField(labelWithString: "整体缩放:") + titleLabel.frame = CGRect(x: left, y: 152, width: 76, height: 17) + scaleSlider.frame = CGRect(x: left + 76, y: 148, width: 170, height: 24) + scaleSlider.target = self + scaleSlider.action = #selector(sliderChanged) + scaleLabel.frame = CGRect(x: left + 254, y: 152, width: 56, height: 17) + scaleLabel.alignment = .right + + showDateBox.frame = CGRect(x: left, y: 116, width: 200, height: 20) + showDateBox.target = self + showDateBox.action = #selector(controlChanged) + showLunarBox.frame = CGRect(x: left, y: 84, width: 200, height: 20) + showLunarBox.target = self + showLunarBox.action = #selector(controlChanged) + + let okBtn = NSButton(title: "好", target: self, action: #selector(okPressed)) + okBtn.bezelStyle = .rounded + okBtn.keyEquivalent = "\r" + okBtn.frame = CGRect(x: 244, y: 20, width: 76, height: 32) + + let cancelBtn = NSButton(title: "取消", target: self, action: #selector(cancelPressed)) + cancelBtn.bezelStyle = .rounded + cancelBtn.keyEquivalent = "\u{1b}" + cancelBtn.frame = CGRect(x: 160, y: 20, width: 76, height: 32) + + let views: [NSView] = [titleLabel, scaleSlider, scaleLabel, showDateBox, showLunarBox, okBtn, cancelBtn] + for v in views { content.addSubview(v) } + } + + @objc private func sliderChanged() { + updateScaleLabel() + applyCurrentValues() + } + + @objc private func controlChanged() { applyCurrentValues() } + + private func applyCurrentValues() { + apply(scale: scaleSlider.doubleValue, + showDate: showDateBox.state == .on, + showLunar: showLunarBox.state == .on) + } + + // 实时保存并广播(userInfo 携带最新值):同进程即时生效,跨进程靠文件轮询同步 + private func apply(scale: Double, showDate: Bool, showLunar: Bool) { + FlipClockSettings.save(scale: scale, showDate: showDate, showLunar: showLunar) + NotificationCenter.default.post(name: .flipClockSettingsDidChange, object: nil, + userInfo: [FlipClockSettings.keyScale: scale, + FlipClockSettings.keyShowDate: showDate, + FlipClockSettings.keyShowLunar: showLunar]) + } + + @objc private func okPressed() { dismissSheet() } + + @objc private func cancelPressed() { + // 恢复打开前的设置 + apply(scale: originalScale, showDate: originalShowDate, showLunar: originalShowLunar) + dismissSheet() + } + + private func dismissSheet() { + guard let win = window else { return } + if let parent = win.sheetParent { + parent.endSheet(win, returnCode: .OK) + } else { + win.orderOut(nil) + } + } +}