mirror of
https://github.com/MonitorControl/MonitorControl.git
synced 2026-05-15 14:15:55 -06:00
Add daily brightness automations
This commit is contained in:
parent
3cfc40598a
commit
e5dd583a29
6 changed files with 667 additions and 4 deletions
|
|
@ -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 */,
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
246
MonitorControl/Support/BrightnessAutomationManager.swift
Normal file
246
MonitorControl/Support/BrightnessAutomationManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue