Moved 'isSecure' out into two different classes

In later versions of Xcode changing the NSTextFieldCell to an NSSecureTextFieldCell generates an error (no longer secure).

Rather than maintain two almost identical field types, GYB is being used to templatize the primary source file 'DSFFloatLabelledTextField.swift.gyb' into two different classes.

As a result, changes _must_ be made to the template file rather than the sources.
This commit is contained in:
Darren Ford 2023-03-15 12:16:35 +11:00
parent 0e89b0c162
commit ad85f71bd2
7 changed files with 1740 additions and 6 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ DSFFloatLabelledTextControls.xcodeproj/xcuserdata
xcschememanagement.plist
.swiftpm
Demos/DSFFloatLabelledTextControls.xcodeproj/xcuserdata
gyb.cpython-310.pyc

View File

@ -1,5 +1,7 @@
// ** Automatically generated from DSFFloatLabelledTextField.swift.gyb **
//
// DSFFloatLabelledSecureTextField.swift
// (generated using tools/regenerate-sources.sh)
//
// Copyright © 2023 Darren Ford. All rights reserved.
//
@ -167,11 +169,6 @@ import AppKit
self.setup()
}
open override func awakeFromNib() {
super.awakeFromNib()
}
open override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()

View File

@ -1,5 +1,7 @@
// ** Automatically generated from DSFFloatLabelledTextField.swift.gyb **
//
// DSFFloatLabelledTextField.swift
// (generated using tools/regenerate-sources.sh)
//
// Copyright © 2023 Darren Ford. All rights reserved.
//
@ -141,7 +143,6 @@ import AppKit
customCell.alignment = self.alignment
customCell.formatter = self.formatter
customCell.topOffset = self.placeholderHeight
self.cell = customCell
}

View File

