diff --git a/FlipClockSaver.xcodeproj/project.pbxproj b/FlipClockSaver.xcodeproj/project.pbxproj index a8cbaa5..33c2659 100644 --- a/FlipClockSaver.xcodeproj/project.pbxproj +++ b/FlipClockSaver.xcodeproj/project.pbxproj @@ -258,12 +258,12 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Manual; - CODE_SIGN_IDENTITY = "Developer ID Application"; - ENABLE_HARDENED_RUNTIME = YES; - COMBINE_HIDPI_IMAGES = YES; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = WV853442TS; + ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = TopFlipClock; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -286,12 +286,12 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Manual; - CODE_SIGN_IDENTITY = "Developer ID Application"; - ENABLE_HARDENED_RUNTIME = YES; - COMBINE_HIDPI_IMAGES = YES; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = WV853442TS; + ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = TopFlipClock; INFOPLIST_KEY_NSHumanReadableCopyright = ""; diff --git a/FlipClockSaver/FlipClockConfigController.swift b/FlipClockSaver/FlipClockConfigController.swift new file mode 100644 index 0000000..cb91d93 --- /dev/null +++ b/FlipClockSaver/FlipClockConfigController.swift @@ -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) + } + } +} diff --git a/FlipClockSaver/FlipClockSaver.swift b/FlipClockSaver/FlipClockSaver.swift deleted file mode 100644 index bdc5581..0000000 --- a/FlipClockSaver/FlipClockSaver.swift +++ /dev/null @@ -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) - } - } -} diff --git a/FlipClockSaver/FlipClockSaverView.swift b/FlipClockSaver/FlipClockSaverView.swift new file mode 100644 index 0000000..b47b081 --- /dev/null +++ b/FlipClockSaver/FlipClockSaverView.swift @@ -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 + } +} diff --git a/FlipClockSaver/FlipClockSettings.swift b/FlipClockSaver/FlipClockSettings.swift new file mode 100644 index 0000000..1839828 --- /dev/null +++ b/FlipClockSaver/FlipClockSettings.swift @@ -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 + } +} diff --git a/FlipClockSaver/FlipDigit.swift b/FlipClockSaver/FlipDigit.swift new file mode 100644 index 0000000..2dd5b9a --- /dev/null +++ b/FlipClockSaver/FlipDigit.swift @@ -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() + } +}