feat: migrate to standard os-backed preferences (closes #161)

This commit is contained in:
louis.pontoise authored and lwouis committed 2020-03-10 14:00:37 +09:00
1 parent 069382c119
commit e28c43f227
9 files changed
+105 -248

No files matched your search

+2 -2
View File
@@ -42,9 +42,9 @@ class DebugProfile {
}
private static func appPreferences() -> String {
return nestedSeparator + Preferences.rawValues
return nestedSeparator + Preferences.all
.sorted { $0.0 < $1.0 }
.map { $0 + intraSeparator + $1 }
.map { $0.key + intraSeparator + Preferences.getAsString($0.key)! }
.joined(separator: nestedSeparator)
}
+86 -166
View File
@@ -1,193 +1,113 @@
import Cocoa
import Carbon.HIToolbox.Events
let defaults = UserDefaults.standard
class Preferences {
// the following constant are not exposed as preferences but may be in the future, probably through macro preferences
static let windowMaterial = NSVisualEffectView.Material.dark
static let fontColor = NSColor.white
static let windowPadding = CGFloat(23)
static let interCellPadding = CGFloat(5)
static let intraCellPadding = CGFloat(5)
static let fontIconSize = CGFloat(20)
static let themeMacro = MacroPreferenceHelper<(CGFloat, CGFloat, CGFloat, NSColor, NSColor)>([
MacroPreference(" macOS", (0, 5, 20, .clear, NSColor(red: 0, green: 0, blue: 0, alpha: 0.4))),
MacroPreference("❖ Windows 10", (2, 0, 0, .white, .clear))
])
static let metaKeyMacro = MacroPreferenceHelper<([Int], NSEvent.ModifierFlags)>([
MacroPreference("⌥ option", ([kVK_Option, kVK_RightOption], .option)),
MacroPreference("⌃ control", ([kVK_Control, kVK_RightControl], .control)),
MacroPreference("⌘ command", ([kVK_Command, kVK_RightCommand], .command))
])
static let showOnScreenMacro = MacroPreferenceHelper<ShowOnScreenPreference>([
MacroPreference("Main screen", ShowOnScreenPreference.main),
MacroPreference("Screen including mouse", ShowOnScreenPreference.mouse),
])
static var defaults: [String: String] = [
"maxScreenUsage": "80",
"minCellsPerRow": "5",
"maxCellsPerRow": "10",
"minRows": "3",
"iconSize": "32",
"fontHeight": "15",
"tabKeyCode": String(kVK_Tab),
"metaKey": metaKeyMacro.macros[0].label,
"windowDisplayDelay": "0",
"theme": themeMacro.macros[0].label,
"showOnScreen": showOnScreenMacro.macros[0].label,
"hideSpaceNumberLabels": String(false),
// default values
static var defaultValues: [String : Any] = [
"maxScreenUsage": Float(80),
"minCellsPerRow": Float(5),
"maxCellsPerRow": Float(10),
"minRows": Float(3),
"iconSize": Float(32),
"fontHeight": Float(15),
"tabKeyCode": kVK_Tab,
"windowDisplayDelay": 0,
"metaKey": MacroPreferences.metaKeyList.keys.first!,
"theme": MacroPreferences.themeList.keys.first!,
"showOnScreen": MacroPreferences.showOnScreenList.keys.first!,
"hideSpaceNumberLabels": false,
]
static var rawValues = [String: String]()
static var cellBorderWidth: CGFloat!
static var cellCornerRadius: CGFloat!
static var tabKeyCode: UInt16!
static var highlightBorderColor: NSColor!
static var highlightBackgroundColor: NSColor!
static var metaKeyCodes: [UInt16]!
static var metaModifierFlag: NSEvent.ModifierFlags!
static var windowDisplayDelay: DispatchTimeInterval!
static var windowCornerRadius: CGFloat!
static var showOnScreen: ShowOnScreenPreference!
static var hideSpaceNumberLabels: Bool!
static var maxScreenUsage: CGFloat!
static var iconSize: CGFloat!
static var fontHeight: CGFloat!
static var font: NSFont!
static var minCellsPerRow: CGFloat!
static var maxCellsPerRow: CGFloat!
static var minRows: CGFloat!
// constant values
// not exposed as preferences now but may be in the future, probably through macro preferences
static var windowMaterial: NSVisualEffectView.Material { .dark }
static var fontColor: NSColor { .white }
static var windowPadding: CGFloat { 23 }
static var interCellPadding: CGFloat { 5 }
static var intraCellPadding: CGFloat { 5 }
static var fontIconSize: CGFloat { 20 }
private static let defaultsFile = fileFromPreferencesFolder("alt-tab-macos-defaults.json")
private static let userFile = fileFromPreferencesFolder("alt-tab-macos.json")
// persisted values
static var maxScreenUsage: CGFloat { CGFloat(defaults.float(forKey: "maxScreenUsage") / 100) }
static var minCellsPerRow: CGFloat { CGFloat(defaults.float(forKey: "minCellsPerRow")) }
static var maxCellsPerRow: CGFloat { CGFloat(defaults.float(forKey: "maxCellsPerRow")) }
static var minRows: CGFloat { CGFloat(defaults.float(forKey: "minRows")) }
static var iconSize: CGFloat { CGFloat(defaults.float(forKey: "iconSize")) }
static var fontHeight: CGFloat { CGFloat(defaults.float(forKey: "fontHeight")) }
static var tabKeyCode: UInt16 { UInt16(defaults.integer(forKey: "tabKeyCode")) }
static var windowDisplayDelay: DispatchTimeInterval { DispatchTimeInterval.milliseconds(defaults.integer(forKey: "windowDisplayDelay")) }
static var hideSpaceNumberLabels: Bool { defaults.bool(forKey: "hideSpaceNumberLabels") }
static func loadFromDiskAndUpdateValues() {
do {
try saveDefaultsToDisk()
let preferencesExist = FileManager.default.fileExists(atPath: userFile.path)
if !preferencesExist {
try FileManager.default.copyItem(at: defaultsFile, to: userFile)
}
rawValues = try loadFromDisk(userFile)
if preferencesExist {
let compatiblePreferences = rawValues.filter { defaults[$0.key] != nil }
rawValues = defaults.merging(compatiblePreferences) { (_, new) in new }
}
try rawValues.forEach { try updateAndValidateFromString($0.key, $0.value) }
if preferencesExist {
try saveRawToDisk()
}
} catch {
debugPrint("Error loading preferences", error)
if (FileManager.default.fileExists(atPath: userFile.path)) {
try! FileManager.default.removeItem(at: userFile)
}
loadFromDiskAndUpdateValues()
}
// macro values
static var theme: Theme { MacroPreferences.themeList[defaults.string(forKey: "theme")!]! }
static var metaKey: MetaKey { MacroPreferences.metaKeyList[defaults.string(forKey: "metaKey")!]! }
static var showOnScreen: ShowOnScreenPreference { MacroPreferences.showOnScreenList[defaults.string(forKey: "showOnScreen")!]! }
// derived values
static var cellBorderWidth: CGFloat { theme.cellBorderWidth }
static var cellCornerRadius: CGFloat { theme.cellCornerRadius }
static var windowCornerRadius: CGFloat { theme.windowCornerRadius }
static var highlightBorderColor: NSColor { theme.highlightBorderColor }
static var highlightBackgroundColor: NSColor { theme.highlightBackgroundColor }
static var metaKeyCodes: [UInt16] { metaKey.keyCodes.map { UInt16($0) } }
static var metaModifierFlag: NSEvent.ModifierFlags { metaKey.modifierFlag }
static var font: NSFont { NSFont.systemFont(ofSize: fontHeight) }
static func registerDefaults() {
defaults.register(defaults: defaultValues)
}
static func updateAndValidateFromString(_ valueName: String, _ value: String) throws {
switch valueName {
case "maxScreenUsage":
maxScreenUsage = try CGFloat(CGFloat(value).orThrow() / 100)
case "minCellsPerRow":
minCellsPerRow = try CGFloat(value).orThrow()
case "maxCellsPerRow":
maxCellsPerRow = try CGFloat(value).orThrow()
case "minRows":
minRows = try CGFloat(value).orThrow()
case "iconSize":
iconSize = try CGFloat(value).orThrow()
case "fontHeight":
fontHeight = try CGFloat(value).orThrow()
font = NSFont.systemFont(ofSize: fontHeight)
case "tabKeyCode":
tabKeyCode = try UInt16(value).orThrow()
case "metaKey":
let p = try metaKeyMacro.labelToMacro[value].orThrow()
metaKeyCodes = p.preferences.0.map { UInt16($0) }
metaModifierFlag = p.preferences.1
case "theme":
let p = try themeMacro.labelToMacro[value].orThrow()
cellBorderWidth = p.preferences.0
cellCornerRadius = p.preferences.1
windowCornerRadius = p.preferences.2
highlightBorderColor = p.preferences.3
highlightBackgroundColor = p.preferences.4
case "windowDisplayDelay":
windowDisplayDelay = DispatchTimeInterval.milliseconds(try Int(value).orThrow())
case "showOnScreen":
let p = try showOnScreenMacro.labelToMacro[value].orThrow()
showOnScreen = p.preferences
case "hideSpaceNumberLabels":
hideSpaceNumberLabels = try Bool(value).orThrow()
default:
throw NSError.make(domain: "Preferences", message: "Tried to update an unknown preference: '\(valueName)' = '\(value)'")
}
rawValues[valueName] = value
static func get(_ key: String) -> Any? {
defaults.object(forKey: key)
}
static func saveRawToDisk() throws {
ProcessInfo.processInfo.disableSuddenTermination()
try saveToDisk(rawValues, userFile)
ProcessInfo.processInfo.enableSuddenTermination()
static func getAsString(_ key: String) -> String? {
defaults.string(forKey: key)
}
private static func preferencesVersion(_ url: URL) throws -> Int {
return try Int(loadFromDisk(url)["version"] ?? "0").orThrow()
static func set(_ key: String, _ value: Any?) {
defaults.set(value, forKey: key)
}
private static func loadFromDisk(_ url: URL) throws -> [String: String] {
return try JSONDecoder().decode([String: String].self, from: Data(contentsOf: url))
}
private static func saveDefaultsToDisk() throws {
try saveToDisk(defaults, defaultsFile)
}
private static func saveToDisk(_ values: [String: String], _ path: URL) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
try encoder
.encode(values)
.write(to: path)
}
private static func fileFromPreferencesFolder(_ fileName: String) -> URL {
return FileManager.default
.urls(for: .libraryDirectory, in: .userDomainMask)
.first!
.appendingPathComponent("Preferences", isDirectory: true)
.appendingPathComponent(fileName)
}
static var all: [String: Any] { defaults.dictionaryRepresentation() }
}
struct MacroPreference<T> {
struct Theme {
let label: String
let preferences: T
init(_ label: String, _ preferences: T) {
self.label = label
self.preferences = preferences
}
let cellBorderWidth: CGFloat
let cellCornerRadius: CGFloat
let windowCornerRadius: CGFloat
let highlightBorderColor: NSColor
let highlightBackgroundColor: NSColor
}
class MacroPreferenceHelper<T> {
let macros: [MacroPreference<T>]
var labels = [String]()
var labelToMacro = [String: MacroPreference<T>]()
init(_ array: [MacroPreference<T>]) {
self.macros = array
array.forEach {
labelToMacro[$0.label] = $0
labels.append($0.label)
}
}
struct MetaKey {
let label: String
let keyCodes: [Int]
let modifierFlag: NSEvent.ModifierFlags
}
enum ShowOnScreenPreference {
case main
case mouse
}
// macros are collection of values derived from a single key
// we don't want to store every value in UserDefaults as the user could change them and contradict the macro
class MacroPreferences {
static let themeList = [
" macOS": Theme(label: " macOS", cellBorderWidth: 0, cellCornerRadius: 5, windowCornerRadius: 20, highlightBorderColor: .clear, highlightBackgroundColor: NSColor(red: 0, green: 0, blue: 0, alpha: 0.4)),
"❖ Windows 10": Theme(label: "❖ Windows 10", cellBorderWidth: 2, cellCornerRadius: 0, windowCornerRadius: 0, highlightBorderColor: .white, highlightBackgroundColor: .clear),
]
static let metaKeyList = [
"⌥ option": MetaKey(label: "⌥ option", keyCodes: [kVK_Option, kVK_RightOption], modifierFlag: .option),
"⌃ control": MetaKey(label: "⌃ control", keyCodes: [kVK_Control, kVK_RightControl], modifierFlag: .control),
"⌘ command": MetaKey(label: "⌘ command", keyCodes: [kVK_Command, kVK_RightCommand], modifierFlag: .command)
]
static let showOnScreenList = [
"Main screen": ShowOnScreenPreference.main,
"Screen including mouse": ShowOnScreenPreference.mouse,
]
}
+1 -1
View File
@@ -2,7 +2,7 @@ import Cocoa
class Screen {
static func preferred() -> NSScreen {
switch Preferences.showOnScreen! {
switch Preferences.showOnScreen {
case .mouse: return withMouse() ?? NSScreen.main!; // .main as fall-back
case .main: return NSScreen.main!;
}
+1 -1
View File
@@ -32,7 +32,7 @@ class App: NSApplication, NSApplicationDelegate, NSWindowDelegate {
#endif
SystemPermissions.ensureAccessibilityCheckboxIsChecked()
SystemPermissions.ensureScreenRecordingCheckboxIsChecked()
Preferences.loadFromDiskAndUpdateValues()
Preferences.registerDefaults()
statusItem = Menubar.make(self)
loadMainMenuXib()
initPreferencesDependentComponents()
@@ -4,7 +4,7 @@ class LabelAndControl: NSObject {
static var callbackTarget: PreferencesWindow!
static func makeLabelWithInput(_ labelText: String, _ rawName: String, _ width: CGFloat, _ suffixText: String? = nil, _ suffixUrl: String? = nil, _ validator: ((String) -> Bool)? = nil) -> [NSView] {
let input = TextField(Preferences.rawValues[rawName]!)
let input = TextField(Preferences.getAsString(rawName)!)
input.validationHandler = validator
input.delegate = input
input.visualizeValidationState()
@@ -15,19 +15,19 @@ class LabelAndControl: NSObject {
static func makeLabelWithCheckbox(_ labelText: String, _ rawName: String) -> [NSView] {
let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil)
setControlValue(checkbox, Preferences.rawValues[rawName]!)
setControlValue(checkbox, Preferences.getAsString(rawName)!)
return makeLabelWithProvidedControl(labelText, rawName, checkbox)
}
static func makeLabelWithDropdown(_ labelText: String, _ rawName: String, _ values: [String], _ suffixText: String? = nil) -> [NSView] {
let popUp = NSPopUpButton()
popUp.addItems(withTitles: values)
popUp.selectItem(withTitle: Preferences.rawValues[rawName]!)
popUp.selectItem(withTitle: Preferences.getAsString(rawName)!)
return makeLabelWithProvidedControl(labelText, rawName, popUp, suffixText)
}
static func makeLabelWithSlider(_ labelText: String, _ rawName: String, _ minValue: Double, _ maxValue: Double, _ numberOfTickMarks: Int, _ allowsTickMarkValuesOnly: Bool, _ unitText: String = "") -> [NSView] {
let value = Preferences.rawValues[rawName]!
let value = Preferences.getAsString(rawName)!
let suffixText = value + " " + unitText
let slider = NSSlider()
slider.minValue = minValue
@@ -74,8 +74,6 @@ class LabelAndControl: NSObject {
return suffix
}
static func getControlValue(_ control: NSControl) -> String {
if control is NSPopUpButton {
return (control as! NSPopUpButton).titleOfSelectedItem!
@@ -107,13 +105,10 @@ class LabelAndControl: NSObject {
}
}
private static func updateSuffixWithValue(_ control: NSControl, _ value: String) {
let suffixIdentifierPredicate = { (view: NSView) -> Bool in
view.identifier?.rawValue == control.identifier!.rawValue + ControlIdentifierDiscriminator.SUFFIX.rawValue
}
if let suffixView: NSTextField = control.superview?.subviews.first(where: suffixIdentifierPredicate) as? NSTextField {
let regex = try! NSRegularExpression(pattern: "^[0-9]+") // first decimal
let range = NSMakeRange(0, suffixView.stringValue.count)
@@ -1,7 +1,6 @@
import Cocoa
class PreferencesWindow: NSWindow {
var windowCloseRequested = false
let tabViewController = TabViewController()
override init(contentRect: NSRect, styleMask style: StyleMask, backing backingStoreType: BackingStoreType, defer flag: Bool) {
@@ -17,29 +16,10 @@ class PreferencesWindow: NSWindow {
}
func controlWasChanged(_ senderControl: NSControl) {
let key: String = senderControl.identifier!.rawValue
let previousValue: String = Preferences.rawValues[key]!
let newValue: String = LabelAndControl.getControlValue(senderControl)
let invalidTextField = senderControl is TextField && !(senderControl as! TextField).isValid()
if (invalidTextField && !windowCloseRequested) || (newValue == previousValue && !invalidTextField) {
return
}
let newValue = LabelAndControl.getControlValue(senderControl)
LabelAndControl.updateControlExtras(senderControl, newValue)
do {
// TODO: remove conditional as soon a Preference does validation on its own
if invalidTextField && windowCloseRequested {
throw NSError.make(domain: "Preferences", message: "Please enter a valid value for '" + key + "'")
}
try Preferences.updateAndValidateFromString(key, newValue)
try Preferences.saveRawToDisk()
(App.shared as! App).initPreferencesDependentComponents()
} catch let error {
debugPrint("PreferencesWindow: save: error", key, newValue, error)
showSaveErrorSheetModal(error as NSError, senderControl, key, previousValue) // allows recursive call by user choice
}
Preferences.set(senderControl.identifier!.rawValue, newValue)
(App.shared as! App).initPreferencesDependentComponents()
}
private func setupWindow() {
@@ -56,47 +36,4 @@ class PreferencesWindow: NSWindow {
tabViewController.addTabViewItem(UpdatesTab.make())
tabViewController.addTabViewItem(AboutTab.make())
}
private func challengeNextInvalidEditableTextField() {
let invalidFields = (contentView?
.findNestedViews(subclassOf: TextField.self)
.filter({ !$0.isValid() })
)
let focusedField = invalidFields?.filter({ $0.currentEditor() != nil }).first
let fieldToNotify = focusedField ?? invalidFields?.first
fieldToNotify?.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: fieldToNotify))
if fieldToNotify != focusedField {
makeFirstResponder(fieldToNotify)
}
}
private func showSaveErrorSheetModal(_ nsError: NSError, _ control: NSControl, _ key: String, _ previousValue: String) {
let alert = NSAlert()
alert.messageText = NSLocalizedString("Could not save Preference", comment: "")
alert.informativeText = nsError.localizedDescription + "\n"
alert.addButton(withTitle: NSLocalizedString("Edit", comment: ""))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
alert.addButton(withTitle: NSLocalizedString("Check again", comment: ""))
alert.beginSheetModal(for: self, completionHandler: { (modalResponse: NSApplication.ModalResponse) -> Void in
if modalResponse == NSApplication.ModalResponse.alertFirstButtonReturn {
debugPrint("PreferencesWindow: save: error: user choice: edit")
self.windowCloseRequested = false
}
if modalResponse == NSApplication.ModalResponse.alertSecondButtonReturn {
debugPrint("PreferencesWindow: save: error: user choice: cancel -> revert value and eventually close window")
try! Preferences.updateAndValidateFromString(key, previousValue)
LabelAndControl.setControlValue(control, previousValue)
LabelAndControl.updateControlExtras(control, previousValue)
if self.windowCloseRequested {
self.close()
}
}
if modalResponse == NSApplication.ModalResponse.alertThirdButtonReturn {
debugPrint("PreferencesWindow: save: error: user choice: check again")
self.controlWasChanged(control)
}
})
}
}
@@ -9,14 +9,14 @@ class AppearanceTab {
private static func makeView() -> NSGridView {
let view = GridView.make([
LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Theme", comment: ""), "theme", Preferences.themeMacro.labels),
LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Theme", comment: ""), "theme", MacroPreferences.themeList.values.map { $0.label }),
LabelAndControl.makeLabelWithSlider(NSLocalizedString("Max size on screen", comment: ""), "maxScreenUsage", 10, 100, 10, true, "%"),
LabelAndControl.makeLabelWithSlider(NSLocalizedString("Min windows per row", comment: ""), "minCellsPerRow", 1, 20, 20, true),
LabelAndControl.makeLabelWithSlider(NSLocalizedString("Max windows per row", comment: ""), "maxCellsPerRow", 1, 40, 20, true),
LabelAndControl.makeLabelWithSlider(NSLocalizedString("Min rows of windows", comment: ""), "minRows", 1, 20, 20, true),
LabelAndControl.makeLabelWithSlider(NSLocalizedString("Window app icon size", comment: ""), "iconSize", 0, 64, 11, false, "px"),
LabelAndControl.makeLabelWithSlider(NSLocalizedString("Window title font size", comment: ""), "fontHeight", 0, 64, 11, false, "px"),
LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Show on", comment: ""), "showOnScreen", Preferences.showOnScreenMacro.labels),
LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Show on", comment: ""), "showOnScreen", Array(MacroPreferences.showOnScreenList.keys)),
LabelAndControl.makeLabelWithSlider(NSLocalizedString("Apparition delay", comment: ""), "windowDisplayDelay", 0, 2000, 11, false, "ms"),
LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Hide space number labels", comment: ""), "hideSpaceNumberLabels"),
])
@@ -22,7 +22,7 @@ class ShortcutsTab {
}
let view = GridView.make([
LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Alt key", comment: ""), "metaKey", Preferences.metaKeyMacro.labels),
LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Alt key", comment: ""), "metaKey", MacroPreferences.metaKeyList.values.map { $0.label }),
LabelAndControl.makeLabelWithInput(NSLocalizedString("Tab key", comment: ""), "tabKeyCode", 33, NSLocalizedString("KeyCodes Reference", comment: ""), "https://eastmanreference.com/complete-list-of-applescript-key-codes", tabKeyCodeValidator),
])
view.column(at: 0).xPlacement = .trailing
@@ -6,6 +6,8 @@ class UpdatesTab: NSObject {
static var periodicallyCheck: NSButton!
static var periodicallyInstall: NSButton!
static var policyObserver = PolicyObserver()
// this helps prevent double-dipping (i.e. user updates the UI > changes the preference > updates the UI)
static var policyLock = false
static func make() -> NSTabViewItem {
return TabViewItem.make(NSLocalizedString("Updates", comment: ""), NSImage.refreshTemplateName, makeView())
@@ -49,13 +51,16 @@ class UpdatesTab: NSObject {
@objc
static func updatePolicyCallback() {
policyLock = true
SUUpdater.shared().automaticallyDownloadsUpdates = periodicallyInstall.state == .on
SUUpdater.shared().automaticallyChecksForUpdates = periodicallyInstall.state == .on || periodicallyCheck.state == .on
policyLock = false
}
}
class PolicyObserver: NSObject {
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
guard !UpdatesTab.policyLock else { return }
if SUUpdater.shared().automaticallyDownloadsUpdates {
UpdatesTab.periodicallyInstall.state = .on
// Sparkle UI "Automatically download and install updates in the future" doesn't activate periodical checks; we do it manually