From 65327c2b89f1c95e36e6edc09bf3155622ba9bce Mon Sep 17 00:00:00 2001 From: Mariusz Bieniek Date: Fri, 8 Nov 2019 01:17:59 +0100 Subject: [PATCH] feat: improves PreferencesPanel UX, partially implements #49 implements requested changes from Code-Review and contains also refactoring and more features and improvements - Application Wide: changes all exceptions to NSError (required for error handling via sheet in panel) - PreferencesPanel: adds display of current value of Sliders - PreferencesPanel: adds validation with visualisation for TextFields (whitelist for KeyCode and ranges for maxScreenUsage & windowDisplayDelay) during editing, window open and close - PreferencesPanel: adds error sheet (in-window modal) at all known possible errors to display error information and ask the user how to proceed - PreferencesPanel: removes Restart button (and relaunch code in Application) - PreferencesPanel: adds Preferences being now live - PreferencesPanel: removes invisibleText workaround - PreferencesPanel: adds TextField value save on each keyDown event (if valid and changed) - PreferencesPanel: adds window activation/deactivation - PreferencesPanel: changes label texts of some Preferences - ... --- alt-tab-macos.xcodeproj/project.pbxproj | 4 + alt-tab-macos/logic/Extensions.swift | 28 +- alt-tab-macos/logic/Preferences.swift | 2 +- .../PreferencesPanelNSTextFieldEditable.swift | 34 ++ alt-tab-macos/ui/Application.swift | 23 +- alt-tab-macos/ui/PreferencesPanel.swift | 296 ++++++++++-------- 6 files changed, 247 insertions(+), 140 deletions(-) create mode 100644 alt-tab-macos/logic/PreferencesPanelNSTextFieldEditable.swift diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 7e0bfc1e..8559dd88 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ D04BA9CCE02D30C8164A552A /* SystemPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */; }; D04BAD4DE538FDF7E7532EE2 /* Labels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD32E130E4A061DC8332 /* Labels.swift */; }; D04BAEF78503D7A2CEFB9E9E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAA44C837F3A67403B9DB /* main.swift */; }; + F02981D5D1E1F62074801CAE /* PreferencesPanelNSTextFieldEditable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0298F4D7927608A986C320D /* PreferencesPanelNSTextFieldEditable.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -72,6 +73,7 @@ D04BAF076A30A1BAFEDBEA66 /* 5 windows - 2 lines.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "5 windows - 2 lines.jpg"; sourceTree = ""; }; D04BAF249324297C07E31164 /* frontpage.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = frontpage.jpg; sourceTree = ""; }; D04BAFA277EAE3BDDDB61110 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; + F0298F4D7927608A986C320D /* PreferencesPanelNSTextFieldEditable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesPanelNSTextFieldEditable.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -172,6 +174,7 @@ D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */, D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */, D04BA86768C6503A11ED81FC /* Extensions.swift */, + F0298F4D7927608A986C320D /* PreferencesPanelNSTextFieldEditable.swift */, ); path = logic; sourceTree = ""; @@ -302,6 +305,7 @@ D04BA02DD4152997C32CF50B /* StatusItem.swift in Sources */, D04BA0F3D46BC79544E2B930 /* Extensions.swift in Sources */, D04BAD4DE538FDF7E7532EE2 /* Labels.swift in Sources */, + F02981D5D1E1F62074801CAE /* PreferencesPanelNSTextFieldEditable.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/alt-tab-macos/logic/Extensions.swift b/alt-tab-macos/logic/Extensions.swift index 91321daa..3a2b5b29 100644 --- a/alt-tab-macos/logic/Extensions.swift +++ b/alt-tab-macos/logic/Extensions.swift @@ -19,15 +19,11 @@ extension Optional { return value case .none: Thread.callStackSymbols.forEach { print($0) } - throw "Optional contained nil" + throw NSError.make(domain: "Optional", message: "Optional contained nil") } } } -// allow String to be treated as Error (e.g. throw "explanation") -extension String: Error { -} - // add String constructor from CGFloat that round up at 1 decimal extension String { init?(_ cgFloat: CGFloat) { @@ -39,3 +35,25 @@ extension String { self.init(string) } } + +// add recursive lookup in subviews for specific type +extension NSView { + func findNestedViews(subclassOf: T.Type) -> [T] { + return recursiveSubviews.compactMap { $0 as? T } + } + + var recursiveSubviews: [NSView] { + return subviews + subviews.flatMap { $0.recursiveSubviews } + } +} + +// add convenience to NSError +extension NSError { + class func make(domain: String, message: String, code: Int = 9999) -> NSError { + return NSError( + domain: domain, + code: code, + userInfo: [NSLocalizedDescriptionKey: message, NSLocalizedFailureReasonErrorKey: message] + ) + } +} diff --git a/alt-tab-macos/logic/Preferences.swift b/alt-tab-macos/logic/Preferences.swift index 01572cb0..af8d7375 100644 --- a/alt-tab-macos/logic/Preferences.swift +++ b/alt-tab-macos/logic/Preferences.swift @@ -102,7 +102,7 @@ class Preferences { let p = try showOnScreenMacro.labelToMacro[value].orThrow() showOnScreen = p.preferences default: - throw "Tried to update an unknown preference: '\(valueName)' = '\(value)'" + throw NSError.make(domain: "Preferences", message: "Tried to update an unknown preference: '\(valueName)' = '\(value)'") } rawValues[valueName] = value } diff --git a/alt-tab-macos/logic/PreferencesPanelNSTextFieldEditable.swift b/alt-tab-macos/logic/PreferencesPanelNSTextFieldEditable.swift new file mode 100644 index 00000000..0e1adb32 --- /dev/null +++ b/alt-tab-macos/logic/PreferencesPanelNSTextFieldEditable.swift @@ -0,0 +1,34 @@ +import Cocoa + +class PreferencesPanelNSTextFieldEditable: NSTextField, NSTextFieldDelegate { + + var validationHandler: ((String)->Bool)? + + // protocol method + func controlTextDidChange(_ obj: Notification) { + visualizeValidationState(isValid()) + let textField = obj.object as! PreferencesPanelNSTextFieldEditable + sendAction(textField.action, to: textField.target) + } + + // custom method + func visualizeValidationState(_ isValid: Bool) -> Void { + if !isValid { + wantsLayer = true + layer?.borderColor = NSColor.systemRed.cgColor + layer?.borderWidth = 1 + } else { + wantsLayer = false + } + } + + // custom method + func isValid() -> Bool { + if let handler = validationHandler { + return handler(stringValue) + } + + return true + } + +} \ No newline at end of file diff --git a/alt-tab-macos/ui/Application.swift b/alt-tab-macos/ui/Application.swift index 3ce2d211..f5d7580d 100644 --- a/alt-tab-macos/ui/Application.swift +++ b/alt-tab-macos/ui/Application.swift @@ -4,7 +4,6 @@ import Cocoa class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { static let name = "AltTab" var statusItem: NSStatusItem? - var backgroundView: NSVisualEffectView? var thumbnailsPanel: ThumbnailsPanel? var preferencesPanel: PreferencesPanel? var selectedOpenWindow: Int = 0 @@ -28,11 +27,15 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { SystemPermissions.ensureAccessibilityCheckboxIsChecked() Preferences.loadFromDiskAndUpdateValues() statusItem = StatusItem.make(self) - thumbnailsPanel = ThumbnailsPanel(self) - preferencesPanel = PreferencesPanel() + initPreferencesDependentComponents() Keyboard.listenToGlobalEvents(self) } + // we put application code here which should be executed on init() and Preferences change + func initPreferencesDependentComponents() { + thumbnailsPanel = ThumbnailsPanel(self) + } + func showUiOrSelectNext() { debugPrint("showUiOrSelectNext") showUiOrCycleSelection(1) @@ -58,7 +61,11 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { } } - @objc func showPreferencesPanel() { + @objc + func showPreferencesPanel() { + if preferencesPanel == nil { + preferencesPanel = PreferencesPanel() + } Screen.showPanel(preferencesPanel!, Screen.preferredScreen(), .appleCentered) } @@ -125,12 +132,4 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { return openWindows.count > selectedOpenWindow ? openWindows[selectedOpenWindow] : nil } - func relaunch(afterDelay seconds: TimeInterval = 0.5) -> Never { - let task = Process() - task.launchPath = "/bin/sh" - task.arguments = ["-c", "sleep \(seconds); open \"\(Bundle.main.bundlePath)\""] - task.launch() - self.terminate(nil) - exit(0) - } } diff --git a/alt-tab-macos/ui/PreferencesPanel.swift b/alt-tab-macos/ui/PreferencesPanel.swift index f2fa7b98..375bf03f 100644 --- a/alt-tab-macos/ui/PreferencesPanel.swift +++ b/alt-tab-macos/ui/PreferencesPanel.swift @@ -1,38 +1,48 @@ import Cocoa +import Foundation -class PreferencesPanel: NSPanel { - // ui: base layout - let panelWidth = CGFloat(400) - let panelHeight = CGFloat(400) // gets auto adjusted to content height +class PreferencesPanel: NSPanel, NSWindowDelegate { + let panelWidth = CGFloat(496) + let panelHeight = CGFloat(256) // auto expands to content height (but does not auto shrink) let panelPadding = CGFloat(40) - let panelWidthToLabelRatio = CGFloat(0.5) - - // ui: preferences elements - var maxScreenUsage: NSSlider? - var maxThumbnailsPerRow: NSTextField? - var iconSize: NSSlider? - var fontHeight: NSSlider? - var tabKeyCode: NSTextField? - var windowDisplayDelay: NSTextField? - var metaKey: NSPopUpButton? - var theme: NSPopUpButton? - var showOnScreen: NSPopUpButton? - - var invisibleTextField: NSTextField? // default firstResponder and used for triggering of focus loose + var labelWidth: CGFloat { get { // derived convenience variable + return (panelWidth - panelPadding) * CGFloat(0.45) + }} + var windowCloseRequested = false override init(contentRect: NSRect, styleMask style: StyleMask, backing backingStoreType: BackingStoreType, defer flag: Bool) { let initialRect = NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight) super.init(contentRect: initialRect, styleMask: style, backing: backingStoreType, defer: flag) title = Application.name + " Preferences" - titlebarAppearsTransparent = true hidesOnDeactivate = false contentView = makeContentView() + NSApp.activate(ignoringOtherApps: true) + } - // setup hidden element - invisibleTextField = NSTextField() - invisibleTextField?.isHidden = true - contentView?.subviews.append(invisibleTextField!) - self.initialFirstResponder = invisibleTextField + override func close() { + NSApp.deactivate() + (NSApp as! Application).preferencesPanel = nil + super.close() + } + + public func windowShouldClose(_ sender: NSWindow) -> Bool { + windowCloseRequested = true + challengeNextInvalidEditableTextField() + return attachedSheet == nil // user is challenged + } + + private func challengeNextInvalidEditableTextField() { + let invalidFields = (contentView? + .findNestedViews(subclassOf: PreferencesPanelNSTextFieldEditable.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 makeContentView() -> NSView { @@ -53,20 +63,34 @@ class PreferencesPanel: NSPanel { } private func makePreferencesViews() -> [NSView] { + // TODO: make the validators be a part of each Preference + let tabKeyCodeValidator: ((String)->Bool) = { + guard let int = Int($0) else { + return false + } + // non-special keys (mac & pc keyboards): https://eastmanreference.com/complete-list-of-applescript-key-codes + var whitelistedKeycodes: [Int] = Array(0...53) + whitelistedKeycodes.append(contentsOf: [65, 67, 69, 75, 76, 78, ]) + whitelistedKeycodes.append(contentsOf: Array(81...89)) + whitelistedKeycodes.append(contentsOf: [91, 92, 115, 116, 117, 119, 121]) + whitelistedKeycodes.append(contentsOf: Array(123...126)) + return whitelistedKeycodes.contains(int) + } + let maxThumbnailsPerRowValidator: ((String)->Bool) = { (3...16).contains(Int($0) ?? -1) } + let windowDisplayDelayValidator: ((String)->Bool) = { (0..<10000).contains(Int($0) ?? -1) } + return [ - makeLabelWithDropdown(\PreferencesPanel.metaKey, "Meta key to activate the app", rawName: "metaKey", values: Preferences.metaKeyMacro.labels), - makeLabelWithInput(\PreferencesPanel.tabKeyCode, "Tab key", rawName: "tabKeyCode", suffixText: "KeyCode"), + makeLabelWithDropdown("Alt key", rawName: "metaKey", values: Preferences.metaKeyMacro.labels), + makeLabelWithInput("Tab key", rawName: "tabKeyCode", width: 33, suffixText: "KeyCode (48 = Tab)", validator: tabKeyCodeValidator), makeHorizontalSeparator(), - makeLabelWithDropdown(\PreferencesPanel.theme, "Main window theme", rawName: "theme", values: Preferences.themeMacro.labels), - makeLabelWithSlider(\PreferencesPanel.maxScreenUsage, "Max window size", rawName: "maxScreenUsage", minValue: 10, maxValue: 100, numberOfTickMarks: 10), - makeLabelWithInput(\PreferencesPanel.maxThumbnailsPerRow, "Max thumbnails per row", rawName: "maxThumbnailsPerRow"), - makeLabelWithSlider(\PreferencesPanel.iconSize, "Apps icon size", rawName: "iconSize", minValue: 12, maxValue: 64, numberOfTickMarks: 16), - makeLabelWithSlider(\PreferencesPanel.fontHeight, "Font size", rawName: "fontHeight", minValue: 12, maxValue: 36, numberOfTickMarks: 16), + makeLabelWithDropdown("Theme", rawName: "theme", values: Preferences.themeMacro.labels), + makeLabelWithSlider("Max screen usage", rawName: "maxScreenUsage", minValue: 10, maxValue: 100, numberOfTickMarks: 10, unitText: "%"), + makeLabelWithInput("Max thumbnails per row", rawName: "maxThumbnailsPerRow", width: 25, validator: maxThumbnailsPerRowValidator), + makeLabelWithSlider("Apps icon size", rawName: "iconSize", minValue: 12, maxValue: 62, numberOfTickMarks: 16, unitText: "px"), + makeLabelWithSlider("Window font size", rawName: "fontHeight", minValue: 12, maxValue: 36, numberOfTickMarks: 16, unitText: "px"), makeHorizontalSeparator(), - makeLabelWithInput(\PreferencesPanel.windowDisplayDelay, "Window apparition delay", rawName: "windowDisplayDelay", suffixText: "ms"), - makeLabelWithDropdown(\PreferencesPanel.showOnScreen, "Show on", rawName: "showOnScreen", values: Preferences.showOnScreenMacro.labels), - makeHorizontalSeparator(), - makeRestartHint() + makeLabelWithInput("Window apparition delay", rawName: "windowDisplayDelay", width: 41, suffixText: "ms", validator: windowDisplayDelayValidator), + makeLabelWithDropdown("Show on", rawName: "showOnScreen", values: Preferences.showOnScreenMacro.labels) ] } @@ -77,135 +101,163 @@ class PreferencesPanel: NSPanel { return view } - @objc private func restartButtonAction() { - self.makeFirstResponder(invisibleTextField) - - if let delegate = Application.shared.delegate as? Application { - delegate.relaunch() + private func makeLabelWithInput(_ labelText: String, rawName: String, width: CGFloat? = nil, suffixText: String? = nil, validator: ((String)->Bool)? = nil) -> NSStackView { + let input = PreferencesPanelNSTextFieldEditable(string: Preferences.rawValues[rawName]!) + input.validationHandler = validator + input.delegate = input + input.visualizeValidationState(input.isValid()) + if width != nil { + input.widthAnchor.constraint(equalToConstant: width!).isActive = true } + + return makeLabelWithProvidedControl(labelText, rawName: rawName, control: input, suffixText: suffixText) } - private func makeRestartHint() -> NSStackView { - let field = NSTextField(wrappingLabelWithString: "Some settings require restarting the app to apply: ") - field.textColor = .systemRed - field.alignment = .right - field.widthAnchor.constraint(equalToConstant: calcLabelWidth()).isActive = true - - let button = NSButton() - button.title = "↻ Restart" - button.bezelStyle = .rounded - button.target = self - button.action = #selector(restartButtonAction) - - let container = NSStackView(views: [field, button]) - container.alignment = .bottom - return container - } - - private func makeLabelWithInput(_ keyPath: ReferenceWritableKeyPath, _ labelText: String, rawName: String, suffixText: String? = nil) -> NSStackView { - let input = NSTextField(string: Preferences.rawValues[rawName]!) - input.widthAnchor.constraint(equalToConstant: 32).isActive = true - - self[keyPath: keyPath] = input - - return makeLabelWithProvidedControl(labelText, rawName, input, suffixText) - } - - private func makeLabelWithDropdown(_ keyPath: ReferenceWritableKeyPath, _ labelText: String, rawName: String, values: [String], suffixText: String? = nil) -> NSStackView { + private func makeLabelWithDropdown(_ labelText: String, rawName: String, values: [String], suffixText: String? = nil) -> NSStackView { let popUp = NSPopUpButton() popUp.addItems(withTitles: values) popUp.selectItem(withTitle: Preferences.rawValues[rawName]!) - self[keyPath: keyPath] = popUp - - return makeLabelWithProvidedControl(labelText, rawName, popUp, suffixText) + return makeLabelWithProvidedControl(labelText, rawName: rawName, control: popUp, suffixText: suffixText) } - private func makeLabelWithSlider(_ keyPath: ReferenceWritableKeyPath, _ labelText: String, rawName: String, minValue: Double, maxValue: Double, numberOfTickMarks: Int, suffixText: String? = nil) -> NSStackView { - let slider = NSSlider( - value: Double(Preferences.rawValues[rawName]!)!, - minValue: minValue, - maxValue: maxValue, - target: self, - action: #selector(controlDidEndEditing) - ) + private func makeLabelWithSlider(_ labelText: String, rawName: String, minValue: Double, maxValue: Double, numberOfTickMarks: Int, unitText: String) -> NSStackView { + let value = Preferences.rawValues[rawName]! + let suffixText = value + unitText + let slider = NSSlider() + slider.minValue = minValue + slider.maxValue = maxValue + slider.stringValue = value slider.numberOfTickMarks = numberOfTickMarks slider.allowsTickMarkValuesOnly = numberOfTickMarks > 1 slider.tickMarkPosition = .below + slider.isContinuous = true - self[keyPath: keyPath] = slider - - return makeLabelWithProvidedControl(labelText, rawName, slider, suffixText) + return makeLabelWithProvidedControl(labelText, rawName: rawName, control: slider, suffixText: suffixText, suffixWidth: 40) } - private func makeLabelWithProvidedControl(_ labelText: String, _ rawName: String, _ control: NSControl, _ suffixText: String? = nil) -> NSStackView { + private func makeLabelWithProvidedControl(_ labelText: String, rawName: String, control: NSControl, suffixText: String? = nil, suffixWidth: CGFloat? = nil) -> NSStackView { let label = NSTextField(wrappingLabelWithString: labelText + ": ") label.alignment = .right - label.widthAnchor.constraint(equalToConstant: calcLabelWidth()).isActive = true + label.widthAnchor.constraint(equalToConstant: labelWidth).isActive = true + label.identifier = NSUserInterfaceItemIdentifier(rawName + ControlIdentifierDiscriminator.LABEL.rawValue) + label.isSelectable = false control.identifier = NSUserInterfaceItemIdentifier(rawName) control.target = self - control.action = #selector(controlDidEndEditing) + control.action = #selector(controlWasChanged) let containerView = NSStackView(views: [label, control]) if suffixText != nil { let suffix = NSTextField(labelWithString: suffixText!) suffix.textColor = .gray + suffix.identifier = NSUserInterfaceItemIdentifier(rawName + ControlIdentifierDiscriminator.SUFFIX.rawValue) + if suffixWidth != nil { + suffix.widthAnchor.constraint(equalToConstant: suffixWidth!).isActive = true + } containerView.addView(suffix, in: .leading) } return containerView } - /* - usage notes: - - NSSlider: supports on purpose currently only decimal values - */ - @objc func controlDidEndEditing(senderControl: NSControl) { - self.makeFirstResponder(invisibleTextField) // deselects any possibly selected NSTextField (so slider & popUp changes deselect them) - - let key: String? = senderControl.identifier?.rawValue - var newValue: String? - - if senderControl is NSPopUpButton { - newValue = (senderControl as! NSPopUpButton).titleOfSelectedItem! - } else if senderControl is NSSlider { - newValue = String(format: "%.0f", Double(senderControl.stringValue)!) // we are only interested in decimals of the provided double - } else { - newValue = senderControl.stringValue + private func updateSuffixWithValue(_ control: NSControl, _ value: String) { + let suffixIdentifierPredicate = {(view: NSView) -> Bool in + view.identifier?.rawValue == control.identifier!.rawValue + ControlIdentifierDiscriminator.SUFFIX.rawValue } -// debugPrint("PreferencesPanel: save: change", key!, newValue!) + 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) + suffixView.stringValue = regex.stringByReplacingMatches(in: suffixView.stringValue, range: range, withTemplate: value) + } + } - if key != nil && newValue != nil { - if newValue == Preferences.rawValues[key!] { - debugPrint("PreferencesPanel: save: abort: value was not changed") - return + @objc + private func controlWasChanged(senderControl: NSControl) { + let key: String = senderControl.identifier!.rawValue + let previousValue: String = Preferences.rawValues[key]! + let newValue: String = getControlValue(senderControl) + let invalidTextField = senderControl is PreferencesPanelNSTextFieldEditable && !(senderControl as! PreferencesPanelNSTextFieldEditable).isValid() + + if (invalidTextField && !windowCloseRequested) || (newValue == previousValue && !invalidTextField) { + return + } + + 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) + (NSApp as! Application).initPreferencesDependentComponents() + try Preferences.saveRawToDisk() + } catch let error { + debugPrint("PreferencesPanel: save: error", key, newValue, error) + showSaveErrorSheetModal(error as NSError, senderControl, key, previousValue) // allows recursive call by user choice + } + } - let previousValue = Preferences.rawValues[key!]! + private func showSaveErrorSheetModal(_ nsError: NSError, _ control: NSControl, _ key: String, _ previousValue: String) { + let alert = NSAlert() + alert.messageText = "Could not save Preference" + alert.informativeText = nsError.localizedDescription + "\n" + alert.addButton(withTitle: "Edit") + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Check again") - do { - try Preferences.updateAndValidateFromString(key!, newValue!) - try Preferences.saveRawToDisk() - } catch { - debugPrint("PreferencesPanel: save: error", key!, error, "previousValue", previousValue, " | newValue", newValue!) - - // restores the previous value in Preferences and senderControl - try! Preferences.updateAndValidateFromString(key!, previousValue) - - if senderControl is NSPopUpButton { - (senderControl as! NSPopUpButton).selectItem(withTitle: previousValue) - } else { - senderControl.stringValue = previousValue + alert.beginSheetModal(for: self, completionHandler: {(modalResponse: NSApplication.ModalResponse) -> Void in + if modalResponse == NSApplication.ModalResponse.alertFirstButtonReturn { + debugPrint("PreferencesPanel: save: error: user choice: edit") + self.windowCloseRequested = false + } + if modalResponse == NSApplication.ModalResponse.alertSecondButtonReturn { + debugPrint("PreferencesPanel: save: error: user choice: cancel -> revert value and eventually close window") + try! Preferences.updateAndValidateFromString(key, previousValue) + self.setControlValue(control, previousValue) + self.updateControlExtras(control, previousValue) + if self.windowCloseRequested { + self.close() } } + if modalResponse == NSApplication.ModalResponse.alertThirdButtonReturn { + debugPrint("PreferencesPanel: save: error: user choice: check again") + self.controlWasChanged(senderControl: control) + } + }) + } + + private func getControlValue(_ control: NSControl) -> String { + if control is NSPopUpButton { + return (control as! NSPopUpButton).titleOfSelectedItem! + } else if control is NSSlider { + return String(format: "%.0f", control.doubleValue) // we are only interested in decimals of the provided double } else { - debugPrint("PreferencesPanel: save: error: key||newValue = nil", key!, newValue!) + return control.stringValue } } - private func calcLabelWidth() -> CGFloat { - return (panelWidth - panelPadding) * panelWidthToLabelRatio + private func setControlValue(_ control: NSControl, _ value: String) { + if control is NSPopUpButton { + (control as! NSPopUpButton).selectItem(withTitle: value) + } else if control is NSTextField{ + control.stringValue = value + (control as! NSTextField).delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: control)) + } else { + control.stringValue = value + } + } + + private func updateControlExtras(_ control: NSControl, _ value: String) { + if control is NSSlider { + updateSuffixWithValue(control as! NSSlider, value) + } } } + +enum ControlIdentifierDiscriminator: String { + case LABEL = "_label" + case SUFFIX = "_suffix" +}