重构:拆分单文件为 4 个模块 + 设置面板显示版本号/构建号
- 拆分 FlipClockSaver.swift(680 行)为: * FlipDigit.swift(单翻页数字渲染+动画) * FlipClockSaverView.swift(屏保主体、布局、绘制、设置面板入口) * FlipClockSettings.swift(设置存储 + .flipClockSettingsDidChange 通知) * FlipClockConfigController.swift(设置窗口 UI) - 工程采用 PBXFileSystemSynchronizedRootGroup,新增/删除源文件由 Xcode 自动同步,无需改 pbxproj - 设置窗口底部新增版本信息:“版本 1.0 · Build 1” (从 Bundle(for:) 读取 CFBundleShortVersionString / CFBundleVersion,比 Bundle.main 更可靠) - Release 构建 BUILD SUCCEEDED,Developer ID 正式签名通过
This commit is contained in:
@@ -258,12 +258,12 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Manual;
|
|
||||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
CODE_SIGN_STYLE = Manual;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = WV853442TS;
|
DEVELOPMENT_TEAM = WV853442TS;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = TopFlipClock;
|
INFOPLIST_KEY_CFBundleDisplayName = TopFlipClock;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
@@ -286,12 +286,12 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Manual;
|
|
||||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
CODE_SIGN_STYLE = Manual;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = WV853442TS;
|
DEVELOPMENT_TEAM = WV853442TS;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = TopFlipClock;
|
INFOPLIST_KEY_CFBundleDisplayName = TopFlipClock;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
// 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 let showSecondsBox = NSButton(checkboxWithTitle: "显示秒数", target: nil, action: nil)
|
||||||
|
private let use24Box = NSButton(checkboxWithTitle: "24 小时制", target: nil, action: nil)
|
||||||
|
|
||||||
|
// 打开时的原始值,用于“取消”时回滚
|
||||||
|
private var originalScale: Double = 0.55
|
||||||
|
private var originalShowDate = true
|
||||||
|
private var originalShowLunar = true
|
||||||
|
private var originalShowSeconds = true
|
||||||
|
private var originalUse24Hour = true
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 340, height: 256),
|
||||||
|
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") }
|
||||||
|
|
||||||
|
// 从 .saver bundle 读取版本号与构建号(用 Bundle(for:) 取本类所在 bundle,
|
||||||
|
// 比 Bundle.main 更可靠——运行时 main bundle 可能是屏保宿主进程)
|
||||||
|
private var versionText: String {
|
||||||
|
let b = Bundle(for: FlipClockConfigController.self)
|
||||||
|
let v = (b.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "1.0"
|
||||||
|
let n = (b.infoDictionary?["CFBundleVersion"] as? String) ?? "1"
|
||||||
|
return "版本 \(v) · Build \(n)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadValues() {
|
||||||
|
originalScale = Double(FlipClockSettings.fitScale)
|
||||||
|
originalShowDate = FlipClockSettings.showDate
|
||||||
|
originalShowLunar = FlipClockSettings.showLunar
|
||||||
|
originalShowSeconds = FlipClockSettings.showSeconds
|
||||||
|
originalUse24Hour = FlipClockSettings.use24Hour
|
||||||
|
scaleSlider.doubleValue = originalScale
|
||||||
|
showDateBox.state = originalShowDate ? .on : .off
|
||||||
|
showLunarBox.state = originalShowLunar ? .on : .off
|
||||||
|
showSecondsBox.state = originalShowSeconds ? .on : .off
|
||||||
|
use24Box.state = originalUse24Hour ? .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: 256))
|
||||||
|
window?.contentView = content
|
||||||
|
let left: CGFloat = 20
|
||||||
|
|
||||||
|
let titleLabel = NSTextField(labelWithString: "整体缩放:")
|
||||||
|
titleLabel.frame = CGRect(x: left, y: 212, width: 76, height: 17)
|
||||||
|
scaleSlider.frame = CGRect(x: left + 76, y: 208, width: 170, height: 24)
|
||||||
|
scaleSlider.target = self
|
||||||
|
scaleSlider.action = #selector(sliderChanged)
|
||||||
|
scaleLabel.frame = CGRect(x: left + 254, y: 212, width: 56, height: 17)
|
||||||
|
scaleLabel.alignment = .right
|
||||||
|
|
||||||
|
showDateBox.frame = CGRect(x: left, y: 176, width: 200, height: 20)
|
||||||
|
showDateBox.target = self
|
||||||
|
showDateBox.action = #selector(controlChanged)
|
||||||
|
showLunarBox.frame = CGRect(x: left, y: 144, width: 200, height: 20)
|
||||||
|
showLunarBox.target = self
|
||||||
|
showLunarBox.action = #selector(controlChanged)
|
||||||
|
showSecondsBox.frame = CGRect(x: left, y: 112, width: 200, height: 20)
|
||||||
|
showSecondsBox.target = self
|
||||||
|
showSecondsBox.action = #selector(controlChanged)
|
||||||
|
use24Box.frame = CGRect(x: left, y: 80, width: 200, height: 20)
|
||||||
|
use24Box.target = self
|
||||||
|
use24Box.action = #selector(controlChanged)
|
||||||
|
|
||||||
|
let okBtn = NSButton(title: "好", target: self, action: #selector(okPressed))
|
||||||
|
okBtn.bezelStyle = .rounded
|
||||||
|
okBtn.keyEquivalent = "\r"
|
||||||
|
okBtn.frame = CGRect(x: 244, y: 24, 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: 24, width: 76, height: 32)
|
||||||
|
|
||||||
|
let versionLabel = NSTextField(labelWithString: versionText)
|
||||||
|
versionLabel.font = NSFont.systemFont(ofSize: 10)
|
||||||
|
versionLabel.textColor = NSColor.tertiaryLabelColor
|
||||||
|
versionLabel.frame = CGRect(x: left, y: 8, width: 220, height: 14)
|
||||||
|
|
||||||
|
let views: [NSView] = [titleLabel, scaleSlider, scaleLabel, showDateBox, showLunarBox,
|
||||||
|
showSecondsBox, use24Box, okBtn, cancelBtn, versionLabel]
|
||||||
|
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,
|
||||||
|
showSeconds: showSecondsBox.state == .on,
|
||||||
|
use24Hour: use24Box.state == .on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实时保存并广播(userInfo 携带最新值):同进程即时生效,跨进程靠文件轮询同步
|
||||||
|
private func apply(scale: Double, showDate: Bool, showLunar: Bool, showSeconds: Bool, use24Hour: Bool) {
|
||||||
|
FlipClockSettings.save(scale: scale, showDate: showDate, showLunar: showLunar,
|
||||||
|
showSeconds: showSeconds, use24Hour: use24Hour)
|
||||||
|
NotificationCenter.default.post(name: .flipClockSettingsDidChange, object: nil,
|
||||||
|
userInfo: [FlipClockSettings.keyScale: scale,
|
||||||
|
FlipClockSettings.keyShowDate: showDate,
|
||||||
|
FlipClockSettings.keyShowLunar: showLunar,
|
||||||
|
FlipClockSettings.keyShowSeconds: showSeconds,
|
||||||
|
FlipClockSettings.keyUse24Hour: use24Hour])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func okPressed() { dismissSheet() }
|
||||||
|
|
||||||
|
@objc private func cancelPressed() {
|
||||||
|
// 恢复打开前的设置
|
||||||
|
apply(scale: originalScale, showDate: originalShowDate, showLunar: originalShowLunar,
|
||||||
|
showSeconds: originalShowSeconds, use24Hour: originalUse24Hour)
|
||||||
|
dismissSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissSheet() {
|
||||||
|
guard let win = window else { return }
|
||||||
|
if let parent = win.sheetParent {
|
||||||
|
parent.endSheet(win, returnCode: .OK)
|
||||||
|
} else {
|
||||||
|
win.orderOut(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
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 {
|
|
||||||
|
|
||||||
// 顶部可配置项(运行时由设置驱动,不再写死)
|
|
||||||
private var showSeconds = true
|
|
||||||
private var 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 showSeconds { addColon(); addDigit(s0); addDigit(s1) }
|
|
||||||
|
|
||||||
digits = ds; rects = rs; colonRects = cols
|
|
||||||
|
|
||||||
if !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 !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 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
|
|
||||||
let newShowSeconds = FlipClockSettings.showSeconds
|
|
||||||
let newUse24Hour = FlipClockSettings.use24Hour
|
|
||||||
guard newScale != fitScale || newShowDate != showDate || newShowLunar != showLunar
|
|
||||||
|| newShowSeconds != showSeconds || newUse24Hour != use24Hour else { return }
|
|
||||||
let togglesChanged = newShowDate != showDate || newShowLunar != showLunar
|
|
||||||
|| newShowSeconds != showSeconds || newUse24Hour != use24Hour
|
|
||||||
fitScale = newScale
|
|
||||||
showDate = newShowDate
|
|
||||||
showLunar = newShowLunar
|
|
||||||
showSeconds = newShowSeconds
|
|
||||||
use24Hour = newUse24Hour
|
|
||||||
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 !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.12
|
|
||||||
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/window:每次点“选项”框架都会重新调用 configureSheet,
|
|
||||||
// 返回同一个已存在的 window 即可反复弹出。若每次都新建,框架缓存的仍是首个窗口,
|
|
||||||
// 关掉后再次点击会复用那个已 endSheet 隐藏的旧窗口而无反应(只能重启“系统设置”才能再开)。
|
|
||||||
private lazy var configController: FlipClockConfigController = FlipClockConfigController()
|
|
||||||
override var configureSheet: NSWindow? {
|
|
||||||
configController.reloadValues()
|
|
||||||
return configController.window
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func handleSettingsChanged(_ note: Notification) {
|
|
||||||
let oldShowDate = showDate
|
|
||||||
let oldShowLunar = showLunar
|
|
||||||
let oldShowSeconds = showSeconds
|
|
||||||
let oldUse24Hour = use24Hour
|
|
||||||
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 }
|
|
||||||
if let v = info[FlipClockSettings.keyShowSeconds] as? Bool { showSeconds = v }
|
|
||||||
if let v = info[FlipClockSettings.keyUse24Hour] as? Bool { use24Hour = v }
|
|
||||||
} else {
|
|
||||||
loadSettings()
|
|
||||||
}
|
|
||||||
NSLog("[FlipClock] view 收到设置通知,已应用 scale=%.2f", Double(fitScale))
|
|
||||||
if oldShowDate != showDate || oldShowLunar != showLunar
|
|
||||||
|| oldShowSeconds != showSeconds || oldUse24Hour != use24Hour {
|
|
||||||
buildLayout()
|
|
||||||
update(animated: false, at: frameTime)
|
|
||||||
}
|
|
||||||
setNeedsDisplay(bounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadSettings() {
|
|
||||||
fitScale = FlipClockSettings.fitScale
|
|
||||||
showDate = FlipClockSettings.showDate
|
|
||||||
showLunar = FlipClockSettings.showLunar
|
|
||||||
showSeconds = FlipClockSettings.showSeconds
|
|
||||||
use24Hour = FlipClockSettings.use24Hour
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 keyShowSeconds = "showSeconds"
|
|
||||||
static let keyUse24Hour = "use24Hour"
|
|
||||||
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,
|
|
||||||
keyShowSeconds: true, keyUse24Hour: 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, showSeconds: Bool, use24Hour: Bool) {
|
|
||||||
let dict: [String: Any] = [keyScale: scale, keyShowDate: showDate, keyShowLunar: showLunar,
|
|
||||||
keyShowSeconds: showSeconds, keyUse24Hour: use24Hour]
|
|
||||||
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)
|
|
||||||
d.set(showSeconds, forKey: keyShowSeconds)
|
|
||||||
d.set(use24Hour, forKey: keyUse24Hour)
|
|
||||||
NSLog("[FlipClock] 设置已保存(fileOK=%d): scale=%.2f date=%d lunar=%d seconds=%d hour24=%d",
|
|
||||||
fileOK, scale, showDate, showLunar, showSeconds, use24Hour)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
static var showSeconds: Bool {
|
|
||||||
if let v = readFile()[keyShowSeconds] as? Bool { return v }
|
|
||||||
return defaults.object(forKey: keyShowSeconds) as? Bool ?? true
|
|
||||||
}
|
|
||||||
|
|
||||||
static var use24Hour: Bool {
|
|
||||||
if let v = readFile()[keyUse24Hour] as? Bool { return v }
|
|
||||||
return defaults.object(forKey: keyUse24Hour) 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 let showSecondsBox = NSButton(checkboxWithTitle: "显示秒数", target: nil, action: nil)
|
|
||||||
private let use24Box = NSButton(checkboxWithTitle: "24 小时制", target: nil, action: nil)
|
|
||||||
|
|
||||||
// 打开时的原始值,用于“取消”时回滚
|
|
||||||
private var originalScale: Double = 0.55
|
|
||||||
private var originalShowDate = true
|
|
||||||
private var originalShowLunar = true
|
|
||||||
private var originalShowSeconds = true
|
|
||||||
private var originalUse24Hour = true
|
|
||||||
|
|
||||||
init() {
|
|
||||||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 340, height: 256),
|
|
||||||
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
|
|
||||||
originalShowSeconds = FlipClockSettings.showSeconds
|
|
||||||
originalUse24Hour = FlipClockSettings.use24Hour
|
|
||||||
scaleSlider.doubleValue = originalScale
|
|
||||||
showDateBox.state = originalShowDate ? .on : .off
|
|
||||||
showLunarBox.state = originalShowLunar ? .on : .off
|
|
||||||
showSecondsBox.state = originalShowSeconds ? .on : .off
|
|
||||||
use24Box.state = originalUse24Hour ? .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: 256))
|
|
||||||
window?.contentView = content
|
|
||||||
let left: CGFloat = 20
|
|
||||||
|
|
||||||
let titleLabel = NSTextField(labelWithString: "整体缩放:")
|
|
||||||
titleLabel.frame = CGRect(x: left, y: 212, width: 76, height: 17)
|
|
||||||
scaleSlider.frame = CGRect(x: left + 76, y: 208, width: 170, height: 24)
|
|
||||||
scaleSlider.target = self
|
|
||||||
scaleSlider.action = #selector(sliderChanged)
|
|
||||||
scaleLabel.frame = CGRect(x: left + 254, y: 212, width: 56, height: 17)
|
|
||||||
scaleLabel.alignment = .right
|
|
||||||
|
|
||||||
showDateBox.frame = CGRect(x: left, y: 176, width: 200, height: 20)
|
|
||||||
showDateBox.target = self
|
|
||||||
showDateBox.action = #selector(controlChanged)
|
|
||||||
showLunarBox.frame = CGRect(x: left, y: 144, width: 200, height: 20)
|
|
||||||
showLunarBox.target = self
|
|
||||||
showLunarBox.action = #selector(controlChanged)
|
|
||||||
showSecondsBox.frame = CGRect(x: left, y: 112, width: 200, height: 20)
|
|
||||||
showSecondsBox.target = self
|
|
||||||
showSecondsBox.action = #selector(controlChanged)
|
|
||||||
use24Box.frame = CGRect(x: left, y: 80, width: 200, height: 20)
|
|
||||||
use24Box.target = self
|
|
||||||
use24Box.action = #selector(controlChanged)
|
|
||||||
|
|
||||||
let okBtn = NSButton(title: "好", target: self, action: #selector(okPressed))
|
|
||||||
okBtn.bezelStyle = .rounded
|
|
||||||
okBtn.keyEquivalent = "\r"
|
|
||||||
okBtn.frame = CGRect(x: 244, y: 24, 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: 24, width: 76, height: 32)
|
|
||||||
|
|
||||||
let views: [NSView] = [titleLabel, scaleSlider, scaleLabel, showDateBox, showLunarBox,
|
|
||||||
showSecondsBox, use24Box, 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,
|
|
||||||
showSeconds: showSecondsBox.state == .on,
|
|
||||||
use24Hour: use24Box.state == .on)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实时保存并广播(userInfo 携带最新值):同进程即时生效,跨进程靠文件轮询同步
|
|
||||||
private func apply(scale: Double, showDate: Bool, showLunar: Bool, showSeconds: Bool, use24Hour: Bool) {
|
|
||||||
FlipClockSettings.save(scale: scale, showDate: showDate, showLunar: showLunar,
|
|
||||||
showSeconds: showSeconds, use24Hour: use24Hour)
|
|
||||||
NotificationCenter.default.post(name: .flipClockSettingsDidChange, object: nil,
|
|
||||||
userInfo: [FlipClockSettings.keyScale: scale,
|
|
||||||
FlipClockSettings.keyShowDate: showDate,
|
|
||||||
FlipClockSettings.keyShowLunar: showLunar,
|
|
||||||
FlipClockSettings.keyShowSeconds: showSeconds,
|
|
||||||
FlipClockSettings.keyUse24Hour: use24Hour])
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func okPressed() { dismissSheet() }
|
|
||||||
|
|
||||||
@objc private func cancelPressed() {
|
|
||||||
// 恢复打开前的设置
|
|
||||||
apply(scale: originalScale, showDate: originalShowDate, showLunar: originalShowLunar,
|
|
||||||
showSeconds: originalShowSeconds, use24Hour: originalUse24Hour)
|
|
||||||
dismissSheet()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dismissSheet() {
|
|
||||||
guard let win = window else { return }
|
|
||||||
if let parent = win.sheetParent {
|
|
||||||
parent.endSheet(win, returnCode: .OK)
|
|
||||||
} else {
|
|
||||||
win.orderOut(nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import ScreenSaver
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
// MARK: - 屏保主体
|
||||||
|
@MainActor
|
||||||
|
@objc(FlipClockSaverView)
|
||||||
|
final class FlipClockSaverView: ScreenSaverView {
|
||||||
|
|
||||||
|
// 顶部可配置项(运行时由设置驱动,不再写死)
|
||||||
|
private var showSeconds = true
|
||||||
|
private var 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 showSeconds { addColon(); addDigit(s0); addDigit(s1) }
|
||||||
|
|
||||||
|
digits = ds; rects = rs; colonRects = cols
|
||||||
|
|
||||||
|
if !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 !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 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
|
||||||
|
let newShowSeconds = FlipClockSettings.showSeconds
|
||||||
|
let newUse24Hour = FlipClockSettings.use24Hour
|
||||||
|
guard newScale != fitScale || newShowDate != showDate || newShowLunar != showLunar
|
||||||
|
|| newShowSeconds != showSeconds || newUse24Hour != use24Hour else { return }
|
||||||
|
let togglesChanged = newShowDate != showDate || newShowLunar != showLunar
|
||||||
|
|| newShowSeconds != showSeconds || newUse24Hour != use24Hour
|
||||||
|
fitScale = newScale
|
||||||
|
showDate = newShowDate
|
||||||
|
showLunar = newShowLunar
|
||||||
|
showSeconds = newShowSeconds
|
||||||
|
use24Hour = newUse24Hour
|
||||||
|
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 !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.12
|
||||||
|
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/window:每次点“选项”框架都会重新调用 configureSheet,
|
||||||
|
// 返回同一个已存在的 window 即可反复弹出。若每次都新建,框架缓存的仍是首个窗口,
|
||||||
|
// 关掉后再次点击会复用那个已 endSheet 隐藏的旧窗口而无反应(只能重启“系统设置”才能再开)。
|
||||||
|
private lazy var configController: FlipClockConfigController = FlipClockConfigController()
|
||||||
|
override var configureSheet: NSWindow? {
|
||||||
|
configController.reloadValues()
|
||||||
|
return configController.window
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleSettingsChanged(_ note: Notification) {
|
||||||
|
let oldShowDate = showDate
|
||||||
|
let oldShowLunar = showLunar
|
||||||
|
let oldShowSeconds = showSeconds
|
||||||
|
let oldUse24Hour = use24Hour
|
||||||
|
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 }
|
||||||
|
if let v = info[FlipClockSettings.keyShowSeconds] as? Bool { showSeconds = v }
|
||||||
|
if let v = info[FlipClockSettings.keyUse24Hour] as? Bool { use24Hour = v }
|
||||||
|
} else {
|
||||||
|
loadSettings()
|
||||||
|
}
|
||||||
|
NSLog("[FlipClock] view 收到设置通知,已应用 scale=%.2f", Double(fitScale))
|
||||||
|
if oldShowDate != showDate || oldShowLunar != showLunar
|
||||||
|
|| oldShowSeconds != showSeconds || oldUse24Hour != use24Hour {
|
||||||
|
buildLayout()
|
||||||
|
update(animated: false, at: frameTime)
|
||||||
|
}
|
||||||
|
setNeedsDisplay(bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSettings() {
|
||||||
|
fitScale = FlipClockSettings.fitScale
|
||||||
|
showDate = FlipClockSettings.showDate
|
||||||
|
showLunar = FlipClockSettings.showLunar
|
||||||
|
showSeconds = FlipClockSettings.showSeconds
|
||||||
|
use24Hour = FlipClockSettings.use24Hour
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import Foundation
|
||||||
|
import ScreenSaver
|
||||||
|
|
||||||
|
// 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 keyShowSeconds = "showSeconds"
|
||||||
|
static let keyUse24Hour = "use24Hour"
|
||||||
|
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,
|
||||||
|
keyShowSeconds: true, keyUse24Hour: 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, showSeconds: Bool, use24Hour: Bool) {
|
||||||
|
let dict: [String: Any] = [keyScale: scale, keyShowDate: showDate, keyShowLunar: showLunar,
|
||||||
|
keyShowSeconds: showSeconds, keyUse24Hour: use24Hour]
|
||||||
|
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)
|
||||||
|
d.set(showSeconds, forKey: keyShowSeconds)
|
||||||
|
d.set(use24Hour, forKey: keyUse24Hour)
|
||||||
|
NSLog("[FlipClock] 设置已保存(fileOK=%d): scale=%.2f date=%d lunar=%d seconds=%d hour24=%d",
|
||||||
|
fileOK, scale, showDate, showLunar, showSeconds, use24Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
static var showSeconds: Bool {
|
||||||
|
if let v = readFile()[keyShowSeconds] as? Bool { return v }
|
||||||
|
return defaults.object(forKey: keyShowSeconds) as? Bool ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
static var use24Hour: Bool {
|
||||||
|
if let v = readFile()[keyUse24Hour] as? Bool { return v }
|
||||||
|
return defaults.object(forKey: keyUse24Hour) as? Bool ?? true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user