diff --git a/MonitorControl.xcodeproj/project.pbxproj b/MonitorControl.xcodeproj/project.pbxproj index 3ae92ca..abcf581 100644 --- a/MonitorControl.xcodeproj/project.pbxproj +++ b/MonitorControl.xcodeproj/project.pbxproj @@ -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 = ""; }; AA99E81627622EBE00413316 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InternetAccessPolicy.strings"; sourceTree = ""; }; AA99E81727622EBE00413316 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + AA9A10002DCD000100000001 /* BrightnessAutomationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessAutomationManager.swift; sourceTree = ""; }; + AA9A10032DCD000100000001 /* BrightnessAutomationWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessAutomationWindowController.swift; sourceTree = ""; }; AA9AE86E26B5BF3D00B6CA65 /* OSD.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OSD.framework; path = /System/Library/PrivateFrameworks/OSD.framework; sourceTree = ""; }; AA9AE87026B5BFB700B6CA65 /* CoreDisplay.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreDisplay.framework; path = /System/Library/Frameworks/CoreDisplay.framework; sourceTree = ""; }; AA9CB6252704BB440086DC0E /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Main.strings; sourceTree = ""; }; @@ -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 */, @@ -869,7 +877,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 7100; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 299YSU96J7; + DEVELOPMENT_TEAM = 5KP38NQP6R; ENABLE_HARDENED_RUNTIME = YES; FRAMEWORK_SEARCH_PATHS = ( "$(PROJECT_DIR)/**", @@ -906,7 +914,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 7100; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 299YSU96J7; + DEVELOPMENT_TEAM = 5KP38NQP6R; ENABLE_HARDENED_RUNTIME = YES; FRAMEWORK_SEARCH_PATHS = ( "$(PROJECT_DIR)/**", @@ -946,7 +954,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 7100; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 299YSU96J7; + DEVELOPMENT_TEAM = 5KP38NQP6R; ENABLE_HARDENED_RUNTIME = YES; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = MonitorControlHelper/Info.plist; @@ -977,7 +985,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 7100; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 299YSU96J7; + DEVELOPMENT_TEAM = 5KP38NQP6R; ENABLE_HARDENED_RUNTIME = YES; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = MonitorControlHelper/Info.plist; diff --git a/MonitorControl/Enums/PrefKey.swift b/MonitorControl/Enums/PrefKey.swift index 76b5f29..682fba5 100644 --- a/MonitorControl/Enums/PrefKey.swift +++ b/MonitorControl/Enums/PrefKey.swift @@ -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 diff --git a/MonitorControl/Support/AppDelegate.swift b/MonitorControl/Support/AppDelegate.swift index 6d1013b..f54c3c0 100644 --- a/MonitorControl/Support/AppDelegate.swift +++ b/MonitorControl/Support/AppDelegate.swift @@ -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() } } diff --git a/MonitorControl/Support/BrightnessAutomationManager.swift b/MonitorControl/Support/BrightnessAutomationManager.swift new file mode 100644 index 0000000..3ecc043 --- /dev/null +++ b/MonitorControl/Support/BrightnessAutomationManager.swift @@ -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) + } +} diff --git a/MonitorControl/Support/BrightnessAutomationWindowController.swift b/MonitorControl/Support/BrightnessAutomationWindowController.swift new file mode 100644 index 0000000..99e5291 --- /dev/null +++ b/MonitorControl/Support/BrightnessAutomationWindowController.swift @@ -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() + } +} diff --git a/MonitorControl/Support/MenuHandler.swift b/MonitorControl/Support/MenuHandler.swift index 6fcd486..2c146e3 100644 --- a/MonitorControl/Support/MenuHandler.swift +++ b/MonitorControl/Support/MenuHandler.swift @@ -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