This commit is contained in:
TheEveEye 2026-05-01 09:48:46 +02:00 committed by GitHub
commit d93bd7aaaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 663 additions and 0 deletions

View file

@ -38,6 +38,8 @@
AA78BDBD2709FE7B00CA8DF7 /* UpdaterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA78BDBC2709FE7B00CA8DF7 /* UpdaterDelegate.swift */; };
AA99521726FE25AB00612E07 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA99521626FE25AB00612E07 /* AppDelegate.swift */; };
AA99521926FE49A300612E07 /* MenuHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA99521826FE49A300612E07 /* MenuHandler.swift */; };
AA9A10012DCD000100000001 /* BrightnessAutomationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9A10002DCD000100000001 /* BrightnessAutomationManager.swift */; };
AA9A10022DCD000100000001 /* BrightnessAutomationWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9A10032DCD000100000001 /* BrightnessAutomationWindowController.swift */; };
AA9AE86F26B5BF3D00B6CA65 /* OSD.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA9AE86E26B5BF3D00B6CA65 /* OSD.framework */; };
AA9AE87126B5BFB700B6CA65 /* CoreDisplay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA9AE87026B5BFB700B6CA65 /* CoreDisplay.framework */; };
AAB2F638273ED099004AB5A4 /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = AAB2F637273ED099004AB5A4 /* .swiftlint.yml */; };
@ -128,6 +130,8 @@
AA99E81527622EBE00413316 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Main.strings"; sourceTree = "<group>"; };
AA99E81627622EBE00413316 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InternetAccessPolicy.strings"; sourceTree = "<group>"; };
AA99E81727622EBE00413316 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
AA9A10002DCD000100000001 /* BrightnessAutomationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessAutomationManager.swift; sourceTree = "<group>"; };
AA9A10032DCD000100000001 /* BrightnessAutomationWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessAutomationWindowController.swift; sourceTree = "<group>"; };
AA9AE86E26B5BF3D00B6CA65 /* OSD.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OSD.framework; path = /System/Library/PrivateFrameworks/OSD.framework; sourceTree = "<absolute>"; };
AA9AE87026B5BFB700B6CA65 /* CoreDisplay.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreDisplay.framework; path = /System/Library/Frameworks/CoreDisplay.framework; sourceTree = "<absolute>"; };
AA9CB6252704BB440086DC0E /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Main.strings; sourceTree = "<group>"; };
@ -309,6 +313,8 @@
F01B0685228221B6008E64DB /* Bridging-Header.h */,
AA99521626FE25AB00612E07 /* AppDelegate.swift */,
AA78BDBC2709FE7B00CA8DF7 /* UpdaterDelegate.swift */,
AA9A10002DCD000100000001 /* BrightnessAutomationManager.swift */,
AA9A10032DCD000100000001 /* BrightnessAutomationWindowController.swift */,
AA99521826FE49A300612E07 /* MenuHandler.swift */,
F01B068F228221B7008E64DB /* SliderHandler.swift */,
6C85EFD922C941B000227EA1 /* DisplayManager.swift */,
@ -639,6 +645,8 @@
F0445D3820023E710025AE82 /* MainPrefsViewController.swift in Sources */,
28D1DDF2227FBE71004CB494 /* NSScreen+Extension.swift in Sources */,
AA99521726FE25AB00612E07 /* AppDelegate.swift in Sources */,
AA9A10012DCD000100000001 /* BrightnessAutomationManager.swift in Sources */,
AA9A10022DCD000100000001 /* BrightnessAutomationWindowController.swift in Sources */,
AA25F6D126E681D30087F3A2 /* KeyboardPrefsViewController.swift in Sources */,
F01B069F228221B7008E64DB /* SliderHandler.swift in Sources */,
AACE5E2327050C63006C2A48 /* NSNotification+Extension.swift in Sources */,

View file

