Initial commit: FlipClockSaver macOS screen saver

- 翻页时钟屏保,纯 Core Graphics 手绘、无黑缝翻页动画
- 支持公历日期 / 农历(天干地支+生肖)显示
- 设置面板:整体缩放、显示日期、显示农历
- 跨进程设置同步(直写 plist 绕过 cfprefsd 缓存)
This commit is contained in:
2026-08-06 09:59:29 +08:00
commit 691ea69038
5 changed files with 994 additions and 0 deletions
+23
View File
@@ -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
*~
+329
View File
@@ -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 = "<group>";
};
/* 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 = "<group>";
};
7B3DB2403022DC2F00B3B568 /* Products */ = {
isa = PBXGroup;
children = (
7B3DB23F3022DC2F00B3B568 /* FlipClockSaver.saver */,
);
name = Products;
sourceTree = "<group>";
};
/* 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;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,4 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
+631
View File
@@ -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 }
// controllerNSControl.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)
}
}
}