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 - ...
This commit is contained in:
1 parent
59fc712086
commit
65327c2b89
6 files changed
+247
-140
No files matched your search
@@ -21,6 +21,7 @@
|
|||||||
D04BA9CCE02D30C8164A552A /* SystemPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */; };
|
D04BA9CCE02D30C8164A552A /* SystemPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */; };
|
||||||
D04BAD4DE538FDF7E7532EE2 /* Labels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD32E130E4A061DC8332 /* Labels.swift */; };
|
D04BAD4DE538FDF7E7532EE2 /* Labels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD32E130E4A061DC8332 /* Labels.swift */; };
|
||||||
D04BAEF78503D7A2CEFB9E9E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAA44C837F3A67403B9DB /* main.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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 = "<group>"; };
|
D04BAF076A30A1BAFEDBEA66 /* 5 windows - 2 lines.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "5 windows - 2 lines.jpg"; sourceTree = "<group>"; };
|
||||||
D04BAF249324297C07E31164 /* frontpage.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = frontpage.jpg; sourceTree = "<group>"; };
|
D04BAF249324297C07E31164 /* frontpage.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = frontpage.jpg; sourceTree = "<group>"; };
|
||||||
D04BAFA277EAE3BDDDB61110 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = "<group>"; };
|
D04BAFA277EAE3BDDDB61110 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = "<group>"; };
|
||||||
|
F0298F4D7927608A986C320D /* PreferencesPanelNSTextFieldEditable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesPanelNSTextFieldEditable.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -172,6 +174,7 @@
|
|||||||
D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */,
|
D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */,
|
||||||
D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */,
|
D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */,
|
||||||
D04BA86768C6503A11ED81FC /* Extensions.swift */,
|
D04BA86768C6503A11ED81FC /* Extensions.swift */,
|
||||||
|
F0298F4D7927608A986C320D /* PreferencesPanelNSTextFieldEditable.swift */,
|
||||||
);
|
);
|
||||||
path = logic;
|
path = logic;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -302,6 +305,7 @@
|
|||||||
D04BA02DD4152997C32CF50B /* StatusItem.swift in Sources */,
|
D04BA02DD4152997C32CF50B /* StatusItem.swift in Sources */,
|
||||||
D04BA0F3D46BC79544E2B930 /* Extensions.swift in Sources */,
|
D04BA0F3D46BC79544E2B930 /* Extensions.swift in Sources */,
|
||||||
D04BAD4DE538FDF7E7532EE2 /* Labels.swift in Sources */,
|
D04BAD4DE538FDF7E7532EE2 /* Labels.swift in Sources */,
|
||||||
|
F02981D5D1E1F62074801CAE /* PreferencesPanelNSTextFieldEditable.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,15 +19,11 @@ extension Optional {
|
|||||||
return value
|
return value
|
||||||
case .none:
|
case .none:
|
||||||
Thread.callStackSymbols.forEach { print($0) }
|
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
|
// add String constructor from CGFloat that round up at 1 decimal
|
||||||
extension String {
|
extension String {
|
||||||
init?(_ cgFloat: CGFloat) {
|
init?(_ cgFloat: CGFloat) {
|
||||||
@@ -39,3 +35,25 @@ extension String {
|
|||||||
self.init(string)
|
self.init(string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// add recursive lookup in subviews for specific type
|
||||||
|
extension NSView {
|
||||||
|
func findNestedViews<T: NSView>(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]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ class Preferences {
|
|||||||
let p = try showOnScreenMacro.labelToMacro[value].orThrow()
|
let p = try showOnScreenMacro.labelToMacro[value].orThrow()
|
||||||
showOnScreen = p.preferences
|
showOnScreen = p.preferences
|
||||||
default:
|
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
|
rawValues[valueName] = value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import Cocoa
|
|||||||
class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate {
|
class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate {
|
||||||
static let name = "AltTab"
|
static let name = "AltTab"
|
||||||
var statusItem: NSStatusItem?
|
var statusItem: NSStatusItem?
|
||||||
var backgroundView: NSVisualEffectView?
|
|
||||||
var thumbnailsPanel: ThumbnailsPanel?
|
var thumbnailsPanel: ThumbnailsPanel?
|
||||||
var preferencesPanel: PreferencesPanel?
|
var preferencesPanel: PreferencesPanel?
|
||||||
var selectedOpenWindow: Int = 0
|
var selectedOpenWindow: Int = 0
|
||||||
@@ -28,11 +27,15 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate {
|
|||||||
SystemPermissions.ensureAccessibilityCheckboxIsChecked()
|
SystemPermissions.ensureAccessibilityCheckboxIsChecked()
|
||||||
Preferences.loadFromDiskAndUpdateValues()
|
Preferences.loadFromDiskAndUpdateValues()
|
||||||
statusItem = StatusItem.make(self)
|
statusItem = StatusItem.make(self)
|
||||||
thumbnailsPanel = ThumbnailsPanel(self)
|
initPreferencesDependentComponents()
|
||||||
preferencesPanel = PreferencesPanel()
|
|
||||||
Keyboard.listenToGlobalEvents(self)
|
Keyboard.listenToGlobalEvents(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// we put application code here which should be executed on init() and Preferences change
|
||||||
|
func initPreferencesDependentComponents() {
|
||||||
|
thumbnailsPanel = ThumbnailsPanel(self)
|
||||||
|
}
|
||||||
|
|
||||||
func showUiOrSelectNext() {
|
func showUiOrSelectNext() {
|
||||||
debugPrint("showUiOrSelectNext")
|
debugPrint("showUiOrSelectNext")
|
||||||
showUiOrCycleSelection(1)
|
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)
|
Screen.showPanel(preferencesPanel!, Screen.preferredScreen(), .appleCentered)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,12 +132,4 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate {
|
|||||||
return openWindows.count > selectedOpenWindow ? openWindows[selectedOpenWindow] : nil
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,38 +1,48 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
|
import Foundation
|
||||||
|
|
||||||
class PreferencesPanel: NSPanel {
|
class PreferencesPanel: NSPanel, NSWindowDelegate {
|
||||||
// ui: base layout
|
let panelWidth = CGFloat(496)
|
||||||
let panelWidth = CGFloat(400)
|
let panelHeight = CGFloat(256) // auto expands to content height (but does not auto shrink)
|
||||||
let panelHeight = CGFloat(400) // gets auto adjusted to content height
|
|
||||||
let panelPadding = CGFloat(40)
|
let panelPadding = CGFloat(40)
|
||||||
let panelWidthToLabelRatio = CGFloat(0.5)
|
var labelWidth: CGFloat { get { // derived convenience variable
|
||||||
|
return (panelWidth - panelPadding) * CGFloat(0.45)
|
||||||
// ui: preferences elements
|
}}
|
||||||
var maxScreenUsage: NSSlider?
|
var windowCloseRequested = false
|
||||||
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
|
|
||||||
|
|
||||||
override init(contentRect: NSRect, styleMask style: StyleMask, backing backingStoreType: BackingStoreType, defer flag: Bool) {
|
override init(contentRect: NSRect, styleMask style: StyleMask, backing backingStoreType: BackingStoreType, defer flag: Bool) {
|
||||||
let initialRect = NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight)
|
let initialRect = NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight)
|
||||||
super.init(contentRect: initialRect, styleMask: style, backing: backingStoreType, defer: flag)
|
super.init(contentRect: initialRect, styleMask: style, backing: backingStoreType, defer: flag)
|
||||||
title = Application.name + " Preferences"
|
title = Application.name + " Preferences"
|
||||||
titlebarAppearsTransparent = true
|
|
||||||
hidesOnDeactivate = false
|
hidesOnDeactivate = false
|
||||||
contentView = makeContentView()
|
contentView = makeContentView()
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
|
||||||
// setup hidden element
|
override func close() {
|
||||||
invisibleTextField = NSTextField()
|
NSApp.deactivate()
|
||||||
invisibleTextField?.isHidden = true
|
(NSApp as! Application).preferencesPanel = nil
|
||||||
contentView?.subviews.append(invisibleTextField!)
|
super.close()
|
||||||
self.initialFirstResponder = invisibleTextField
|
}
|
||||||
|
|
||||||
|
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 {
|
private func makeContentView() -> NSView {
|
||||||
@@ -53,20 +63,34 @@ class PreferencesPanel: NSPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makePreferencesViews() -> [NSView] {
|
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 [
|
return [
|
||||||
makeLabelWithDropdown(\PreferencesPanel.metaKey, "Meta key to activate the app", rawName: "metaKey", values: Preferences.metaKeyMacro.labels),
|
makeLabelWithDropdown("Alt key", rawName: "metaKey", values: Preferences.metaKeyMacro.labels),
|
||||||
makeLabelWithInput(\PreferencesPanel.tabKeyCode, "Tab key", rawName: "tabKeyCode", suffixText: "KeyCode"),
|
makeLabelWithInput("Tab key", rawName: "tabKeyCode", width: 33, suffixText: "KeyCode (48 = Tab)", validator: tabKeyCodeValidator),
|
||||||
makeHorizontalSeparator(),
|
makeHorizontalSeparator(),
|
||||||
makeLabelWithDropdown(\PreferencesPanel.theme, "Main window theme", rawName: "theme", values: Preferences.themeMacro.labels),
|
makeLabelWithDropdown("Theme", rawName: "theme", values: Preferences.themeMacro.labels),
|
||||||
makeLabelWithSlider(\PreferencesPanel.maxScreenUsage, "Max window size", rawName: "maxScreenUsage", minValue: 10, maxValue: 100, numberOfTickMarks: 10),
|
makeLabelWithSlider("Max screen usage", rawName: "maxScreenUsage", minValue: 10, maxValue: 100, numberOfTickMarks: 10, unitText: "%"),
|
||||||
makeLabelWithInput(\PreferencesPanel.maxThumbnailsPerRow, "Max thumbnails per row", rawName: "maxThumbnailsPerRow"),
|
makeLabelWithInput("Max thumbnails per row", rawName: "maxThumbnailsPerRow", width: 25, validator: maxThumbnailsPerRowValidator),
|
||||||
makeLabelWithSlider(\PreferencesPanel.iconSize, "Apps icon size", rawName: "iconSize", minValue: 12, maxValue: 64, numberOfTickMarks: 16),
|
makeLabelWithSlider("Apps icon size", rawName: "iconSize", minValue: 12, maxValue: 62, numberOfTickMarks: 16, unitText: "px"),
|
||||||
makeLabelWithSlider(\PreferencesPanel.fontHeight, "Font size", rawName: "fontHeight", minValue: 12, maxValue: 36, numberOfTickMarks: 16),
|
makeLabelWithSlider("Window font size", rawName: "fontHeight", minValue: 12, maxValue: 36, numberOfTickMarks: 16, unitText: "px"),
|
||||||
makeHorizontalSeparator(),
|
makeHorizontalSeparator(),
|
||||||
makeLabelWithInput(\PreferencesPanel.windowDisplayDelay, "Window apparition delay", rawName: "windowDisplayDelay", suffixText: "ms"),
|
makeLabelWithInput("Window apparition delay", rawName: "windowDisplayDelay", width: 41, suffixText: "ms", validator: windowDisplayDelayValidator),
|
||||||
makeLabelWithDropdown(\PreferencesPanel.showOnScreen, "Show on", rawName: "showOnScreen", values: Preferences.showOnScreenMacro.labels),
|
makeLabelWithDropdown("Show on", rawName: "showOnScreen", values: Preferences.showOnScreenMacro.labels)
|
||||||
makeHorizontalSeparator(),
|
|
||||||
makeRestartHint()
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,135 +101,163 @@ class PreferencesPanel: NSPanel {
|
|||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func restartButtonAction() {
|
private func makeLabelWithInput(_ labelText: String, rawName: String, width: CGFloat? = nil, suffixText: String? = nil, validator: ((String)->Bool)? = nil) -> NSStackView {
|
||||||
self.makeFirstResponder(invisibleTextField)
|
let input = PreferencesPanelNSTextFieldEditable(string: Preferences.rawValues[rawName]!)
|
||||||
|
input.validationHandler = validator
|
||||||
if let delegate = Application.shared.delegate as? Application {
|
input.delegate = input
|
||||||
delegate.relaunch()
|
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 {
|
private func makeLabelWithDropdown(_ labelText: String, rawName: String, values: [String], suffixText: String? = nil) -> 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<PreferencesPanel, NSTextField?>, _ 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<PreferencesPanel, NSPopUpButton?>, _ labelText: String, rawName: String, values: [String], suffixText: String? = nil) -> NSStackView {
|
|
||||||
let popUp = NSPopUpButton()
|
let popUp = NSPopUpButton()
|
||||||
popUp.addItems(withTitles: values)
|
popUp.addItems(withTitles: values)
|
||||||
popUp.selectItem(withTitle: Preferences.rawValues[rawName]!)
|
popUp.selectItem(withTitle: Preferences.rawValues[rawName]!)
|
||||||
|
|
||||||
self[keyPath: keyPath] = popUp
|
return makeLabelWithProvidedControl(labelText, rawName: rawName, control: popUp, suffixText: suffixText)
|
||||||
|
|
||||||
return makeLabelWithProvidedControl(labelText, rawName, popUp, suffixText)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeLabelWithSlider(_ keyPath: ReferenceWritableKeyPath<PreferencesPanel, NSSlider?>, _ labelText: String, rawName: String, minValue: Double, maxValue: Double, numberOfTickMarks: Int, suffixText: String? = nil) -> NSStackView {
|
private func makeLabelWithSlider(_ labelText: String, rawName: String, minValue: Double, maxValue: Double, numberOfTickMarks: Int, unitText: String) -> NSStackView {
|
||||||
let slider = NSSlider(
|
let value = Preferences.rawValues[rawName]!
|
||||||
value: Double(Preferences.rawValues[rawName]!)!,
|
let suffixText = value + unitText
|
||||||
minValue: minValue,
|
let slider = NSSlider()
|
||||||
maxValue: maxValue,
|
slider.minValue = minValue
|
||||||
target: self,
|
slider.maxValue = maxValue
|
||||||
action: #selector(controlDidEndEditing)
|
slider.stringValue = value
|
||||||
)
|
|
||||||
slider.numberOfTickMarks = numberOfTickMarks
|
slider.numberOfTickMarks = numberOfTickMarks
|
||||||
slider.allowsTickMarkValuesOnly = numberOfTickMarks > 1
|
slider.allowsTickMarkValuesOnly = numberOfTickMarks > 1
|
||||||
slider.tickMarkPosition = .below
|
slider.tickMarkPosition = .below
|
||||||
|
slider.isContinuous = true
|
||||||
|
|
||||||
self[keyPath: keyPath] = slider
|
return makeLabelWithProvidedControl(labelText, rawName: rawName, control: slider, suffixText: suffixText, suffixWidth: 40)
|
||||||
|
|
||||||
return makeLabelWithProvidedControl(labelText, rawName, slider, suffixText)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 + ": ")
|
let label = NSTextField(wrappingLabelWithString: labelText + ": ")
|
||||||
label.alignment = .right
|
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.identifier = NSUserInterfaceItemIdentifier(rawName)
|
||||||
control.target = self
|
control.target = self
|
||||||
control.action = #selector(controlDidEndEditing)
|
control.action = #selector(controlWasChanged)
|
||||||
let containerView = NSStackView(views: [label, control])
|
let containerView = NSStackView(views: [label, control])
|
||||||
|
|
||||||
if suffixText != nil {
|
if suffixText != nil {
|
||||||
let suffix = NSTextField(labelWithString: suffixText!)
|
let suffix = NSTextField(labelWithString: suffixText!)
|
||||||
suffix.textColor = .gray
|
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)
|
containerView.addView(suffix, in: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
return containerView
|
return containerView
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
private func updateSuffixWithValue(_ control: NSControl, _ value: String) {
|
||||||
usage notes:
|
let suffixIdentifierPredicate = {(view: NSView) -> Bool in
|
||||||
- NSSlider: supports on purpose currently only decimal values
|
view.identifier?.rawValue == control.identifier!.rawValue + ControlIdentifierDiscriminator.SUFFIX.rawValue
|
||||||
*/
|
|
||||||
@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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
@objc
|
||||||
if newValue == Preferences.rawValues[key!] {
|
private func controlWasChanged(senderControl: NSControl) {
|
||||||
debugPrint("PreferencesPanel: save: abort: value was not changed")
|
let key: String = senderControl.identifier!.rawValue
|
||||||
return
|
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 {
|
alert.beginSheetModal(for: self, completionHandler: {(modalResponse: NSApplication.ModalResponse) -> Void in
|
||||||
try Preferences.updateAndValidateFromString(key!, newValue!)
|
if modalResponse == NSApplication.ModalResponse.alertFirstButtonReturn {
|
||||||
try Preferences.saveRawToDisk()
|
debugPrint("PreferencesPanel: save: error: user choice: edit")
|
||||||
} catch {
|
self.windowCloseRequested = false
|
||||||
debugPrint("PreferencesPanel: save: error", key!, error, "previousValue", previousValue, " | newValue", newValue!)
|
}
|
||||||
|
if modalResponse == NSApplication.ModalResponse.alertSecondButtonReturn {
|
||||||
// restores the previous value in Preferences and senderControl
|
debugPrint("PreferencesPanel: save: error: user choice: cancel -> revert value and eventually close window")
|
||||||
try! Preferences.updateAndValidateFromString(key!, previousValue)
|
try! Preferences.updateAndValidateFromString(key, previousValue)
|
||||||
|
self.setControlValue(control, previousValue)
|
||||||
if senderControl is NSPopUpButton {
|
self.updateControlExtras(control, previousValue)
|
||||||
(senderControl as! NSPopUpButton).selectItem(withTitle: previousValue)
|
if self.windowCloseRequested {
|
||||||
} else {
|
self.close()
|
||||||
senderControl.stringValue = previousValue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 {
|
} else {
|
||||||
debugPrint("PreferencesPanel: save: error: key||newValue = nil", key!, newValue!)
|
return control.stringValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calcLabelWidth() -> CGFloat {
|
private func setControlValue(_ control: NSControl, _ value: String) {
|
||||||
return (panelWidth - panelPadding) * panelWidthToLabelRatio
|
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"
|
||||||
|
}
|
||||||
Reference in new issue
Block a user