Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

697 lines
24 KiB
Swift

import AppKit
import CoreGraphics
import Foundation
import SwiftUI
import MacToolsPluginKit
public final class DisplayBrightnessPluginFactory: NSObject, MacToolsPluginBundleFactory {
public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider {
DisplayBrightnessPluginProvider(context: context)
}
}
@MainActor
private struct DisplayBrightnessPluginProvider: PluginProvider {
let context: PluginRuntimeContext
func makePlugins() -> [any MacToolsPlugin] {
[
DisplayBrightnessPlugin(
localization: PluginLocalization(bundle: context.resourceBundle),
shortcutPreferences: DisplayBrightnessShortcutPreferences(storage: context.storage)
)
]
}
}
enum DisplayBrightnessShortcutDirection: Equatable {
case decrease
case increase
var actionID: String {
switch self {
case .decrease:
return "display-brightness.decrease"
case .increase:
return "display-brightness.increase"
}
}
func title(localization: PluginLocalization) -> String {
switch self {
case .decrease:
return localization.string("shortcut.direction.decrease", defaultValue: "降低")
case .increase:
return localization.string("shortcut.direction.increase", defaultValue: "增加")
}
}
var systemImage: String {
switch self {
case .decrease:
return "sun.min.fill"
case .increase:
return "sun.max.fill"
}
}
var multiplier: Double {
switch self {
case .decrease:
return -1
case .increase:
return 1
}
}
}
struct DisplayBrightnessShortcutAction: Equatable {
let id: String
let direction: DisplayBrightnessShortcutDirection
let targetDisplayIDs: [CGDirectDisplayID]
}
struct DisplayBrightnessShortcutAcceleration {
private var lastPressDateByActionID: [String: Date] = [:]
private var quickPressCountByActionID: [String: Int] = [:]
mutating func stepForPress(actionID: String, now: Date, fastTapWindow: TimeInterval = 0.48) -> Int {
let quickPressCount: Int
if let lastPressDate = lastPressDateByActionID[actionID],
now.timeIntervalSince(lastPressDate) <= fastTapWindow {
quickPressCount = min((quickPressCountByActionID[actionID] ?? 0) + 1, 9)
} else {
quickPressCount = 0
}
quickPressCountByActionID[actionID] = quickPressCount
lastPressDateByActionID[actionID] = now
return min(10, 1 + quickPressCount)
}
static func stepForHold(elapsed: TimeInterval, baseline: Int) -> Int {
let elapsedStep: Int
switch elapsed {
case ..<0.45:
elapsedStep = 1
case ..<0.9:
elapsedStep = 2
case ..<1.35:
elapsedStep = 3
case ..<1.8:
elapsedStep = 4
case ..<2.4:
elapsedStep = 6
case ..<3.2:
elapsedStep = 8
default:
elapsedStep = 10
}
return min(10, max(baseline, elapsedStep))
}
}
private struct DisplayBrightnessShortcutSession {
let id: UUID
let action: DisplayBrightnessShortcutAction
let task: Task<Void, Never>
}
@MainActor
final class DisplayBrightnessPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginShortcutEventHandling, DisplayTopologyRefreshing {
private enum Constants {
static let displayControlPrefix = "display."
static let brightnessControlSuffix = ".brightness"
static let disableBuiltInDisplayControlID = "built-in-display-disable"
static let restoreBuiltInDisplayControlID = "built-in-display-restore"
static let shortcutGroupID = "display-brightness.shortcuts"
}
let metadata: PluginMetadata
let primaryPanelDescriptor = PluginPrimaryPanelDescriptor(
controlStyle: .disclosure,
menuActionBehavior: .keepPresented
)
var onStateChange: (() -> Void)?
var requestPermissionGuidance: ((String) -> Void)?
var shortcutBindingResolver: ((String) -> ShortcutBinding?)?
private let controller: DisplayBrightnessControlling
private let displayDisableCoordinator: any DisplayDisableCoordinating
private let showsDisplayDisableControls: Bool
private let shortcutPreferences: DisplayBrightnessShortcutPreferences
private let mouseDisplayIDProvider: @MainActor () -> CGDirectDisplayID?
private let localization: PluginLocalization
private var isExpanded = false
private var displayDisableActionTask: Task<Void, Never>?
private var displayTopologyTask: Task<Void, Never>?
private var shortcutAcceleration = DisplayBrightnessShortcutAcceleration()
private var shortcutSessions: [String: DisplayBrightnessShortcutSession] = [:]
init(
controller: DisplayBrightnessControlling? = nil,
localization: PluginLocalization = PluginLocalization(bundle: .main),
displayDisableCoordinator: (any DisplayDisableCoordinating)? = nil,
showsDisplayDisableControls: Bool = true,
shortcutPreferences: DisplayBrightnessShortcutPreferences? = nil,
mouseDisplayIDProvider: @escaping @MainActor () -> CGDirectDisplayID? = DisplayBrightnessPlugin.currentMouseDisplayID
) {
self.localization = localization
self.controller = controller ?? DisplayBrightnessController(localization: localization)
self.displayDisableCoordinator = displayDisableCoordinator ?? DisplayDisableCoordinator(
service: Self.defaultDisplayDisableService(),
store: UserDefaultsDisplayDisableStateStore(),
localization: localization
)
self.showsDisplayDisableControls = showsDisplayDisableControls
self.shortcutPreferences = shortcutPreferences ?? DisplayBrightnessShortcutPreferences(
storage: UserDefaultsPluginStorage(pluginID: "display-brightness")
)
self.mouseDisplayIDProvider = mouseDisplayIDProvider
self.metadata = PluginMetadata(
id: "display-brightness",
title: localization.string("metadata.title", defaultValue: "显示器亮度"),
iconName: "sun.max",
iconTint: Color(nsColor: .systemYellow),
order: 20,
defaultDescription: localization.string(
"metadata.description",
defaultValue: "快速调节每个显示器的亮度"
)
)
self.controller.onStateChange = { [weak self] in
self?.onStateChange?()
}
}
var primaryPanelState: PluginPanelState {
let snapshot = controller.snapshot()
guard !snapshot.displays.isEmpty else {
isExpanded = false
return PluginPanelState(
subtitle: localization.string(
"panel.subtitle.noDisplays",
defaultValue: "未检测到可调节亮度的显示器"
),
isOn: false,
isExpanded: false,
isEnabled: false,
isVisible: true,
detail: nil,
errorMessage: snapshot.errorMessage
)
}
return PluginPanelState(
subtitle: subtitle(for: snapshot.displays),
isOn: false,
isExpanded: isExpanded,
isEnabled: true,
isVisible: true,
detail: isExpanded ? buildDetail(for: snapshot.displays) : nil,
errorMessage: snapshot.errorMessage
)
}
var permissionRequirements: [PluginPermissionRequirement] { [] }
var settingsSections: [PluginSettingsSection] { [] }
var configuration: PluginConfiguration? {
PluginConfiguration(description: metadata.defaultDescription) { [shortcutPreferences, localization] _ in
DisplayBrightnessSettingsView(
preferences: shortcutPreferences,
localization: localization
)
}
}
var shortcutDefinitions: [PluginShortcutDefinition] {
[
shortcutDefinition(direction: .decrease),
shortcutDefinition(direction: .increase)
]
}
func refresh() {
controller.refresh()
displayDisableCoordinator.refreshSnapshot()
}
func refreshDisplayTopology() {
controller.refresh()
let coordinator = displayDisableCoordinator
displayTopologyTask?.cancel()
displayTopologyTask = Task { @MainActor [weak self, coordinator] in
await coordinator.reconcileTopology()
self?.onStateChange?()
}
}
func handleAction(_ action: PluginPanelAction) {
switch action {
case let .setDisclosureExpanded(value):
isExpanded = value
onStateChange?()
case let .setSlider(controlID, value, phase):
guard let displayID = Self.parseDisplayID(from: controlID) else {
DisplayBrightnessLog.plugin.error(
"invalid slider control id \(controlID, privacy: .public)"
)
return
}
controller.setBrightness(value, for: displayID, phase: phase)
onStateChange?()
case let .invokeAction(controlID):
handleInvokeAction(controlID: controlID)
case .setSwitch,
.setSelection,
.setNavigationSelection,
.clearNavigationSelection,
.setDate:
return
}
}
func permissionState(for permissionID: String) -> PluginPermissionState {
PluginPermissionState(isGranted: true, footnote: nil)
}
func handlePermissionAction(id: String) {}
func handleSettingsAction(id: String) {}
func handleShortcutAction(id: String) {
handleShortcutEvent(id: id, phase: .pressed)
}
func handleShortcutEvent(id: String, phase: PluginShortcutEventPhase) {
switch phase {
case .pressed:
startShortcutAction(id: id)
case .released:
stopShortcutAction(id: id)
}
}
func deactivate(reason: PluginDeactivationReason) {
displayDisableActionTask?.cancel()
displayTopologyTask?.cancel()
stopAllShortcutActions()
guard reason.requiresStateCleanup else { return }
displayDisableCoordinator.restoreBuiltInDisplay()
}
static func parseDisplayID(from controlID: String) -> CGDirectDisplayID? {
guard
controlID.hasPrefix(Constants.displayControlPrefix),
controlID.hasSuffix(Constants.brightnessControlSuffix)
else {
return nil
}
let startIndex = controlID.index(
controlID.startIndex,
offsetBy: Constants.displayControlPrefix.count
)
let endIndex = controlID.index(
controlID.endIndex,
offsetBy: -Constants.brightnessControlSuffix.count
)
return CGDirectDisplayID(controlID[startIndex..<endIndex])
}
private func subtitle(for displays: [DisplayBrightnessDisplay]) -> String {
if displays.count == 1, let display = displays.first {
return "\(display.display.name) \(Self.percentText(for: display.brightness))"
}
return localization.format("panel.subtitle.displayCountFormat", defaultValue: "%d 个显示器", displays.count)
}
private func buildDetail(for displays: [DisplayBrightnessDisplay]) -> PluginPanelDetail {
let brightnessControls = displays.map { display in
PluginPanelControl(
id: "\(Constants.displayControlPrefix)\(display.display.id)\(Constants.brightnessControlSuffix)",
kind: .slider,
options: [],
selectedOptionID: nil,
dateValue: nil,
minimumDate: nil,
displayedComponents: nil,
datePickerStyle: nil,
sectionTitle: display.display.name,
sliderValue: display.brightness,
sliderBounds: 0...1,
sliderStep: 0.01,
valueLabel: Self.percentText(for: display.brightness),
isEnabled: true
)
}
return PluginPanelDetail(
primaryControls: brightnessControls + displayDisableControls(),
secondaryPanel: nil
)
}
private func displayDisableControls() -> [PluginPanelControl] {
guard showsDisplayDisableControls else {
return []
}
let snapshot = displayDisableCoordinator.snapshot
switch snapshot.status {
case .unsupported:
return [displayDisableActionControl(
id: Constants.disableBuiltInDisplayControlID,
title: localization.string(
"displayDisable.action.disable",
defaultValue: "关闭内建显示屏"
),
iconName: "display",
isEnabled: false
)]
case .unavailable:
var controls = [displayDisableActionControl(
id: Constants.disableBuiltInDisplayControlID,
title: localization.string(
"displayDisable.action.disable",
defaultValue: "关闭内建显示屏"
),
iconName: "display",
isEnabled: false
)]
if snapshot.isRestoreAllowed {
controls.append(displayDisableActionControl(
id: Constants.restoreBuiltInDisplayControlID,
title: localization.string(
"displayDisable.action.restore",
defaultValue: "恢复内建显示屏"
),
iconName: "display",
isEnabled: true
))
}
return controls
case .disabled:
return [displayDisableActionControl(
id: Constants.restoreBuiltInDisplayControlID,
title: localization.string(
"displayDisable.action.restore",
defaultValue: "恢复内建显示屏"
),
iconName: "display",
isEnabled: snapshot.isRestoreAllowed
)]
case .available, .failed, .busy:
var controls: [PluginPanelControl] = []
controls.append(displayDisableActionControl(
id: Constants.disableBuiltInDisplayControlID,
title: localization.string(
"displayDisable.action.disable",
defaultValue: "关闭内建显示屏"
),
iconName: "display",
isEnabled: snapshot.isDisableAllowed
))
if snapshot.isRestoreAllowed {
controls.append(displayDisableActionControl(
id: Constants.restoreBuiltInDisplayControlID,
title: localization.string(
"displayDisable.action.restore",
defaultValue: "恢复内建显示屏"
),
iconName: "display",
isEnabled: true
))
}
return controls
}
}
private func displayDisableActionControl(
id: String,
title: String,
iconName: String,
isEnabled: Bool
) -> PluginPanelControl {
PluginPanelControl(
id: id,
kind: .actionRow,
options: [],
selectedOptionID: nil,
dateValue: nil,
minimumDate: nil,
displayedComponents: nil,
datePickerStyle: nil,
sectionTitle: nil,
actionTitle: title,
actionIconSystemName: iconName,
showsLeadingDivider: true,
isEnabled: isEnabled
)
}
private func handleInvokeAction(controlID: String) {
switch controlID {
case Constants.disableBuiltInDisplayControlID:
displayDisableActionTask?.cancel()
let coordinator = displayDisableCoordinator
displayDisableActionTask = Task { @MainActor [weak self, coordinator] in
await coordinator.disableBuiltInDisplay()
self?.onStateChange?()
}
case Constants.restoreBuiltInDisplayControlID:
displayDisableActionTask?.cancel()
displayDisableCoordinator.restoreBuiltInDisplay()
onStateChange?()
default:
return
}
}
private static func percentText(for brightness: Double) -> String {
"\(Int((brightness * 100).rounded()))%"
}
private static func defaultDisplayDisableService() -> any DisplayDisableServicing {
SystemDisplayDisableService()
}
private func shortcutDefinition(direction: DisplayBrightnessShortcutDirection) -> PluginShortcutDefinition {
let actionID = direction.actionID
let directionTitle = direction.title(localization: localization)
return PluginShortcutDefinition(
id: actionID,
title: localization.format("shortcut.titleFormat", defaultValue: "%@亮度", directionTitle),
description: localization.format("shortcut.descriptionFormat", defaultValue: "%@显示器亮度。", directionTitle),
actionID: actionID,
scope: .global,
defaultBinding: nil,
isRequired: false,
settingsGroupID: Constants.shortcutGroupID,
settingsGroupTitle: localization.string(
"shortcut.settingsGroupTitle",
defaultValue: "亮度快捷键"
),
settingsGroupDescription: localization.string(
"shortcut.settingsGroupDescription",
defaultValue: "按所选作用范围调整显示器亮度。"
),
settingsControlTitle: directionTitle,
settingsControlSystemImage: direction.systemImage
)
}
private func startShortcutAction(id: String) {
guard shortcutSessions[id] == nil,
let direction = Self.shortcutDirection(for: id)
else {
return
}
var snapshot = controller.snapshot()
if snapshot.displays.isEmpty {
controller.refresh()
snapshot = controller.snapshot()
}
let targetDisplayIDs = shortcutTargetDisplayIDs(in: snapshot)
guard !targetDisplayIDs.isEmpty else {
return
}
let action = DisplayBrightnessShortcutAction(
id: id,
direction: direction,
targetDisplayIDs: targetDisplayIDs
)
let now = Date()
let initialStep = shortcutAcceleration.stepForPress(actionID: id, now: now)
applyShortcutAction(action, step: initialStep, phase: .changed)
scheduleShortcutRepeats(for: action, initialStep: initialStep, startDate: now)
}
private func stopShortcutAction(id: String) {
guard let session = shortcutSessions.removeValue(forKey: id) else {
return
}
session.task.cancel()
commitShortcutAction(session.action)
}
private func stopAllShortcutActions() {
for session in shortcutSessions.values {
session.task.cancel()
}
shortcutSessions.removeAll()
}
private func scheduleShortcutRepeats(
for action: DisplayBrightnessShortcutAction,
initialStep: Int,
startDate: Date
) {
let sessionID = UUID()
let task = Task { @MainActor [weak self] in
await Self.sleep(seconds: Self.initialHoldDelay)
var repeatCount = 0
while !Task.isCancelled {
guard let self,
self.shortcutSessions[action.id]?.id == sessionID
else {
return
}
guard repeatCount < Self.maximumHoldRepeatCount else {
self.stopShortcutAction(id: action.id)
return
}
let elapsed = Date().timeIntervalSince(startDate)
let step = DisplayBrightnessShortcutAcceleration.stepForHold(
elapsed: elapsed,
baseline: initialStep
)
self.applyShortcutAction(action, step: step, phase: .changed)
repeatCount += 1
await Self.sleep(seconds: Self.repeatDelay)
}
}
shortcutSessions[action.id] = DisplayBrightnessShortcutSession(
id: sessionID,
action: action,
task: task
)
}
private func applyShortcutAction(
_ action: DisplayBrightnessShortcutAction,
step: Int,
phase: PluginPanelAction.SliderPhase
) {
let delta = Double(step) / 100 * action.direction.multiplier
for display in displays(for: action.targetDisplayIDs) {
controller.setBrightness(display.brightness + delta, for: display.id, phase: phase)
}
}
private func commitShortcutAction(_ action: DisplayBrightnessShortcutAction) {
for display in displays(for: action.targetDisplayIDs) {
controller.setBrightness(display.brightness, for: display.id, phase: .ended)
}
}
private func displays(for displayIDs: [CGDirectDisplayID]) -> [DisplayBrightnessDisplay] {
let displaysByID = Dictionary(uniqueKeysWithValues: controller.snapshot().displays.map { ($0.id, $0) })
return displayIDs.compactMap { displaysByID[$0] }
}
private func shortcutTargetDisplayIDs(in snapshot: DisplayBrightnessSnapshot) -> [CGDirectDisplayID] {
switch shortcutPreferences.targetMode {
case .followsMouse:
if let mouseDisplayID = mouseDisplayIDProvider(),
snapshot.displays.contains(where: { $0.id == mouseDisplayID }) {
return [mouseDisplayID]
}
if let mainDisplay = snapshot.displays.first(where: { $0.display.isMain }) {
return [mainDisplay.id]
}
return snapshot.displays.first.map { [$0.id] } ?? []
case .allDisplays:
return snapshot.displays.map(\.id)
}
}
private static var initialHoldDelay: TimeInterval {
systemKeyboardTiming(
key: "InitialKeyRepeat",
fallback: 25,
minimum: 0.22,
maximum: 0.55
)
}
private static var repeatDelay: TimeInterval {
systemKeyboardTiming(
key: "KeyRepeat",
fallback: 6,
minimum: 0.075,
maximum: 0.16
)
}
private static var maximumHoldRepeatCount: Int { 100 }
private static func systemKeyboardTiming(
key: String,
fallback: Double,
minimum: TimeInterval,
maximum: TimeInterval
) -> TimeInterval {
let rawValue = UserDefaults.standard.object(forKey: key) as? Double ?? fallback
return min(max(rawValue * 0.014, minimum), maximum)
}
private static func sleep(seconds: TimeInterval) async {
let nanoseconds = UInt64(max(0, seconds) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
static func shortcutDirection(for actionID: String) -> DisplayBrightnessShortcutDirection? {
switch actionID {
case DisplayBrightnessShortcutDirection.decrease.actionID:
return .decrease
case DisplayBrightnessShortcutDirection.increase.actionID:
return .increase
default:
return nil
}
}
private static func currentMouseDisplayID() -> CGDirectDisplayID? {
let mouseLocation = NSEvent.mouseLocation
guard let screen = NSScreen.screens.first(where: { $0.frame.contains(mouseLocation) }) else {
return nil
}
return displayID(for: screen)
}
private static func displayID(for screen: NSScreen) -> CGDirectDisplayID? {
guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else {
return nil
}
return screenNumber.uint32Value
}
}