diff --git a/KeyStats/Assets.xcassets/DynamicColorTip1.imageset/Contents.json b/KeyStats/Assets.xcassets/DynamicColorTip1.imageset/Contents.json new file mode 100644 index 0000000..ffd6544 --- /dev/null +++ b/KeyStats/Assets.xcassets/DynamicColorTip1.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tip1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KeyStats/Assets.xcassets/DynamicColorTip1.imageset/tip1.png b/KeyStats/Assets.xcassets/DynamicColorTip1.imageset/tip1.png new file mode 100644 index 0000000..e7dfc70 Binary files /dev/null and b/KeyStats/Assets.xcassets/DynamicColorTip1.imageset/tip1.png differ diff --git a/KeyStats/Assets.xcassets/DynamicColorTip2.imageset/Contents.json b/KeyStats/Assets.xcassets/DynamicColorTip2.imageset/Contents.json new file mode 100644 index 0000000..6d14b39 --- /dev/null +++ b/KeyStats/Assets.xcassets/DynamicColorTip2.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tip2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KeyStats/Assets.xcassets/DynamicColorTip2.imageset/tip2.png b/KeyStats/Assets.xcassets/DynamicColorTip2.imageset/tip2.png new file mode 100644 index 0000000..dce5535 Binary files /dev/null and b/KeyStats/Assets.xcassets/DynamicColorTip2.imageset/tip2.png differ diff --git a/KeyStats/Assets.xcassets/DynamicColorTip3.imageset/Contents.json b/KeyStats/Assets.xcassets/DynamicColorTip3.imageset/Contents.json new file mode 100644 index 0000000..0bca63e --- /dev/null +++ b/KeyStats/Assets.xcassets/DynamicColorTip3.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tip3.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KeyStats/Assets.xcassets/DynamicColorTip3.imageset/tip3.png b/KeyStats/Assets.xcassets/DynamicColorTip3.imageset/tip3.png new file mode 100644 index 0000000..15f75a1 Binary files /dev/null and b/KeyStats/Assets.xcassets/DynamicColorTip3.imageset/tip3.png differ diff --git a/KeyStats/Assets.xcassets/DynamicColorTip4.imageset/Contents.json b/KeyStats/Assets.xcassets/DynamicColorTip4.imageset/Contents.json new file mode 100644 index 0000000..2e77718 --- /dev/null +++ b/KeyStats/Assets.xcassets/DynamicColorTip4.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tip4.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KeyStats/Assets.xcassets/DynamicColorTip4.imageset/tip4.png b/KeyStats/Assets.xcassets/DynamicColorTip4.imageset/tip4.png new file mode 100644 index 0000000..59b8791 Binary files /dev/null and b/KeyStats/Assets.xcassets/DynamicColorTip4.imageset/tip4.png differ diff --git a/KeyStats/Assets.xcassets/DynamicColorTip5.imageset/Contents.json b/KeyStats/Assets.xcassets/DynamicColorTip5.imageset/Contents.json new file mode 100644 index 0000000..9a51522 --- /dev/null +++ b/KeyStats/Assets.xcassets/DynamicColorTip5.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tip5.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KeyStats/Assets.xcassets/DynamicColorTip5.imageset/tip5.png b/KeyStats/Assets.xcassets/DynamicColorTip5.imageset/tip5.png new file mode 100644 index 0000000..7444997 Binary files /dev/null and b/KeyStats/Assets.xcassets/DynamicColorTip5.imageset/tip5.png differ diff --git a/KeyStats/Assets.xcassets/DynamicColorTip6.imageset/Contents.json b/KeyStats/Assets.xcassets/DynamicColorTip6.imageset/Contents.json new file mode 100644 index 0000000..20cb6d6 --- /dev/null +++ b/KeyStats/Assets.xcassets/DynamicColorTip6.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tip6.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KeyStats/Assets.xcassets/DynamicColorTip6.imageset/tip6.png b/KeyStats/Assets.xcassets/DynamicColorTip6.imageset/tip6.png new file mode 100644 index 0000000..36fe09b Binary files /dev/null and b/KeyStats/Assets.xcassets/DynamicColorTip6.imageset/tip6.png differ diff --git a/KeyStats/MenuBarController.swift b/KeyStats/MenuBarController.swift index 3f298e6..7eb61d7 100644 --- a/KeyStats/MenuBarController.swift +++ b/KeyStats/MenuBarController.swift @@ -1,5 +1,10 @@ import Cocoa +enum DynamicIconColorStyle: String { + case icon + case dot +} + /// 菜单栏控制器 class MenuBarController { @@ -7,6 +12,7 @@ class MenuBarController { private var statusView: MenuBarStatusView? private var popover: NSPopover! private var eventMonitor: Any? + private let dynamicIconColorStyleKey = "dynamicIconColorStyle" init() { setupStatusItem() @@ -99,12 +105,21 @@ class MenuBarController { private func updateMenuBarAppearance() { let parts = StatsManager.shared.getMenuBarTextParts() + let tintColor = StatsManager.shared.enableDynamicIconColor + ? StatsManager.shared.currentIconTintColor + : nil + let styleValue = UserDefaults.standard.string(forKey: dynamicIconColorStyleKey) ?? DynamicIconColorStyle.icon.rawValue + let style = DynamicIconColorStyle(rawValue: styleValue) ?? .icon + if let statusView = statusView { statusView.update(keysText: parts.keys, clicksText: parts.clicks) + statusView.updateIconColor(tintColor, style: style) statusItem.length = statusView.intrinsicContentSize.width } else if let button = statusItem.button { button.attributedTitle = makeStatusTitle(keysText: parts.keys, clicksText: parts.clicks) + button.contentTintColor = style == .icon ? tintColor : nil } + } private func makeStatusTitle(keysText: String, clicksText: String) -> NSAttributedString { @@ -154,7 +169,9 @@ class MenuBarController { // MARK: - 菜单栏自定义视图 class MenuBarStatusView: NSView { + private let iconContainer = NSView() private let imageView = NSImageView() + private let colorDotView = NSView() private let topLabel = NSTextField(labelWithString: "0") private let bottomLabel = NSTextField(labelWithString: "0") private let stack = NSStackView() @@ -192,6 +209,16 @@ class MenuBarStatusView: NSView { imageView.imageAlignment = .alignCenter imageView.contentTintColor = .labelColor imageView.translatesAutoresizingMaskIntoConstraints = false + + iconContainer.translatesAutoresizingMaskIntoConstraints = false + iconContainer.addSubview(imageView) + + colorDotView.wantsLayer = true + colorDotView.layer?.cornerRadius = 3 + colorDotView.layer?.backgroundColor = NSColor.clear.cgColor + colorDotView.translatesAutoresizingMaskIntoConstraints = false + colorDotView.isHidden = true + iconContainer.addSubview(colorDotView) topLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .semibold) bottomLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .medium) @@ -210,7 +237,7 @@ class MenuBarStatusView: NSView { stack.alignment = .centerY stack.spacing = 4 stack.translatesAutoresizingMaskIntoConstraints = false - stack.addArrangedSubview(imageView) + stack.addArrangedSubview(iconContainer) stack.addArrangedSubview(textStack) addSubview(stack) @@ -219,8 +246,16 @@ class MenuBarStatusView: NSView { stackTrailingConstraint = stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalPadding) NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: 18), - imageView.heightAnchor.constraint(equalToConstant: 18), + iconContainer.widthAnchor.constraint(equalToConstant: 18), + iconContainer.heightAnchor.constraint(equalToConstant: 18), + imageView.leadingAnchor.constraint(equalTo: iconContainer.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: iconContainer.trailingAnchor), + imageView.topAnchor.constraint(equalTo: iconContainer.topAnchor), + imageView.bottomAnchor.constraint(equalTo: iconContainer.bottomAnchor), + colorDotView.widthAnchor.constraint(equalToConstant: 6), + colorDotView.heightAnchor.constraint(equalToConstant: 6), + colorDotView.leadingAnchor.constraint(equalTo: iconContainer.leadingAnchor, constant: -3), + colorDotView.topAnchor.constraint(equalTo: iconContainer.topAnchor, constant: -3), stackLeadingConstraint, stackTrailingConstraint, stack.centerYAnchor.constraint(equalTo: centerYAnchor) @@ -250,6 +285,24 @@ class MenuBarStatusView: NSView { needsLayout = true } + func updateIconColor(_ color: NSColor?, style: DynamicIconColorStyle) { + guard let color = color else { + imageView.contentTintColor = .labelColor + colorDotView.isHidden = true + return + } + + switch style { + case .icon: + imageView.contentTintColor = color + colorDotView.isHidden = true + case .dot: + imageView.contentTintColor = .labelColor + colorDotView.layer?.backgroundColor = color.cgColor + colorDotView.isHidden = false + } + } + private func updateHorizontalPadding(hasText: Bool) { horizontalPadding = hasText ? 6 : 4 stackLeadingConstraint.constant = horizontalPadding diff --git a/KeyStats/SettingsViewController.swift b/KeyStats/SettingsViewController.swift index d684e13..ad540f7 100644 --- a/KeyStats/SettingsViewController.swift +++ b/KeyStats/SettingsViewController.swift @@ -6,6 +6,16 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate { private var showKeyPressesButton: NSButton! private var showMouseClicksButton: NSButton! private var launchAtLoginButton: NSButton! + private var dynamicIconColorButton: NSButton! + private var dynamicIconColorStylePopUp: NSPopUpButton! + private var dynamicIconColorStyleRow: NSStackView! + private var dynamicIconColorHelpButton: NSButton! + private lazy var dynamicIconColorHelpPopover: NSPopover = makeDynamicIconColorHelpPopover() + private weak var dynamicIconColorHelpContentView: NSView? + private var helpButtonTrackingArea: NSTrackingArea? + private var helpPopoverTrackingArea: NSTrackingArea? + private var isHoveringHelpButton = false + private var isHoveringHelpPopover = false private var resetButton: NSButton! private var showThresholdsButton: NSButton! private var thresholdStack: NSStackView! @@ -17,6 +27,7 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate { private let thresholdMinimum = 0 private let thresholdMaximum = 1_000_000 private let thresholdStep = 100.0 + private let dynamicIconColorStyleKey = "dynamicIconColorStyle" private lazy var thresholdFormatter: NumberFormatter = { let formatter = NumberFormatter() @@ -46,6 +57,12 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate { updateState() } + override func viewDidLayout() { + super.viewDidLayout() + updateHelpButtonTrackingArea() + updateHelpPopoverTrackingArea() + } + // MARK: - UI private func setupUI() { @@ -71,12 +88,48 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate { target: self, action: #selector(toggleShowThresholds)) - let optionsStack = NSStackView(views: [showKeyPressesButton, showMouseClicksButton, launchAtLoginButton, showThresholdsButton]) + dynamicIconColorButton = NSButton(checkboxWithTitle: NSLocalizedString("settings.dynamicIconColor", comment: ""), + target: self, + action: #selector(toggleDynamicIconColor)) + + dynamicIconColorHelpButton = NSButton() + dynamicIconColorHelpButton.bezelStyle = .helpButton + dynamicIconColorHelpButton.title = "" + dynamicIconColorHelpButton.controlSize = .mini + dynamicIconColorHelpButton.translatesAutoresizingMaskIntoConstraints = false + dynamicIconColorHelpButton.widthAnchor.constraint(equalToConstant: 12).isActive = true + dynamicIconColorHelpButton.heightAnchor.constraint(equalToConstant: 12).isActive = true + + let dynamicIconColorRow = NSStackView(views: [dynamicIconColorButton, dynamicIconColorHelpButton]) + dynamicIconColorRow.orientation = .horizontal + dynamicIconColorRow.alignment = .centerY + dynamicIconColorRow.spacing = 6 + dynamicIconColorRow.translatesAutoresizingMaskIntoConstraints = false + + dynamicIconColorStylePopUp = NSPopUpButton() + let iconStyleTitle = NSLocalizedString("settings.dynamicIconColorStyle.icon", comment: "") + let dotStyleTitle = NSLocalizedString("settings.dynamicIconColorStyle.dot", comment: "") + dynamicIconColorStylePopUp.addItems(withTitles: [iconStyleTitle, dotStyleTitle]) + dynamicIconColorStylePopUp.item(at: 0)?.representedObject = DynamicIconColorStyle.icon.rawValue + dynamicIconColorStylePopUp.item(at: 1)?.representedObject = DynamicIconColorStyle.dot.rawValue + dynamicIconColorStylePopUp.target = self + dynamicIconColorStylePopUp.action = #selector(dynamicIconColorStyleChanged) + + let styleLabel = NSTextField(labelWithString: NSLocalizedString("settings.dynamicIconColorStyle", comment: "")) + styleLabel.font = NSFont.systemFont(ofSize: 13) + let styleRow = NSStackView(views: [styleLabel, dynamicIconColorStylePopUp]) + styleRow.orientation = .horizontal + styleRow.alignment = .centerY + styleRow.spacing = 8 + styleRow.translatesAutoresizingMaskIntoConstraints = false + dynamicIconColorStyleRow = styleRow + + let optionsStack = NSStackView(views: [showKeyPressesButton, showMouseClicksButton, launchAtLoginButton, dynamicIconColorRow, styleRow, showThresholdsButton]) optionsStack.orientation = .vertical optionsStack.alignment = .leading optionsStack.spacing = 8 optionsStack.translatesAutoresizingMaskIntoConstraints = false - + keyPressThresholdField = makeThresholdField() keyPressThresholdStepper = makeThresholdStepper(action: #selector(keyPressThresholdStepperChanged)) clickThresholdField = makeThresholdField() @@ -130,12 +183,172 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate { showKeyPressesButton.state = StatsManager.shared.showKeyPressesInMenuBar ? .on : .off showMouseClicksButton.state = StatsManager.shared.showMouseClicksInMenuBar ? .on : .off launchAtLoginButton.state = LaunchAtLoginManager.shared.isEnabled ? .on : .off + dynamicIconColorButton.state = StatsManager.shared.enableDynamicIconColor ? .on : .off + updateDynamicIconColorStyleSelection() let notificationsEnabled = StatsManager.shared.notificationsEnabled showThresholdsButton.state = notificationsEnabled ? .on : .off thresholdStack.isHidden = !notificationsEnabled updateThresholdUI() } + private func updateDynamicIconColorStyleSelection() { + let styleValue = UserDefaults.standard.string(forKey: dynamicIconColorStyleKey) ?? DynamicIconColorStyle.icon.rawValue + let style = DynamicIconColorStyle(rawValue: styleValue) ?? .icon + if let item = dynamicIconColorStylePopUp.itemArray.first(where: { ($0.representedObject as? String) == style.rawValue }) { + dynamicIconColorStylePopUp.select(item) + } + let isEnabled = StatsManager.shared.enableDynamicIconColor + dynamicIconColorStylePopUp.isEnabled = isEnabled + dynamicIconColorStyleRow.isHidden = !isEnabled + } + + private func makeDynamicIconColorHelpPopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .transient + popover.contentSize = NSSize(width: 420, height: 520) + popover.contentViewController = makeDynamicIconColorHelpViewController() + return popover + } + + private func makeDynamicIconColorHelpViewController() -> NSViewController { + let viewController = NSViewController() + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + viewController.view = container + dynamicIconColorHelpContentView = container + updateHelpPopoverTrackingArea() + + let titleLabel = NSTextField(labelWithString: NSLocalizedString("settings.dynamicIconColorHelp.title", comment: "")) + titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + let bodyLabel = NSTextField(wrappingLabelWithString: NSLocalizedString("settings.dynamicIconColorHelp.body", comment: "")) + bodyLabel.font = NSFont.systemFont(ofSize: 12) + bodyLabel.textColor = .secondaryLabelColor + bodyLabel.translatesAutoresizingMaskIntoConstraints = false + + let imageGridStack = NSStackView() + imageGridStack.orientation = .vertical + imageGridStack.alignment = .centerX + imageGridStack.spacing = 8 + imageGridStack.translatesAutoresizingMaskIntoConstraints = false + + let imageNames = [ + "DynamicColorTip3", + "DynamicColorTip2", + "DynamicColorTip1", + "DynamicColorTip6", + "DynamicColorTip5", + "DynamicColorTip4" + ] + var currentRow: NSStackView? + for (index, name) in imageNames.enumerated() { + if index % 3 == 0 { + let row = NSStackView() + row.orientation = .horizontal + row.alignment = .centerY + row.spacing = 8 + row.translatesAutoresizingMaskIntoConstraints = false + imageGridStack.addArrangedSubview(row) + currentRow = row + } + guard let image = NSImage(named: name) else { continue } + let imageView = NSImageView(image: image) + imageView.imageScaling = .scaleProportionallyDown + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.widthAnchor.constraint(equalToConstant: 64).isActive = true + imageView.heightAnchor.constraint(lessThanOrEqualToConstant: 44).isActive = true + currentRow?.addArrangedSubview(imageView) + } + + let contentStack = NSStackView(views: [titleLabel, bodyLabel, imageGridStack]) + contentStack.orientation = .vertical + contentStack.alignment = .leading + contentStack.spacing = 10 + contentStack.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(contentStack) + + NSLayoutConstraint.activate([ + contentStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12), + contentStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12), + contentStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 12), + contentStack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12) + ]) + + return viewController + } + + private func updateHelpButtonTrackingArea() { + if let trackingArea = helpButtonTrackingArea { + dynamicIconColorHelpButton.removeTrackingArea(trackingArea) + } + let trackingArea = NSTrackingArea( + rect: dynamicIconColorHelpButton.bounds, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: ["dynamicIconColorHelpButton": true] + ) + dynamicIconColorHelpButton.addTrackingArea(trackingArea) + helpButtonTrackingArea = trackingArea + } + + private func updateHelpPopoverTrackingArea() { + guard let container = dynamicIconColorHelpContentView else { return } + if let trackingArea = helpPopoverTrackingArea { + container.removeTrackingArea(trackingArea) + } + let trackingArea = NSTrackingArea( + rect: container.bounds, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: ["dynamicIconColorHelpPopover": true] + ) + container.addTrackingArea(trackingArea) + helpPopoverTrackingArea = trackingArea + } + + override func mouseEntered(with event: NSEvent) { + if event.trackingArea?.userInfo?["dynamicIconColorHelpButton"] as? Bool == true { + isHoveringHelpButton = true + showDynamicIconColorHelpPopover() + return + } + if event.trackingArea?.userInfo?["dynamicIconColorHelpPopover"] as? Bool == true { + isHoveringHelpPopover = true + return + } + super.mouseEntered(with: event) + } + + override func mouseExited(with event: NSEvent) { + if event.trackingArea?.userInfo?["dynamicIconColorHelpButton"] as? Bool == true { + isHoveringHelpButton = false + scheduleHelpPopoverCloseIfNeeded() + return + } + if event.trackingArea?.userInfo?["dynamicIconColorHelpPopover"] as? Bool == true { + isHoveringHelpPopover = false + scheduleHelpPopoverCloseIfNeeded() + return + } + super.mouseExited(with: event) + } + + private func showDynamicIconColorHelpPopover() { + guard let button = dynamicIconColorHelpButton else { return } + if dynamicIconColorHelpPopover.isShown { return } + dynamicIconColorHelpPopover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY) + } + + private func scheduleHelpPopoverCloseIfNeeded() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + guard let self = self else { return } + if !self.isHoveringHelpButton && !self.isHoveringHelpPopover { + self.dynamicIconColorHelpPopover.performClose(nil) + } + } + } + // MARK: - 通知阈值 private enum ThresholdType { @@ -263,6 +476,17 @@ class SettingsViewController: NSViewController, NSTextFieldDelegate { StatsManager.shared.showMouseClicksInMenuBar = (showMouseClicksButton.state == .on) } + @objc private func toggleDynamicIconColor() { + StatsManager.shared.enableDynamicIconColor = dynamicIconColorButton.state == .on + updateDynamicIconColorStyleSelection() + } + + @objc private func dynamicIconColorStyleChanged() { + guard let rawValue = dynamicIconColorStylePopUp.selectedItem?.representedObject as? String else { return } + UserDefaults.standard.set(rawValue, forKey: dynamicIconColorStyleKey) + StatsManager.shared.menuBarUpdateHandler?() + } + @objc private func toggleLaunchAtLogin() { let shouldEnable = launchAtLoginButton.state == .on do { diff --git a/KeyStats/StatsManager.swift b/KeyStats/StatsManager.swift index ca02fec..f565daa 100644 --- a/KeyStats/StatsManager.swift +++ b/KeyStats/StatsManager.swift @@ -1,5 +1,6 @@ import Foundation import Cocoa +import UserNotifications private let metersPerPixel: Double = 0.000264583 @@ -70,6 +71,8 @@ class StatsManager { private let keyPressNotifyThresholdKey = "keyPressNotifyThreshold" private let clickNotifyThresholdKey = "clickNotifyThreshold" private let notificationsEnabledKey = "notificationsEnabled" + private let enableDynamicIconColorKey = "enableDynamicIconColor" + private let dynamicIconColorStyleKey = "dynamicIconColorStyle" private let dateFormatter: DateFormatter private var history: [String: DailyStats] = [:] private var saveTimer: Timer? @@ -77,7 +80,19 @@ class StatsManager { private var midnightCheckTimer: Timer? private let saveInterval: TimeInterval = 2.0 private let statsUpdateDebounceInterval: TimeInterval = 0.3 + private let inputRateWindowSeconds: TimeInterval = 3.0 + private let inputRateBucketInterval: TimeInterval = 0.5 + private let inputRateApmThresholds: [Double] = [0, 80, 160, 240] + private let inputRateLock = NSLock() private var isReadyForUpdates = false + private lazy var inputRateBuckets: [Int] = { + let bucketCount = max(1, Int(inputRateWindowSeconds / inputRateBucketInterval)) + return Array(repeating: 0, count: bucketCount) + }() + private var inputRateBucketIndex = 0 + private var inputRateTimer: Timer? + private(set) var currentInputRatePerSecond: Double = 0 + private(set) var currentIconTintColor: NSColor? var menuBarUpdateHandler: (() -> Void)? var statsUpdateHandler: (() -> Void)? @@ -123,6 +138,28 @@ class StatsManager { } } + /// 设置:是否启用动态图标颜色 + var enableDynamicIconColor: Bool { + didSet { + userDefaults.set(enableDynamicIconColor, forKey: enableDynamicIconColorKey) + let applyChanges = { [weak self] in + guard let self = self else { return } + if self.enableDynamicIconColor { + self.resetInputRateBuckets() + self.startInputRateTracking() + } else { + self.stopInputRateTracking() + } + self.updateCurrentInputRate() + } + if Thread.isMainThread { + applyChanges() + return + } + DispatchQueue.main.async(execute: applyChanges) + } + } + private var lastNotifiedKeyPresses: Int = 0 private var lastNotifiedClicks: Int = 0 @@ -141,12 +178,13 @@ class StatsManager { dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" - // 加载设置(默认为 true) + // 加载设置(按键/点击默认 true,通知/动态图标默认 false) showKeyPressesInMenuBar = userDefaults.object(forKey: showKeyPressesKey) as? Bool ?? true showMouseClicksInMenuBar = userDefaults.object(forKey: showMouseClicksKey) as? Bool ?? true notificationsEnabled = userDefaults.object(forKey: notificationsEnabledKey) as? Bool ?? false keyPressNotifyThreshold = userDefaults.object(forKey: keyPressNotifyThresholdKey) as? Int ?? 1000 clickNotifyThreshold = userDefaults.object(forKey: clickNotifyThresholdKey) as? Int ?? 1000 + enableDynamicIconColor = userDefaults.object(forKey: enableDynamicIconColorKey) as? Bool ?? false // 先初始化 currentStats 为默认值 let calendar = Calendar.current @@ -164,6 +202,11 @@ class StatsManager { isReadyForUpdates = true saveStats() + if enableDynamicIconColor { + resetInputRateBuckets() + startInputRateTracking() + updateCurrentInputRate() + } setupMidnightReset() } @@ -176,6 +219,7 @@ class StatsManager { if let keyName = keyName, !keyName.isEmpty { currentStats.keyPressCounts[keyName, default: 0] += 1 } + registerInputEvent() notifyMenuBarUpdate() notifyStatsUpdate() notifyKeyPressThresholdIfNeeded() @@ -184,6 +228,7 @@ class StatsManager { func incrementLeftClicks() { ensureCurrentDay() currentStats.leftClicks += 1 + registerInputEvent() notifyMenuBarUpdate() notifyStatsUpdate() notifyClickThresholdIfNeeded() @@ -192,6 +237,7 @@ class StatsManager { func incrementRightClicks() { ensureCurrentDay() currentStats.rightClicks += 1 + registerInputEvent() notifyMenuBarUpdate() notifyStatsUpdate() notifyClickThresholdIfNeeded() @@ -206,9 +252,108 @@ class StatsManager { func addScrollDistance(_ distance: Double) { ensureCurrentDay() currentStats.scrollDistance += abs(distance) + registerInputEvent() scheduleDebouncedStatsUpdate() } + // MARK: - 输入速率 + + func registerInputEvent() { + guard enableDynamicIconColor else { return } + inputRateLock.lock() + inputRateBuckets[inputRateBucketIndex] += 1 + inputRateLock.unlock() + } + + private func resetInputRateBuckets() { + inputRateLock.lock() + inputRateBuckets = Array(repeating: 0, count: inputRateBuckets.count) + inputRateBucketIndex = 0 + inputRateLock.unlock() + } + + private func startInputRateTracking() { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.startInputRateTracking() + } + return + } + + inputRateTimer?.invalidate() + inputRateTimer = Timer.scheduledTimer(withTimeInterval: inputRateBucketInterval, repeats: true) { [weak self] _ in + self?.advanceInputRateBucket() + } + if let timer = inputRateTimer { + RunLoop.main.add(timer, forMode: .common) + } + } + + private func stopInputRateTracking() { + inputRateTimer?.invalidate() + inputRateTimer = nil + } + + private func advanceInputRateBucket() { + inputRateLock.lock() + inputRateBucketIndex = (inputRateBucketIndex + 1) % inputRateBuckets.count + inputRateBuckets[inputRateBucketIndex] = 0 + inputRateLock.unlock() + updateCurrentInputRate() + } + + private func updateCurrentInputRate() { + inputRateLock.lock() + let totalEvents = inputRateBuckets.reduce(0, +) + inputRateLock.unlock() + currentInputRatePerSecond = Double(totalEvents) / inputRateWindowSeconds + currentIconTintColor = enableDynamicIconColor ? colorForRate(currentInputRatePerSecond) : nil + notifyMenuBarUpdate() + } + + private func colorForRate(_ ratePerSecond: Double) -> NSColor? { + let apm = ratePerSecond * 60 + let thresholds = inputRateApmThresholds + if apm < thresholds[1] { return nil } + if apm >= thresholds[3] { return .systemRed } + + if apm <= thresholds[2] { + let progress = (apm - thresholds[1]) / (thresholds[2] - thresholds[1]) + let lightGreen = lightenColor(.systemGreen, fraction: 0.6) + return interpolateColor(from: lightGreen, to: .systemGreen, progress: progress) + } + + let progress = (apm - thresholds[2]) / (thresholds[3] - thresholds[2]) + return interpolateColor(from: .systemYellow, to: .systemRed, progress: progress) + } + + private func interpolateColor(from: NSColor, to: NSColor, progress: Double) -> NSColor { + let fromColor = from.usingColorSpace(.deviceRGB) ?? from + let toColor = to.usingColorSpace(.deviceRGB) ?? to + var fr: CGFloat = 0 + var fg: CGFloat = 0 + var fb: CGFloat = 0 + var fa: CGFloat = 0 + var tr: CGFloat = 0 + var tg: CGFloat = 0 + var tb: CGFloat = 0 + var ta: CGFloat = 0 + fromColor.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) + toColor.getRed(&tr, green: &tg, blue: &tb, alpha: &ta) + let t = CGFloat(max(0, min(1, progress))) + return NSColor( + red: fr + (tr - fr) * t, + green: fg + (tg - fg) * t, + blue: fb + (tb - fb) * t, + alpha: fa + (ta - fa) * t + ) + } + + private func lightenColor(_ color: NSColor, fraction: CGFloat) -> NSColor { + let resolved = color.usingColorSpace(.deviceRGB) ?? color + return resolved.blended(withFraction: min(max(fraction, 0), 1), of: .white) ?? resolved + } + // MARK: - 通知阈值 private func updateNotificationBaselines() { @@ -334,6 +479,8 @@ class StatsManager { statsUpdateTimer = nil midnightCheckTimer?.invalidate() midnightCheckTimer = nil + inputRateTimer?.invalidate() + inputRateTimer = nil saveStats() } diff --git a/KeyStats/StatsPopoverViewController.swift b/KeyStats/StatsPopoverViewController.swift index f4ac0a8..7a687d9 100644 --- a/KeyStats/StatsPopoverViewController.swift +++ b/KeyStats/StatsPopoverViewController.swift @@ -328,7 +328,7 @@ class StatsPopoverViewController: NSViewController { historySummaryLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16), // 底部分隔线 - bottomSeparator.topAnchor.constraint(equalTo: historySummaryLabel.bottomAnchor, constant: 12), + bottomSeparator.topAnchor.constraint(equalTo: historySummaryLabel.bottomAnchor, constant: 16), bottomSeparator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16), bottomSeparator.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16), diff --git a/KeyStats/en.lproj/Localizable.strings b/KeyStats/en.lproj/Localizable.strings index bab79ee..e01e2c0 100644 --- a/KeyStats/en.lproj/Localizable.strings +++ b/KeyStats/en.lproj/Localizable.strings @@ -5,6 +5,12 @@ "button.permission" = "Grant Permission"; "button.launchAtLogin" = "Open at Login"; "settings.title" = "Settings"; +"settings.dynamicIconColor" = "Enable Dynamic Icon Color"; +"settings.dynamicIconColorStyle" = "Display When Enabled"; +"settings.dynamicIconColorStyle.icon" = "Tint Icon"; +"settings.dynamicIconColorStyle.dot" = "Status Dot"; +"settings.dynamicIconColorHelp.title" = "Dynamic Color Guide"; +"settings.dynamicIconColorHelp.body" = "Colors are calculated from recent input rate and shown in the menu bar.\n\nAPM (events per minute) ranges:\n• < 80: no tint (default)\n• 80–160: light green → green\n• 160–240: yellow → red\n• ≥ 240: red"; "settings.windowTitle" = "KeyStats Settings"; "button.back" = "Back"; "setting.showKeyPresses" = "Show Key Presses in Menu Bar"; diff --git a/KeyStats/zh-Hans.lproj/Localizable.strings b/KeyStats/zh-Hans.lproj/Localizable.strings index a0d9e31..45b0373 100644 --- a/KeyStats/zh-Hans.lproj/Localizable.strings +++ b/KeyStats/zh-Hans.lproj/Localizable.strings @@ -5,6 +5,12 @@ "button.permission" = "获取权限"; "button.launchAtLogin" = "开机启动"; "settings.title" = "设置"; +"settings.dynamicIconColor" = "启用动态图标颜色"; +"settings.dynamicIconColorStyle" = "启用后显示方式"; +"settings.dynamicIconColorStyle.icon" = "图标着色"; +"settings.dynamicIconColorStyle.dot" = "状态圆点"; +"settings.dynamicIconColorHelp.title" = "动态颜色说明"; +"settings.dynamicIconColorHelp.body" = "根据近期输入速率自动计算颜色,并在菜单栏实时展示。\n\nAPM(每分钟输入事件数)颜色范围:\n• < 80:不着色(保持默认)\n• 80–160:从浅绿渐变到绿\n• 160–240:从黄渐变到红\n• ≥ 240:红色"; "settings.windowTitle" = "KeyStats设置"; "button.back" = "返回"; "setting.showKeyPresses" = "在菜单栏显示按键数";