From 21a4587e775202ff468e180a4c64c9b71e199b6c Mon Sep 17 00:00:00 2001 From: Mariusz Bieniek Date: Sun, 10 Nov 2019 20:09:21 +0100 Subject: [PATCH] feat: improves PreferencesPanel UX, partially implements #49 implements requested changes from Code-Review and contains also refactoring, additions & improvements - PreferencesPanel: does not handle NSApp.activate & .deactivate anymore - PreferencesPanel: maxThumbnailsPerRow & windowDisplayDelay are now sliders - PreferencesPanel: Sliders do not use tickmarks anymore (to allow finer settings) - PreferencesPanel: tweaked Sliders min/max values - PreferencesPanel: adds Hyperlink to KeyCodes Reference as suffix for Tab key control (with additional required sub-class of NSTextField) - PreferencesPanel: adds makeSuffix() method for code clarity --- alt-tab-macos.xcodeproj/project.pbxproj | 12 ++-- .../PreferencesPanelNSTextFieldEditable.swift | 34 ---------- alt-tab-macos/ui/HyperlinkLabel.swift | 21 +++++++ alt-tab-macos/ui/PreferencesPanel.swift | 63 ++++++++++--------- alt-tab-macos/ui/TextField.swift | 35 +++++++++++ 5 files changed, 99 insertions(+), 66 deletions(-) delete mode 100644 alt-tab-macos/logic/PreferencesPanelNSTextFieldEditable.swift create mode 100644 alt-tab-macos/ui/HyperlinkLabel.swift create mode 100644 alt-tab-macos/ui/TextField.swift diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 8559dd883..f1325ae1b 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -21,7 +21,8 @@ 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 */; }; + F029861A378EC1417106FEC3 /* TextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0298E42A818112B290FF6C7 /* TextField.swift */; }; + F0298AB28A3CE5DBEC385730 /* HyperlinkLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0298708E2B13DBD4738AE76 /* HyperlinkLabel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -73,7 +74,8 @@ 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 = ""; }; + F0298708E2B13DBD4738AE76 /* HyperlinkLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HyperlinkLabel.swift; sourceTree = ""; }; + F0298E42A818112B290FF6C7 /* TextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextField.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -174,7 +176,6 @@ D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */, D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */, D04BA86768C6503A11ED81FC /* Extensions.swift */, - F0298F4D7927608A986C320D /* PreferencesPanelNSTextFieldEditable.swift */, ); path = logic; sourceTree = ""; @@ -189,6 +190,8 @@ D04BAE5BBE182DD5DDFE2E3E /* ThumbnailsPanel.swift */, D04BA0AF7C5DCF367FBB663C /* StatusItem.swift */, D04BAD32E130E4A061DC8332 /* Labels.swift */, + F0298E42A818112B290FF6C7 /* TextField.swift */, + F0298708E2B13DBD4738AE76 /* HyperlinkLabel.swift */, ); path = ui; sourceTree = ""; @@ -305,7 +308,8 @@ D04BA02DD4152997C32CF50B /* StatusItem.swift in Sources */, D04BA0F3D46BC79544E2B930 /* Extensions.swift in Sources */, D04BAD4DE538FDF7E7532EE2 /* Labels.swift in Sources */, - F02981D5D1E1F62074801CAE /* PreferencesPanelNSTextFieldEditable.swift in Sources */, + F029861A378EC1417106FEC3 /* TextField.swift in Sources */, + F0298AB28A3CE5DBEC385730 /* HyperlinkLabel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/alt-tab-macos/logic/PreferencesPanelNSTextFieldEditable.swift b/alt-tab-macos/logic/PreferencesPanelNSTextFieldEditable.swift deleted file mode 100644 index 0e1adb326..000000000 --- a/alt-tab-macos/logic/PreferencesPanelNSTextFieldEditable.swift +++ /dev/null @@ -1,34 +0,0 @@ -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/HyperlinkLabel.swift b/alt-tab-macos/ui/HyperlinkLabel.swift new file mode 100644 index 000000000..f6b0b4ca9 --- /dev/null +++ b/alt-tab-macos/ui/HyperlinkLabel.swift @@ -0,0 +1,21 @@ +import Cocoa + +class HyperlinkLabel: NSTextField { + + public convenience init(labelWithUrl stringValue: String, nsUrl: NSURL) { + self.init(labelWithString: stringValue) + isSelectable = true + allowsEditingTextAttributes = true + let linkTextAttributes: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key.link: nsUrl as Any, + NSAttributedString.Key.font: NSFont.labelFont(ofSize: NSFont.systemFontSize), + ] + + attributedStringValue = NSAttributedString(string: stringValue, attributes: linkTextAttributes) + } + + // the whole point for this sub-class: always display a pointing-hand cursor (not only when the TextField is focused) + override func resetCursorRects() { + addCursorRect(bounds, cursor: NSCursor.pointingHand) + } +} \ No newline at end of file diff --git a/alt-tab-macos/ui/PreferencesPanel.swift b/alt-tab-macos/ui/PreferencesPanel.swift index 375bf03f7..c241e3745 100644 --- a/alt-tab-macos/ui/PreferencesPanel.swift +++ b/alt-tab-macos/ui/PreferencesPanel.swift @@ -5,9 +5,9 @@ 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) - var labelWidth: CGFloat { get { // derived convenience variable + var labelWidth: CGFloat { return (panelWidth - panelPadding) * CGFloat(0.45) - }} + } var windowCloseRequested = false override init(contentRect: NSRect, styleMask style: StyleMask, backing backingStoreType: BackingStoreType, defer flag: Bool) { @@ -16,11 +16,9 @@ class PreferencesPanel: NSPanel, NSWindowDelegate { title = Application.name + " Preferences" hidesOnDeactivate = false contentView = makeContentView() - NSApp.activate(ignoringOtherApps: true) } override func close() { - NSApp.deactivate() (NSApp as! Application).preferencesPanel = nil super.close() } @@ -28,12 +26,12 @@ class PreferencesPanel: NSPanel, NSWindowDelegate { public func windowShouldClose(_ sender: NSWindow) -> Bool { windowCloseRequested = true challengeNextInvalidEditableTextField() - return attachedSheet == nil // user is challenged + return attachedSheet == nil // depends if user is challenged with a sheet } private func challengeNextInvalidEditableTextField() { let invalidFields = (contentView? - .findNestedViews(subclassOf: PreferencesPanelNSTextFieldEditable.self) + .findNestedViews(subclassOf: TextField.self) .filter({ !$0.isValid() }) ) let focusedField = invalidFields?.filter({ $0.currentEditor() != nil }).first @@ -76,20 +74,18 @@ class PreferencesPanel: NSPanel, NSWindowDelegate { 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("Alt key", rawName: "metaKey", values: Preferences.metaKeyMacro.labels), - makeLabelWithInput("Tab key", rawName: "tabKeyCode", width: 33, suffixText: "KeyCode (48 = Tab)", validator: tabKeyCodeValidator), + makeLabelWithInput("Tab key", rawName: "tabKeyCode", width: 33, suffixText: "KeyCodes Reference", suffixUrl: "https://eastmanreference.com/complete-list-of-applescript-key-codes", validator: tabKeyCodeValidator), makeHorizontalSeparator(), 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"), + makeLabelWithSlider("Max screen usage", rawName: "maxScreenUsage", minValue: 10, maxValue: 100, numberOfTickMarks: 0, unitText: "%"), + makeLabelWithSlider("Max thumbnails per row", rawName: "maxThumbnailsPerRow", minValue: 3, maxValue: 16, numberOfTickMarks: 0), + makeLabelWithSlider("Apps icon size", rawName: "iconSize", minValue: 12, maxValue: 64, numberOfTickMarks: 0, unitText: "px"), + makeLabelWithSlider("Window font size", rawName: "fontHeight", minValue: 12, maxValue: 64, numberOfTickMarks: 0, unitText: "px"), makeHorizontalSeparator(), - makeLabelWithInput("Window apparition delay", rawName: "windowDisplayDelay", width: 41, suffixText: "ms", validator: windowDisplayDelayValidator), + makeLabelWithSlider("Window apparition delay", rawName: "windowDisplayDelay", minValue: 0, maxValue: 2000, numberOfTickMarks: 0, unitText: "ms"), makeLabelWithDropdown("Show on", rawName: "showOnScreen", values: Preferences.showOnScreenMacro.labels) ] } @@ -101,16 +97,16 @@ class PreferencesPanel: NSPanel, NSWindowDelegate { return view } - 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]!) + private func makeLabelWithInput(_ labelText: String, rawName: String, width: CGFloat? = nil, suffixText: String? = nil, suffixUrl: String? = nil, validator: ((String)->Bool)? = nil) -> NSStackView { + let input = TextField(Preferences.rawValues[rawName]!) input.validationHandler = validator input.delegate = input - input.visualizeValidationState(input.isValid()) + input.visualizeValidationState() if width != nil { input.widthAnchor.constraint(equalToConstant: width!).isActive = true } - return makeLabelWithProvidedControl(labelText, rawName: rawName, control: input, suffixText: suffixText) + return makeLabelWithProvidedControl(labelText, rawName: rawName, control: input, suffixText: suffixText, suffixUrl: suffixUrl) } private func makeLabelWithDropdown(_ labelText: String, rawName: String, values: [String], suffixText: String? = nil) -> NSStackView { @@ -121,7 +117,7 @@ class PreferencesPanel: NSPanel, NSWindowDelegate { return makeLabelWithProvidedControl(labelText, rawName: rawName, control: popUp, suffixText: suffixText) } - private func makeLabelWithSlider(_ labelText: String, rawName: String, minValue: Double, maxValue: Double, numberOfTickMarks: Int, unitText: String) -> NSStackView { + 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() @@ -133,10 +129,10 @@ class PreferencesPanel: NSPanel, NSWindowDelegate { slider.tickMarkPosition = .below slider.isContinuous = true - return makeLabelWithProvidedControl(labelText, rawName: rawName, control: slider, suffixText: suffixText, suffixWidth: 40) + return makeLabelWithProvidedControl(labelText, rawName: rawName, control: slider, suffixText: suffixText, suffixWidth: 60) } - private func makeLabelWithProvidedControl(_ labelText: String, rawName: String, control: NSControl, suffixText: String? = nil, suffixWidth: CGFloat? = nil) -> NSStackView { + private func makeLabelWithProvidedControl(_ labelText: String, rawName: String, control: NSControl, suffixText: String? = nil, suffixWidth: CGFloat? = nil, suffixUrl: String? = nil) -> NSStackView { let label = NSTextField(wrappingLabelWithString: labelText + ": ") label.alignment = .right label.widthAnchor.constraint(equalToConstant: labelWidth).isActive = true @@ -149,18 +145,29 @@ class PreferencesPanel: NSPanel, NSWindowDelegate { 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 - } + let suffix = makeSuffix(controlName: rawName, text: suffixText!, width: suffixWidth, url: suffixUrl) containerView.addView(suffix, in: .leading) } return containerView } + private func makeSuffix(controlName: String, text: String, width: CGFloat? = nil, url: String? = nil) -> NSTextField { + let suffix: NSTextField + if url == nil { + suffix = NSTextField(labelWithString: text) + } else { + suffix = HyperlinkLabel(labelWithUrl: text, nsUrl: NSURL(string: url!)!) + } + suffix.textColor = .gray + suffix.identifier = NSUserInterfaceItemIdentifier(controlName + ControlIdentifierDiscriminator.SUFFIX.rawValue) + if width != nil { + suffix.widthAnchor.constraint(equalToConstant: width!).isActive = true + } + + return suffix + } + private func updateSuffixWithValue(_ control: NSControl, _ value: String) { let suffixIdentifierPredicate = {(view: NSView) -> Bool in view.identifier?.rawValue == control.identifier!.rawValue + ControlIdentifierDiscriminator.SUFFIX.rawValue @@ -178,7 +185,7 @@ class PreferencesPanel: NSPanel, NSWindowDelegate { 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() + let invalidTextField = senderControl is TextField && !(senderControl as! TextField).isValid() if (invalidTextField && !windowCloseRequested) || (newValue == previousValue && !invalidTextField) { return diff --git a/alt-tab-macos/ui/TextField.swift b/alt-tab-macos/ui/TextField.swift new file mode 100644 index 000000000..9e34f60fb --- /dev/null +++ b/alt-tab-macos/ui/TextField.swift @@ -0,0 +1,35 @@ +import Cocoa + +class TextField: NSTextField, NSTextFieldDelegate { + + var validationHandler: ((String)->Bool)? + + public convenience init(_ value: String) { + self.init(string: value) + wantsLayer = true + layer?.borderWidth = 1 + } + + func controlTextDidChange(_ obj: Notification) { + visualizeValidationState() + let textField = obj.object as! TextField + sendAction(textField.action, to: textField.target) + } + + func visualizeValidationState() -> Void { + if !isValid() { + layer?.borderColor = NSColor.systemRed.cgColor + } else { + layer?.borderColor = .clear + } + } + + func isValid() -> Bool { + if let handler = validationHandler { + return handler(stringValue) + } + + return true + } + +} \ No newline at end of file