@ -0,0 +1,448 @@
// ** Automatically generated from DSFFloatLabelledTextField.swift.gyb **
//
// DSFFloatLabelled${field_type}Field.swift
// (generated using tools/regenerate-sources.sh)
//
// Copyright © 2023 Darren Ford. All rights reserved.
//
// MIT license
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//
import AppKit
/// DSFFloatLabelledTextField delegate protocol
@objc public protocol DSFFloatLabelled${field_type}FieldDelegate: NSObjectProtocol {
/// Called when the label is shown or hidden
@objc optional func floatLabelledTextField(_ field: DSFFloatLabelled${field_type}Field, didShowFloatingLabel didShow: Bool)
/// Called when the field becomes or loses first responder status
@objc optional func floatLabelledTextField(_ field: DSFFloatLabelled${field_type}Field, didFocus: Bool)
/// Called when the content of the field changes
@objc optional func floatLabelledTextFieldContentChanged(_ field: DSFFloatLabelled${field_type}Field)
}
/// An NS${field_type}Field that implements the Float Label Pattern
@IBDesignable open class DSFFloatLabelled${field_type}Field: NS${field_type}Field {
/// Optional delegate to provide callbacks for the floating label state.
///
/// This delegate can be set via Interface Builder or programatically
@IBOutlet @objc public weak var floatLabelDelegate: DSFFloatLabelled${field_type}FieldDelegate?
/// The size (in pt) of the floating label text
@IBInspectable public var placeholderTextSize: CGFloat = NSFont.smallSystemFontSize {
didSet {
self.floatingLabel.font = NSFont.systemFont(ofSize: self.placeholderTextSize)
self.reconfigureControl()
}
}
/// Spacing between the floating label and the text field text
@IBInspectable public var placeholderSpacing: CGFloat = 0.0 {
didSet {
self.floatingLabel.font = NSFont.systemFont(ofSize: self.placeholderTextSize)
self.reconfigureControl()
}
}
// Override so that we can notify when the developer changes the text programatically too
open override var stringValue: String {
get {
return super.stringValue
}
set {
super.stringValue = newValue
NotificationCenter.default.post(name: NSControl.textDidChangeNotification, object: self)
}
}
// Override so that we can update when the developer changes the placeholder string
open override var placeholderString: String? {
get {
return super.placeholderString
}
set {
super.placeholderString = newValue
self.floatingLabel.stringValue = newValue ?? ""
}
}
/// Floating label
private let floatingLabel = NSTextField()
/// Is the label currently showing
private var isShowing: Bool = false {
didSet {
self.floatLabelDelegate?.floatLabelledTextField?(self, didShowFloatingLabel: self.isShowing)
}
}
/// Constraint to tie the label to the top of the control
private var floatingTop: NSLayoutConstraint?
/// Height of the control
private var heightConstraint: NSLayoutConstraint?
/// Observers for the font and placeholder text
private var fontObserver: NSKeyValueObservation?
private var placeholderObserver: NSKeyValueObservation?
/// Returns the height of the placeholder text
var placeholderHeight: CGFloat {
let layoutManager = NSLayoutManager()
return layoutManager.defaultLineHeight(for: self.floatingLabel.font!) + 1
}
/// Returns the height of the primary (editable) text
private var textHeight: CGFloat {
let layoutManager = NSLayoutManager()
return layoutManager.defaultLineHeight(for: self.font!) + 1
}
/// Returns the total height of the control given the font settings
private var controlHeight: CGFloat {
return self.textHeight + self.placeholderSpacing + self.placeholderHeight
}
open override var intrinsicContentSize: NSSize {
var sz = super.intrinsicContentSize
sz.height = self.controlHeight
return sz
}
func configureCell() {
let customCell = DSFFloatLabelled${field_type}FieldCell()
customCell.isEditable = true
customCell.wraps = false
customCell.usesSingleLineMode = true
customCell.placeholderString = self.placeholderString
customCell.title = self.stringValue
customCell.font = self.font
customCell.isBordered = self.isBordered
customCell.isBezeled = self.isBezeled
customCell.bezelStyle = self.bezelStyle
customCell.isScrollable = true
customCell.isContinuous = self.isContinuous
customCell.alignment = self.alignment
customCell.formatter = self.formatter
customCell.topOffset = self.placeholderHeight
self.cell = customCell
}
func setTopOffset(_ value: CGFloat) {
guard var f = self.cell as? DSFFloatLabelledTextFieldCellProtocol else {
return
}
f.topOffset = value
}
/// Set the fonts to be used in the control
open func setFonts(primary: NSFont, secondary: NSFont) {
self.floatingLabel.font = secondary
self.font = primary
}
public override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
self.setup()
}
required public init?(coder: NSCoder) {
super.init(coder: coder)
self.setup()
}
open override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
// When we're added to the view, make sure that the floating label alignment matches us
// (as the user might have changed the alignment BEFORE adding to the superview
self.floatingLabel.alignment = self.alignment
}
private func setup() {
// Setup the common elements of the control
self.commonSetup()
// Configure a default text cell
self.configureCell()
// Listen to changes in the primary font so we can reconfigure to match
self.fontObserver = self.observe(\.font, options: [.new]) { [weak self] _, _ in
self?.reconfigureControl()
}
// Listen to changes in the placeholder text so we can reflect it in the floater
self.placeholderObserver = self.observe(\.placeholderString, options: [.new]) { [weak self] _, _ in
guard let `self` = self else { return }
self.floatingLabel.stringValue = self.placeholderString!
self.reconfigureControl()
}
}
/// Build the floating label
private func createFloatingLabel() {
if self.floatingLabel.superview == nil {
self.addSubview(self.floatingLabel)
}
self.floatingLabel.wantsLayer = true
self.floatingLabel.isEditable = false
self.floatingLabel.isSelectable = false
self.floatingLabel.isEnabled = true
self.floatingLabel.isBezeled = false
self.floatingLabel.isBordered = false
self.floatingLabel.translatesAutoresizingMaskIntoConstraints = false
self.floatingLabel.font = NSFont.systemFont(ofSize: self.placeholderTextSize)
self.floatingLabel.textColor = NSColor.placeholderTextColor
self.floatingLabel.stringValue = self.placeholderString ?? ""
self.floatingLabel.alphaValue = 0.0
self.floatingLabel.alignment = self.alignment
self.floatingLabel.drawsBackground = false
self.floatingTop = NSLayoutConstraint(
item: self.floatingLabel, attribute: .top,
relatedBy: .equal,
toItem: self, attribute: .top,
multiplier: 1.0, constant: 10
)
self.addConstraint(self.floatingTop!)
var x = NSLayoutConstraint(
item: self.floatingLabel, attribute: .leading,
relatedBy: .equal,
toItem: self, attribute: .leading,
multiplier: 1.0, constant: self.isBezeled ? 4 : 0
)
self.addConstraint(x)
x = NSLayoutConstraint(
item: self.floatingLabel, attribute: .trailing,
relatedBy: .equal,
toItem: self, attribute: .trailing,
multiplier: 1.0, constant: self.isBezeled ? -4 : 0
)
self.addConstraint(x)
self.floatingLabel.setContentHuggingPriority(NSLayoutConstraint.Priority(10), for: .horizontal)
self.floatingLabel.setContentCompressionResistancePriority(NSLayoutConstraint.Priority(10), for: .horizontal)
}
private func commonSetup() {
self.wantsLayer = true
self.translatesAutoresizingMaskIntoConstraints = false
self.usesSingleLineMode = true
self.delegate = self
// Default to natural layout
self.alignment = .natural
self.createFloatingLabel()
self.heightConstraint = NSLayoutConstraint(
item: self, attribute: .height,
relatedBy: .equal,
toItem: nil, attribute: .notAnAttribute,
multiplier: 1.0, constant: self.controlHeight
)
self.addConstraint(self.heightConstraint!)
// If the field already has text, make sure the placeholder is shown
if self.stringValue.count > 0 {
self.showPlaceholder(animated: false)
}
}
/// Change the layout if any changes occur
private func reconfigureControl() {
if self.isCurrentFocus() {
/// If we are currently editing, then finish before changing.
self.window?.endEditing(for: nil)
}
self.expandFrame()
}
/// Rebuild the frame of the text field to match the new settings
private func expandFrame() {
self.heightConstraint?.constant = self.controlHeight
self.setTopOffset(self.placeholderHeight + self.placeholderSpacing)
self.needsLayout = true
}
open override func layout() {
super.layout()
self.setTopOffset(self.placeholderHeight + self.placeholderSpacing)
self.needsLayout = true
}
}
// MARK: - Focus and editing
extension DSFFloatLabelled${field_type}Field: NSTextFieldDelegate {
// Change the floating label color to represent active state
private func setFloatingLabelActive(_ active: Bool) {
if active {
self.floatingLabel.textColor = NSColor.simulatedAccentColor
}
else {
self.floatingLabel.textColor = NSColor.placeholderTextColor
}
self.floatLabelDelegate?.floatLabelledTextField?(self, didFocus: active)
}
public func controlTextDidChange(_ obj: Notification) {
guard let field = obj.object as? NSTextField else {
return
}
if field.stringValue.count > 0, !self.isShowing {
self.showPlaceholder(animated: true)
}
else if field.stringValue.count == 0, self.isShowing {
self.hidePlaceholder()
}
self.floatLabelDelegate?.floatLabelledTextFieldContentChanged?(self)
}
open override func becomeFirstResponder() -> Bool {
let becomeResult = super.becomeFirstResponder()
if becomeResult, self.isCurrentFocus() {
// Set the color of the 'label' to match the current focus color.
// We need to perform this on the main thread after the current set of notifications are processed
// Why? We (occasionally) receive a 'controlTextDidEndEditing' message AFTER we receive a
// 'becomeFirstResponder'. I've read that this is related to the text field automatically selecting
// text when taking focus, but I haven't been able to verify this in any useful manner.
DispatchQueue.main.async { [weak self] in
if let `self` = self {
self.setFloatingLabelActive(true)
}
}
}
return becomeResult
}
open func controlTextDidEndEditing(_: Notification) {
// When we lose focus, set the label color back to the placeholder color
self.setFloatingLabelActive(false)
}
/// Does our text field currently have input focus?
private func isCurrentFocus() -> Bool {
// 1. Get the window's first responder
// 2. Check is has an active field editor
// 3. Is the delegate of the field editor us?
if let fr = self.window?.firstResponder as? NSTextView,
self.window?.fieldEditor(false, for: nil) != nil,
fr.delegate === self {
return true
}
return false
}
}
// MARK: - Animations
extension DSFFloatLabelled${field_type}Field {
/// Duration of the fade in/out of the secondary label
private var animationDuration: TimeInterval {
return shouldAnimate() ? 0.4 : 0.0
}
/// Returns true if the system is NOT set to reduce motion (accessibility settings)
private func shouldAnimate() -> Bool {
if #available(OSX 10.12, *) {
return !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
} else {
// Fallback on earlier versions. Just animate
return true
}
}
private func showPlaceholder(animated: Bool) {
self.isShowing = true
if animated {
NSAnimationContext.runAnimationGroup({ [weak self] context in
guard let `self` = self else { return }
context.allowsImplicitAnimation = true
context.duration = self.animationDuration
self.floatingTop?.constant = 0
self.floatingLabel.alphaValue = 1.0
self.layoutSubtreeIfNeeded()
}, completionHandler: {
//
})
}
else {
self.setFloatingLabelActive(false)
self.floatingTop?.constant = 0
self.floatingLabel.alphaValue = 1.0
}
}
private func hidePlaceholder() {
self.isShowing = false
let duration = self.animationDuration
NSAnimationContext.runAnimationGroup({ [weak self] context in
guard let `self` = self else { return }
context.allowsImplicitAnimation = true
context.duration = duration
self.floatingTop?.constant = self.textHeight / 1.5
self.floatingLabel.alphaValue = 0.0
self.layoutSubtreeIfNeeded()
}, completionHandler: {
//
})
}
}
// MARK: - Cell definition
private class DSFFloatLabelled${field_type}FieldCell: NS${field_type}FieldCell, DSFFloatLabelledTextFieldCellProtocol {
var topOffset: CGFloat = 0
private func offset() -> CGFloat {
return self.topOffset - (self.isBezeled ? 5 : 1)
}
override func titleRect(forBounds rect: NSRect) -> NSRect {
return NSRect(x: rect.origin.x, y: rect.origin.y + self.offset(), width: rect.width, height: rect.height)
}
override func edit(withFrame rect: NSRect, in controlView: NSView, editor textObj: NSText, delegate: Any?, event: NSEvent?) {
let insetRect = NSRect(x: rect.origin.x, y: rect.origin.y + self.offset(), width: rect.width, height: rect.height)
super.edit(withFrame: insetRect, in: controlView, editor: textObj, delegate: delegate, event: event)
}
override func select(withFrame rect: NSRect, in controlView: NSView, editor textObj: NSText, delegate: Any?, start selStart: Int, length selLength: Int) {
let insetRect = NSRect(x: rect.origin.x, y: rect.origin.y + self.offset(), width: rect.width, height: rect.height)
super.select(withFrame: insetRect, in: controlView, editor: textObj, delegate: delegate, start: selStart, length: selLength)
}
override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
let insetRect = NSRect(x: cellFrame.origin.x, y: cellFrame.origin.y + self.offset(), width: cellFrame.width, height: cellFrame.height)
super.drawInterior(withFrame: insetRect, in: controlView)
}
}

3
tools/gyb Normal file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env python3
import gyb
gyb.main()

1258
tools/gyb.py Normal file

File diff suppressed because it is too large Load Diff

26
tools/regenerate-sources.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/sh
# A script to re-build the text field sources from the source template file
# Stop on error
set -e
# The path where this script resides
script_path="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )"
# The path for the package (one level up)
package_path="$( cd -- "${script_path}/../" >/dev/null 2>&1 ; pwd -P )"
pushd .
# We have to run docbuild in the root of the source package
cd "${package_path}"
# Generate the NSTextField implementation
python3 ${script_path}/gyb --line-directive '' -D field_type="Text" -o "DSFFloatLabelledTextField.swift" "Sources/DSFFloatLabelledTextField/DSFFloatLabelledTextField.swift.gyb"
# Generate the NSSecureTextField implementation
python3 ${script_path}/gyb --line-directive '' -D field_type="SecureText" -o "DSFFloatLabelledSecureTextField.swift" "Sources/DSFFloatLabelledTextField/DSFFloatLabelledTextField.swift.gyb"
popd
echo "Done!"