@ -87,6 +87,9 @@ enum PrefKey: String {
// Sliders for multiple displays
case multiSliders
// Daily brightness automations
case brightnessAutomations
/* -- Display specific settings */
// Enable mute DDC for display

View file

@ -29,6 +29,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var startupActionWriteCounter: Int = 0
var audioPlayer: AVAudioPlayer?
let updaterController = SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: UpdaterDelegate(), userDriverDelegate: nil)
let brightnessAutomationManager = BrightnessAutomationManager()
var brightnessAutomationWindowController: BrightnessAutomationWindowController?
var settingsPaneStyle: Settings.Style {
if !DEBUG_MACOS10, #available(macOS 11.0, *) {
@ -65,6 +67,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
CGDisplayRegisterReconfigurationCallback({ _, _, _ in app.displayReconfigured() }, nil)
self.configure(firstrun: true)
DisplayManager.shared.createGammaActivityEnforcer()
self.brightnessAutomationManager.start()
self.updaterController.startUpdater()
}
@ -81,6 +84,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self.settingsWindowController.show()
}
@objc func brightnessAutomationsClicked(_: AnyObject) {
os_log("Brightness Automations clicked", type: .info)
menu.closeMenu()
if self.brightnessAutomationWindowController == nil {
self.brightnessAutomationWindowController = BrightnessAutomationWindowController(manager: self.brightnessAutomationManager)
}
self.brightnessAutomationWindowController?.showWindow(self)
}
func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool {
app.prefsClicked(self)
return true
@ -152,6 +164,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
displaysPrefsVc?.loadDisplayList()
self.brightnessAutomationWindowController?.reloadData()
self.brightnessAutomationManager.handleWakeOrDisplayChange()
self.job(start: true)
}
@ -208,6 +222,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self.job(start: true)
}
self.startupActionWriteRepeatAfterSober()
self.brightnessAutomationManager.handleWakeOrDisplayChange()
self.updateMediaKeyTap()
}
}

View file

@ -0,0 +1,246 @@
// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others
import Cocoa
import os.log
struct BrightnessAutomation: Codable, Equatable {
enum TargetMode: String, Codable {
case all
case specific
}
var id: String
var isEnabled: Bool
var hour: Int
var minute: Int
var brightness: Float
var targetMode: TargetMode
var targetDisplayPrefsIds: [String]
var targetDisplayLabels: [String]
var lastRunDate: Date?
var minuteOfDay: Int {
self.hour * 60 + self.minute
}
}
final class BrightnessAutomationManager {
private(set) var automations: [BrightnessAutomation] = []
private var timer: Timer?
private let calendar = Calendar.current
init() {
self.load()
}
deinit {
self.timer?.invalidate()
}
func start() {
self.applyLatestMissedAutomationForToday()
self.scheduleNextRun()
}
func handleWakeOrDisplayChange() {
self.applyLatestMissedAutomationForToday()
self.scheduleNextRun()
}
func upsert(_ automation: BrightnessAutomation) {
if let index = self.automations.firstIndex(where: { $0.id == automation.id }) {
self.automations[index] = automation
} else {
self.automations.append(automation)
}
self.sortAutomations()
self.save()
self.scheduleNextRun()
}
func delete(id: String) {
self.automations.removeAll { $0.id == id }
self.save()
self.scheduleNextRun()
}
func availableDisplayTargets() -> [(prefsId: String, label: String)] {
DisplayManager.shared.getAllDisplays()
.filter { self.canControlBrightness($0) }
.map { display in
let friendlyName = display.readPrefAsString(key: .friendlyName)
return (display.prefsId, friendlyName.isEmpty ? display.name : friendlyName)
}
}
func summary(for automation: BrightnessAutomation) -> String {
let time = String(format: "%02d:%02d", automation.hour, automation.minute)
let brightness = String(format: "%.0f%%", Double(automation.brightness) * 100)
let target: String
if automation.targetMode == .all {
target = NSLocalizedString("All displays", comment: "Shown in brightness automation window")
} else if automation.targetDisplayLabels.isEmpty {
target = NSLocalizedString("No displays", comment: "Shown in brightness automation window")
} else {
target = automation.targetDisplayLabels.joined(separator: ", ")
}
return "\(automation.isEnabled ? "" : "Off - ")\(time) - \(brightness) - \(target)"
}
private func load() {
guard let data = prefs.data(forKey: PrefKey.brightnessAutomations.rawValue) else {
self.automations = []
return
}
do {
self.automations = try JSONDecoder().decode([BrightnessAutomation].self, from: data)
self.sortAutomations()
} catch {
os_log("Unable to load brightness automations: %{public}@", type: .error, error.localizedDescription)
self.automations = []
}
}
private func save() {
do {
let data = try JSONEncoder().encode(self.automations)
prefs.set(data, forKey: PrefKey.brightnessAutomations.rawValue)
} catch {
os_log("Unable to save brightness automations: %{public}@", type: .error, error.localizedDescription)
}
}
private func sortAutomations() {
self.automations.sort {
if $0.minuteOfDay == $1.minuteOfDay {
return self.summary(for: $0).localizedStandardCompare(self.summary(for: $1)) == .orderedAscending
}
return $0.minuteOfDay < $1.minuteOfDay
}
}
private func scheduleNextRun() {
self.timer?.invalidate()
self.timer = nil
let enabledAutomations = self.automations.filter(\.isEnabled)
guard !enabledAutomations.isEmpty else {
return
}
let now = Date()
guard let next = enabledAutomations.compactMap({ automation -> (Date, String)? in
guard let date = self.nextRunDate(for: automation, after: now) else {
return nil
}
return (date, automation.id)
}).min(by: { $0.0 < $1.0 }) else {
return
}
let timer = Timer(timeInterval: max(0.1, next.0.timeIntervalSince(now)), repeats: false) { [weak self] _ in
self?.timerFired(automationID: next.1)
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
private func timerFired(automationID: String) {
defer {
self.scheduleNextRun()
}
guard app.sleepID == 0, app.reconfigureID == 0 else {
return
}
guard let index = self.automations.firstIndex(where: { $0.id == automationID }), self.automations[index].isEnabled else {
return
}
if self.apply(self.automations[index]) {
self.automations[index].lastRunDate = Date()
self.save()
}
}
private func applyLatestMissedAutomationForToday() {
guard app.sleepID == 0, app.reconfigureID == 0 else {
return
}
let now = Date()
let currentMinuteOfDay = self.minuteOfDay(for: now)
guard let automation = self.automations
.filter({ $0.isEnabled && $0.minuteOfDay <= currentMinuteOfDay && !self.hasRun($0, onSameDayAs: now) })
.max(by: { $0.minuteOfDay < $1.minuteOfDay }) else {
return
}
guard let index = self.automations.firstIndex(where: { $0.id == automation.id }) else {
return
}
if self.apply(automation) {
self.automations[index].lastRunDate = now
self.save()
}
}
private func apply(_ automation: BrightnessAutomation) -> Bool {
let value = max(0, min(1, automation.brightness))
let targetDisplays = self.targetDisplays(for: automation)
guard !targetDisplays.isEmpty else {
os_log("Brightness automation %{public}@ skipped because no target displays are available.", type: .info, automation.id)
return false
}
for display in targetDisplays {
if display.setBrightness(value) {
display.sliderHandler[.brightness]?.setValue(value, displayID: display.identifier)
}
}
return true
}
private func targetDisplays(for automation: BrightnessAutomation) -> [Display] {
let displays = DisplayManager.shared.getAllDisplays().filter { self.canControlBrightness($0) }
if automation.targetMode == .all {
return displays
}
let selected = Set(automation.targetDisplayPrefsIds)
return displays.filter { selected.contains($0.prefsId) }
}
private func canControlBrightness(_ display: Display) -> Bool {
if display.isDummy {
return false
}
if let otherDisplay = display as? OtherDisplay, otherDisplay.isSw() {
return true
}
return !display.readPrefAsBool(key: .unavailableDDC, for: .brightness)
}
private func nextRunDate(for automation: BrightnessAutomation, after date: Date) -> Date? {
var components = self.calendar.dateComponents([.year, .month, .day], from: date)
components.hour = automation.hour
components.minute = automation.minute
components.second = 0
guard var runDate = self.calendar.date(from: components) else {
return nil
}
if runDate <= date {
guard let tomorrow = self.calendar.date(byAdding: .day, value: 1, to: runDate) else {
return nil
}
runDate = tomorrow
}
return runDate
}
private func minuteOfDay(for date: Date) -> Int {
let components = self.calendar.dateComponents([.hour, .minute], from: date)
return (components.hour ?? 0) * 60 + (components.minute ?? 0)
}
private func hasRun(_ automation: BrightnessAutomation, onSameDayAs date: Date) -> Bool {
guard let lastRunDate = automation.lastRunDate else {
return false
}
return self.calendar.isDate(lastRunDate, inSameDayAs: date)
}
}

View file

@ -0,0 +1,375 @@
// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others
import Cocoa
final class BrightnessAutomationWindowController: NSWindowController, NSTableViewDataSource, NSTableViewDelegate {
private let manager: BrightnessAutomationManager
private let tableView = NSTableView()
private let enabledButton = NSButton(checkboxWithTitle: NSLocalizedString("Enabled", comment: "Shown in brightness automation window"), target: nil, action: nil)
private let timePicker = NSDatePicker()
private let brightnessSlider = NSSlider(value: 0.5, minValue: 0, maxValue: 1, target: nil, action: nil)
private let brightnessPercentLabel = NSTextField(labelWithString: "50%")
private let targetPopup = NSPopUpButton()
private let monitorStack = NSStackView()
private let addButton = NSButton(title: NSLocalizedString("Add", comment: "Shown in brightness automation window"), target: nil, action: nil)
private let saveButton = NSButton(title: NSLocalizedString("Save", comment: "Shown in brightness automation window"), target: nil, action: nil)
private let deleteButton = NSButton(title: NSLocalizedString("Delete", comment: "Shown in brightness automation window"), target: nil, action: nil)
private let saveFeedbackLabel = NSTextField(labelWithString: "")
private var monitorButtons: [NSButton] = []
private var selectedAutomationID: String?
init(manager: BrightnessAutomationManager) {
self.manager = manager
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 720, height: 430),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = NSLocalizedString("Brightness Automations", comment: "Shown in brightness automation window")
window.minSize = NSSize(width: 620, height: 360)
super.init(window: window)
self.buildInterface()
self.reloadData()
}
required init?(coder _: NSCoder) {
nil
}
override func showWindow(_ sender: Any?) {
self.reloadData()
super.showWindow(sender)
self.window?.center()
NSApp.activate(ignoringOtherApps: true)
}
func reloadData() {
self.tableView.reloadData()
self.reloadMonitorButtons()
if let selectedAutomationID = self.selectedAutomationID, let automation = self.manager.automations.first(where: { $0.id == selectedAutomationID }) {
self.load(automation)
} else if let firstAutomation = self.manager.automations.first {
self.select(automationID: firstAutomation.id)
} else {
self.selectedAutomationID = nil
self.loadDefaultForm()
}
self.updateButtonState()
}
private func buildInterface() {
guard let contentView = self.window?.contentView else {
return
}
let rootStack = NSStackView()
rootStack.orientation = .horizontal
rootStack.spacing = 18
rootStack.edgeInsets = NSEdgeInsets(top: 18, left: 18, bottom: 18, right: 18)
rootStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(rootStack)
NSLayoutConstraint.activate([
rootStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
rootStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
rootStack.topAnchor.constraint(equalTo: contentView.topAnchor),
rootStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.borderType = .bezelBorder
scrollView.translatesAutoresizingMaskIntoConstraints = false
self.tableView.headerView = nil
self.tableView.usesAlternatingRowBackgroundColors = true
self.tableView.rowHeight = 34
self.tableView.delegate = self
self.tableView.dataSource = self
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("automation"))
column.title = NSLocalizedString("Automations", comment: "Shown in brightness automation window")
column.resizingMask = .autoresizingMask
self.tableView.addTableColumn(column)
scrollView.documentView = self.tableView
rootStack.addArrangedSubview(scrollView)
scrollView.widthAnchor.constraint(equalToConstant: 300).isActive = true
let formStack = NSStackView()
formStack.orientation = .vertical
formStack.alignment = .leading
formStack.spacing = 12
formStack.translatesAutoresizingMaskIntoConstraints = false
rootStack.addArrangedSubview(formStack)
formStack.widthAnchor.constraint(greaterThanOrEqualToConstant: 330).isActive = true
self.enabledButton.target = self
self.enabledButton.action = #selector(self.formChanged)
formStack.addArrangedSubview(self.enabledButton)
self.timePicker.datePickerElements = [.hourMinute]
self.timePicker.datePickerStyle = .textFieldAndStepper
self.timePicker.target = self
self.timePicker.action = #selector(self.formChanged)
self.addFormRow(title: NSLocalizedString("Time", comment: "Shown in brightness automation window"), control: self.timePicker, to: formStack)
let brightnessStack = NSStackView()
brightnessStack.orientation = .horizontal
brightnessStack.spacing = 8
brightnessStack.translatesAutoresizingMaskIntoConstraints = false
self.brightnessSlider.target = self
self.brightnessSlider.action = #selector(self.brightnessChanged)
self.brightnessSlider.widthAnchor.constraint(equalToConstant: 190).isActive = true
self.brightnessPercentLabel.alignment = .right
self.brightnessPercentLabel.widthAnchor.constraint(equalToConstant: 44).isActive = true
brightnessStack.addArrangedSubview(self.brightnessSlider)
brightnessStack.addArrangedSubview(self.brightnessPercentLabel)
self.addFormRow(title: NSLocalizedString("Brightness", comment: "Shown in brightness automation window"), control: brightnessStack, to: formStack)
self.targetPopup.addItem(withTitle: NSLocalizedString("All displays", comment: "Shown in brightness automation window"))
self.targetPopup.lastItem?.tag = 0
self.targetPopup.addItem(withTitle: NSLocalizedString("Specific displays", comment: "Shown in brightness automation window"))
self.targetPopup.lastItem?.tag = 1
self.targetPopup.target = self
self.targetPopup.action = #selector(self.targetChanged)
self.addFormRow(title: NSLocalizedString("Targets", comment: "Shown in brightness automation window"), control: self.targetPopup, to: formStack)
self.monitorStack.orientation = .vertical
self.monitorStack.alignment = .leading
self.monitorStack.spacing = 6
self.monitorStack.translatesAutoresizingMaskIntoConstraints = false
formStack.addArrangedSubview(self.monitorStack)
let spacer = NSView()
spacer.translatesAutoresizingMaskIntoConstraints = false
formStack.addArrangedSubview(spacer)
spacer.heightAnchor.constraint(greaterThanOrEqualToConstant: 20).isActive = true
let buttonStack = NSStackView()
buttonStack.orientation = .horizontal
buttonStack.spacing = 8
buttonStack.alignment = .centerY
self.addButton.target = self
self.addButton.action = #selector(self.addAutomation)
self.saveButton.target = self
self.saveButton.action = #selector(self.saveAutomation)
self.deleteButton.target = self
self.deleteButton.action = #selector(self.deleteAutomation)
self.saveFeedbackLabel.textColor = .secondaryLabelColor
buttonStack.addArrangedSubview(self.addButton)
buttonStack.addArrangedSubview(self.saveButton)
buttonStack.addArrangedSubview(self.deleteButton)
buttonStack.addArrangedSubview(self.saveFeedbackLabel)
formStack.addArrangedSubview(buttonStack)
}
private func addFormRow(title: String, control: NSView, to stack: NSStackView) {
let row = NSStackView()
row.orientation = .horizontal
row.alignment = .centerY
row.spacing = 12
let label = NSTextField(labelWithString: title)
label.alignment = .right
label.widthAnchor.constraint(equalToConstant: 82).isActive = true
row.addArrangedSubview(label)
row.addArrangedSubview(control)
stack.addArrangedSubview(row)
}
private func reloadMonitorButtons() {
for view in self.monitorStack.arrangedSubviews {
self.monitorStack.removeArrangedSubview(view)
view.removeFromSuperview()
}
self.monitorButtons = self.manager.availableDisplayTargets().map { target in
let button = NSButton(checkboxWithTitle: target.label, target: self, action: #selector(self.formChanged))
button.identifier = NSUserInterfaceItemIdentifier(target.prefsId)
self.monitorStack.addArrangedSubview(button)
return button
}
if self.monitorButtons.isEmpty {
let label = NSTextField(labelWithString: NSLocalizedString("No controllable displays are currently available.", comment: "Shown in brightness automation window"))
label.textColor = .secondaryLabelColor
self.monitorStack.addArrangedSubview(label)
}
}
private func loadDefaultForm() {
let components = Calendar.current.dateComponents([.hour, .minute], from: Date())
self.enabledButton.state = .on
self.timePicker.dateValue = self.dateForTime(hour: components.hour ?? 0, minute: components.minute ?? 0)
self.brightnessSlider.floatValue = 0.5
self.targetPopup.selectItem(withTag: 0)
for button in self.monitorButtons {
button.state = .off
}
self.brightnessChanged(self.brightnessSlider)
self.updateMonitorButtonState()
}
private func load(_ automation: BrightnessAutomation) {
self.enabledButton.state = automation.isEnabled ? .on : .off
self.timePicker.dateValue = self.dateForTime(hour: automation.hour, minute: automation.minute)
self.brightnessSlider.floatValue = automation.brightness
self.targetPopup.selectItem(withTag: automation.targetMode == .all ? 0 : 1)
let selectedDisplayIds = Set(automation.targetDisplayPrefsIds)
for button in self.monitorButtons {
button.state = selectedDisplayIds.contains(button.identifier?.rawValue ?? "") ? .on : .off
}
self.brightnessChanged(self.brightnessSlider)
self.updateMonitorButtonState()
}
private func select(automationID: String) {
self.selectedAutomationID = automationID
if let row = self.manager.automations.firstIndex(where: { $0.id == automationID }) {
self.tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
}
if let automation = self.manager.automations.first(where: { $0.id == automationID }) {
self.load(automation)
}
self.updateButtonState()
}
private func automationFromForm(existingID: String?) -> BrightnessAutomation? {
let specificTargets = self.monitorButtons.filter { $0.state == .on }.compactMap { button -> (String, String)? in
guard let prefsId = button.identifier?.rawValue else {
return nil
}
return (prefsId, button.title)
}
if self.targetPopup.selectedTag() == 1, specificTargets.isEmpty {
self.showAlert(message: NSLocalizedString("Select at least one display.", comment: "Shown in brightness automation window"))
return nil
}
let timeComponents = Calendar.current.dateComponents([.hour, .minute], from: self.timePicker.dateValue)
let existing = existingID.flatMap { id in self.manager.automations.first { $0.id == id } }
return BrightnessAutomation(
id: existingID ?? UUID().uuidString,
isEnabled: self.enabledButton.state == .on,
hour: timeComponents.hour ?? 0,
minute: timeComponents.minute ?? 0,
brightness: self.brightnessSlider.floatValue,
targetMode: self.targetPopup.selectedTag() == 0 ? .all : .specific,
targetDisplayPrefsIds: specificTargets.map(\.0),
targetDisplayLabels: specificTargets.map(\.1),
lastRunDate: existing?.lastRunDate
)
}
private func updateButtonState() {
let hasSelection = self.selectedAutomationID != nil
self.saveButton.isEnabled = hasSelection
self.deleteButton.isEnabled = hasSelection
}
private func updateMonitorButtonState() {
let isSpecific = self.targetPopup.selectedTag() == 1
self.monitorStack.isHidden = !isSpecific
for button in self.monitorButtons {
button.isEnabled = isSpecific
}
}
private func dateForTime(hour: Int, minute: Int) -> Date {
var components = Calendar.current.dateComponents([.year, .month, .day], from: Date())
components.hour = hour
components.minute = minute
components.second = 0
return Calendar.current.date(from: components) ?? Date()
}
private func showAlert(message: String) {
let alert = NSAlert()
alert.messageText = message
alert.addButton(withTitle: NSLocalizedString("OK", comment: "Shown in alert dialog"))
if let window = self.window {
alert.beginSheetModal(for: window)
} else {
alert.runModal()
}
}
@objc private func addAutomation() {
guard let automation = self.automationFromForm(existingID: nil) else {
return
}
self.manager.upsert(automation)
self.reloadData()
self.select(automationID: automation.id)
self.showSaveFeedback()
}
@objc private func saveAutomation() {
guard let selectedAutomationID = self.selectedAutomationID, let automation = self.automationFromForm(existingID: selectedAutomationID) else {
return
}
self.manager.upsert(automation)
self.reloadData()
self.select(automationID: automation.id)
}
@objc private func deleteAutomation() {
guard let selectedAutomationID = self.selectedAutomationID else {
return
}
self.manager.delete(id: selectedAutomationID)
self.selectedAutomationID = nil
self.reloadData()
}
@objc private func brightnessChanged(_: Any) {
self.brightnessPercentLabel.stringValue = String(format: "%.0f%%", Double(self.brightnessSlider.floatValue) * 100)
}
@objc private func targetChanged(_: Any) {
self.updateMonitorButtonState()
}
@objc private func formChanged(_: Any) {}
private func showSaveFeedback() {
self.saveFeedbackLabel.stringValue = NSLocalizedString("Saved", comment: "Shown in brightness automation window")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
self?.saveFeedbackLabel.stringValue = ""
}
}
func numberOfRows(in _: NSTableView) -> Int {
self.manager.automations.count
}
func tableView(_: NSTableView, viewFor _: NSTableColumn?, row: Int) -> NSView? {
let identifier = NSUserInterfaceItemIdentifier("automationCell")
let cell = self.tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView ?? NSTableCellView()
cell.identifier = identifier
let textField: NSTextField
if let existingTextField = cell.textField {
textField = existingTextField
} else {
textField = NSTextField(labelWithString: "")
textField.lineBreakMode = .byTruncatingTail
textField.translatesAutoresizingMaskIntoConstraints = false
cell.addSubview(textField)
cell.textField = textField
NSLayoutConstraint.activate([
textField.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8),
textField.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
textField.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
])
}
textField.stringValue = self.manager.summary(for: self.manager.automations[row])
textField.textColor = self.manager.automations[row].isEnabled ? .labelColor : .secondaryLabelColor
return cell
}
func tableViewSelectionDidChange(_: Notification) {
let row = self.tableView.selectedRow
guard row >= 0, row < self.manager.automations.count else {
self.selectedAutomationID = nil
self.updateButtonState()
return
}
self.selectedAutomationID = self.manager.automations[row].id
self.load(self.manager.automations[row])
self.updateButtonState()
}
}

View file

@ -236,6 +236,18 @@ class MenuHandler: NSMenu, NSMenuDelegate {
let menuItemView = NSView(frame: NSRect(x: 0, y: 0, width: viewWidth, height: iconSize + 10))
let automationsIcon = NSButton()
automationsIcon.bezelStyle = .regularSquare
automationsIcon.isBordered = false
automationsIcon.setButtonType(.momentaryChange)
automationsIcon.image = NSImage(systemSymbolName: "clock", accessibilityDescription: NSLocalizedString("Brightness Automations…", comment: "Shown in menu"))
automationsIcon.alternateImage = NSImage(systemSymbolName: "clock.fill", accessibilityDescription: NSLocalizedString("Brightness Automations…", comment: "Shown in menu"))
automationsIcon.alphaValue = 0.3
automationsIcon.frame = NSRect(x: 17 + compensateForBlock, y: menuItemView.frame.origin.y + 5, width: iconSize, height: iconSize)
automationsIcon.imageScaling = .scaleProportionallyUpOrDown
automationsIcon.target = app
automationsIcon.action = #selector(app.brightnessAutomationsClicked)
let settingsIcon = NSButton()
settingsIcon.bezelStyle = .regularSquare
settingsIcon.isBordered = false
@ -273,6 +285,7 @@ class MenuHandler: NSMenu, NSMenuDelegate {
quitIcon.imageScaling = .scaleProportionallyUpOrDown
quitIcon.action = #selector(app.quitClicked)
menuItemView.addSubview(automationsIcon)
menuItemView.addSubview(settingsIcon)
menuItemView.addSubview(updateIcon)
menuItemView.addSubview(quitIcon)
@ -283,6 +296,9 @@ class MenuHandler: NSMenu, NSMenuDelegate {
if app.macOS10() {
self.insertItem(NSMenuItem.separator(), at: self.items.count)
}
let automationsItem = NSMenuItem(title: NSLocalizedString("Brightness Automations…", comment: "Shown in menu"), action: #selector(app.brightnessAutomationsClicked), keyEquivalent: "")
automationsItem.target = app
self.insertItem(automationsItem, at: self.items.count)
self.insertItem(withTitle: NSLocalizedString("Settings…", comment: "Shown in menu"), action: #selector(app.prefsClicked), keyEquivalent: ",", at: self.items.count)
let updateItem = NSMenuItem(title: NSLocalizedString("Check for updates…", comment: "Shown in menu"), action: #selector(app.updaterController.checkForUpdates(_:)), keyEquivalent: "")
updateItem.target = app.updaterController