From 5e1140e67bf6ff73ec7377bc83fc8b27df6495af Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 30 Mar 2019 14:24:03 +0700 Subject: [PATCH] Upgrade dependencies --- Gifski/Constants.swift | 4 +- Gifski/CustomButton.swift | 94 +++++++- Gifski/MainWindowController.swift | 2 +- Gifski/Vendor/CircularProgress+Util.swift | 217 ++++++++++-------- Gifski/Vendor/Defaults.swift | 265 ++++++++++++++++++++-- Gifski/Vendor/DockProgress.swift | 154 ++++--------- 6 files changed, 504 insertions(+), 232 deletions(-) diff --git a/Gifski/Constants.swift b/Gifski/Constants.swift index 86ee2139..3afa4842 100644 --- a/Gifski/Constants.swift +++ b/Gifski/Constants.swift @@ -5,6 +5,6 @@ extension NSColor { } extension Defaults.Keys { - static let outputQuality = Defaults.Key("outputQuality", default: 1) - static let successfulConversionsCount = Defaults.Key("successfulConversionsCount", default: 0) + static let outputQuality = Key("outputQuality", default: 1) + static let successfulConversionsCount = Key("successfulConversionsCount", default: 0) } diff --git a/Gifski/CustomButton.swift b/Gifski/CustomButton.swift index 5112bb90..047fbfb3 100644 --- a/Gifski/CustomButton.swift +++ b/Gifski/CustomButton.swift @@ -2,6 +2,75 @@ import Cocoa // TODO(sindresorhus): I plan to extract this into a reusable package when it's more mature. +/** +Convenience class for adding a tracking area to a view. + +``` +final class HoverView: NSView { + private lazy var trackingArea = TrackingArea( + for: self, + options: [ + .mouseEnteredAndExited, + .activeInActiveApp + ] + ) + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingArea.update() + } +} +``` +*/ +final class TrackingArea { + private let view: NSView + private let rect: CGRect + private let options: NSTrackingArea.Options + private var trackingArea: NSTrackingArea? + + /** + - Parameters: + - view: The view to add tracking to. + - rect: The area inside the view to track. Defaults to the whole view (`view.bounds`). + */ + init(for view: NSView, rect: CGRect? = nil, options: NSTrackingArea.Options = []) { + self.view = view + self.rect = rect ?? view.bounds + self.options = options + } + + /** + Updates the tracking area. + This should be called in your `NSView#updateTrackingAreas()` method. + */ + func update() { + if let oldTrackingArea = trackingArea { + view.removeTrackingArea(oldTrackingArea) + } + + let newTrackingArea = NSTrackingArea( + rect: rect, + options: [ + .mouseEnteredAndExited, + .activeInActiveApp + ], + owner: view, + userInfo: nil + ) + + view.addTrackingArea(newTrackingArea) + trackingArea = newTrackingArea + } +} + +final class AnimationDelegate: NSObject, CAAnimationDelegate { + var didStopHandler: ((Bool) -> Void)? + + func animationDidStop(_ animation: CAAnimation, finished flag: Bool) { + didStopHandler?(flag) + } +} + extension CALayer { // TODO: Find a way to use a strongly-typed KeyPath here. // TODO: Accept NSColor instead of CGColor. @@ -16,8 +85,16 @@ extension CALayer { animation.duration = duration animation.fillMode = .forwards animation.isRemovedOnCompletion = false - add(animation, forKey: keyPath) - setValue(color, forKey: keyPath) + add(animation, forKey: keyPath) { [weak self] _ in + self?.setValue(color, forKey: keyPath) + } + } + + func add(_ animation: CAAnimation, forKey key: String?, completion: @escaping ((Bool) -> Void)) { + let animationDelegate = AnimationDelegate() + animationDelegate.didStopHandler = completion + animation.delegate = animationDelegate + add(animation, forKey: key) } } @@ -212,6 +289,19 @@ open class CustomButton: NSButton { } } + private lazy var trackingArea = TrackingArea( + for: self, + options: [ + .mouseEnteredAndExited, + .activeInActiveApp + ] + ) + + override open func updateTrackingAreas() { + super.updateTrackingAreas() + trackingArea.update() + } + private func setup() { wantsLayer = true diff --git a/Gifski/MainWindowController.swift b/Gifski/MainWindowController.swift index 320981f7..6be7280f 100644 --- a/Gifski/MainWindowController.swift +++ b/Gifski/MainWindowController.swift @@ -192,7 +192,7 @@ final class MainWindowController: NSWindowController { progress = Progress(totalUnitCount: 1) circularProgress.progressInstance = progress - DockProgress.progress = progress + DockProgress.progressInstance = progress timeEstimator.progress = progress timeEstimator.start() diff --git a/Gifski/Vendor/CircularProgress+Util.swift b/Gifski/Vendor/CircularProgress+Util.swift index 22289430..2a8f2979 100644 --- a/Gifski/Vendor/CircularProgress+Util.swift +++ b/Gifski/Vendor/CircularProgress+Util.swift @@ -1,29 +1,60 @@ -import AppKit +import Cocoa -extension NSBezierPath { - static func circle( - radius: Double, - center: CGPoint, - startAngle: Double = 0, - endAngle: Double = 360 - ) -> Self { - let path = self.init() - path.appendArc( - withCenter: center, - radius: CGFloat(radius), - startAngle: CGFloat(startAngle), - endAngle: CGFloat(endAngle) +extension NSColor { + typealias HSBAColor = (hue: Double, saturation: Double, brightness: Double, alpha: Double) + var hsba: HSBAColor { + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + let color = usingColorSpace(.deviceRGB) ?? self + color.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + return HSBAColor(Double(hue), Double(saturation), Double(brightness), Double(alpha)) + } + + /// Adjust color components by ratio. + func adjusting( + hue: Double = 0, + saturation: Double = 0, + brightness: Double = 0, + alpha: Double = 0 + ) -> NSColor { + let color = hsba + return NSColor( + hue: CGFloat(color.hue * (hue + 1)), + saturation: CGFloat(color.saturation * (saturation + 1)), + brightness: CGFloat(color.brightness * (brightness + 1)), + alpha: CGFloat(color.alpha * (alpha + 1)) ) - return path } } + extension CALayer { - /// This is required for CALayers that are created independently of a view - func setAutomaticContentsScale() { - contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + static func animate( + duration: TimeInterval = 1, + delay: TimeInterval = 0, + timingFunction: CAMediaTimingFunction = .default, + animations: @escaping (() -> Void), + completion: (() -> Void)? = nil + ) { + DispatchQueue.main.asyncAfter(duration: delay) { + CATransaction.begin() + CATransaction.setAnimationDuration(duration) + CATransaction.setAnimationTimingFunction(timingFunction) + + if let completion = completion { + CATransaction.setCompletionBlock(completion) + } + + animations() + CATransaction.commit() + } } +} + +extension CALayer { /** Set CALayer properties without the implicit animation @@ -56,29 +87,41 @@ extension CALayer { } } } +} - static func animate( - duration: TimeInterval = 1, - delay: TimeInterval = 0, - timingFunction: CAMediaTimingFunction = .default, - animations: @escaping (() -> Void), - completion: (() -> Void)? = nil - ) { - DispatchQueue.main.asyncAfter(duration: delay) { - CATransaction.begin() - CATransaction.setAnimationDuration(duration) - CATransaction.setAnimationTimingFunction(timingFunction) - if let completion = completion { - CATransaction.setCompletionBlock(completion) - } +extension CALayer { + /// This is required for CALayers that are created independently of a view + func setAutomaticContentsScale() { + contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + } +} - animations() - CATransaction.commit() - } + +extension NSFont { + static let helveticaNeueLight = NSFont(name: "HelveticaNeue-Light", size: 0) +} + + +extension NSBezierPath { + static func circle( + radius: Double, + center: CGPoint, + startAngle: Double = 0, + endAngle: Double = 360 + ) -> Self { + let path = self.init() + path.appendArc( + withCenter: center, + radius: CGFloat(radius), + startAngle: CGFloat(startAngle), + endAngle: CGFloat(endAngle) + ) + return path } } + extension CAShapeLayer { static func circle(radius: Double, center: CGPoint) -> Self { let layer = self.init() @@ -92,35 +135,6 @@ extension CAShapeLayer { } } -/** -Shows the indeterminate state, when it's activated. - -It draws part of a circle that gets animated into a looping motion around its core. -*/ -final class IndeterminateShapeLayer: CAShapeLayer { - convenience init(radius: Double, center: CGPoint) { - self.init() - fillColor = nil - path = NSBezierPath.circle(radius: radius, center: bounds.center, startAngle: 270).cgPath - anchorPoint = CGPoint(x: 0.5, y: 0.5) - position = center - } -} - -extension CABasicAnimation { - /// Rotates the element around its center point infinitely. - static var rotate: CABasicAnimation { - let animation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.transform)) - animation.valueFunction = CAValueFunction(name: .rotateZ) - animation.fromValue = 0 - animation.toValue = -(Double.pi * 2) - animation.duration = 1 - animation.repeatCount = .infinity - animation.timingFunction = CAMediaTimingFunction(name: .linear) - - return animation - } -} extension CATextLayer { /// Initializer with better defaults @@ -148,35 +162,58 @@ extension CATextLayer { } } -extension NSFont { - static let helveticaNeueLight = NSFont(name: "HelveticaNeue-Light", size: 0) + +final class ProgressCircleShapeLayer: CAShapeLayer { + convenience init(radius: Double, center: CGPoint) { + self.init() + fillColor = nil + lineCap = .round + path = NSBezierPath.progressCircle(radius: radius, center: center).cgPath + strokeEnd = 0 + } + + var progress: Double { + get { + return Double(strokeEnd) + } + set { + strokeEnd = CGFloat(newValue) + } + } + + func resetProgress() { + CALayer.withoutImplicitAnimations { + strokeEnd = 0 + } + } } -extension NSColor { - typealias HSBAColor = (hue: Double, saturation: Double, brightness: Double, alpha: Double) - var hsba: HSBAColor { - var hue: CGFloat = 0 - var saturation: CGFloat = 0 - var brightness: CGFloat = 0 - var alpha: CGFloat = 0 - let color = usingColorSpace(.deviceRGB) ?? self - color.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) - return HSBAColor(Double(hue), Double(saturation), Double(brightness), Double(alpha)) +/** +Shows the indeterminate state, when it's activated. + +It draws part of a circle that gets animated into a looping motion around its core. +*/ +final class IndeterminateShapeLayer: CAShapeLayer { + convenience init(radius: Double, center: CGPoint) { + self.init() + fillColor = nil + path = NSBezierPath.circle(radius: radius, center: bounds.center, startAngle: 270).cgPath + anchorPoint = CGPoint(x: 0.5, y: 0.5) + position = center } +} - /// Adjust color components by ratio. - func adjusting( - hue: Double = 0, - saturation: Double = 0, - brightness: Double = 0, - alpha: Double = 0 - ) -> NSColor { - let color = hsba - return NSColor( - hue: CGFloat(color.hue * (hue + 1)), - saturation: CGFloat(color.saturation * (saturation + 1)), - brightness: CGFloat(color.brightness * (brightness + 1)), - alpha: CGFloat(color.alpha * (alpha + 1)) - ) + +extension CABasicAnimation { + /// Rotates the element around its center point infinitely. + static var rotate: CABasicAnimation { + let animation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.transform)) + animation.valueFunction = CAValueFunction(name: .rotateZ) + animation.fromValue = 0 + animation.toValue = -(Double.pi * 2) + animation.duration = 1 + animation.repeatCount = .infinity + animation.timingFunction = CAMediaTimingFunction(name: .linear) + return animation } } diff --git a/Gifski/Vendor/Defaults.swift b/Gifski/Vendor/Defaults.swift index cc66013c..f01f93d2 100644 --- a/Gifski/Vendor/Defaults.swift +++ b/Gifski/Vendor/Defaults.swift @@ -3,48 +3,66 @@ import Foundation public final class Defaults { public class Keys { + public typealias Key = Defaults.Key + public typealias OptionalKey = Defaults.OptionalKey + fileprivate init() {} } public final class Key: Keys { - fileprivate let name: String - fileprivate let defaultValue: T + public let name: String + public let defaultValue: T + public let suite: UserDefaults - public init(_ key: String, default defaultValue: T) { + public init(_ key: String, default defaultValue: T, suite: UserDefaults = .standard) { self.name = key self.defaultValue = defaultValue + self.suite = suite + + super.init() + + // Sets the default value in the actual UserDefaults, so it can be used in other contexts, like binding. + if UserDefaults.isNativelySupportedType(T.self) { + suite.register(defaults: [key: defaultValue]) + } else if let value = suite._encode(defaultValue) { + suite.register(defaults: [key: value]) + } } } public final class OptionalKey: Keys { - fileprivate let name: String + public let name: String + public let suite: UserDefaults - public init(_ key: String) { + public init(_ key: String, suite: UserDefaults = .standard) { self.name = key + self.suite = suite } } + fileprivate init() {} + public subscript(key: Defaults.Key) -> T { get { - return UserDefaults.standard[key] + return key.suite[key] } set { - UserDefaults.standard[key] = newValue + key.suite[key] = newValue } } public subscript(key: Defaults.OptionalKey) -> T? { get { - return UserDefaults.standard[key] + return key.suite[key] } set { - UserDefaults.standard[key] = newValue + key.suite[key] = newValue } } - public func clear() { - for key in UserDefaults.standard.dictionaryRepresentation().keys { - UserDefaults.standard.removeObject(forKey: key) + public func clear(suite: UserDefaults = .standard) { + for key in suite.dictionaryRepresentation().keys { + suite.removeObject(forKey: key) } } } @@ -54,13 +72,15 @@ public let defaults = Defaults() extension UserDefaults { private func _get(_ key: String) -> T? { - if isNativelySupportedType(T.self) { + if UserDefaults.isNativelySupportedType(T.self) { return object(forKey: key) as? T } - guard let text = string(forKey: key), - let data = "[\(text)]".data(using: .utf8) else { - return nil + guard + let text = string(forKey: key), + let data = "[\(text)]".data(using: .utf8) + else { + return nil } do { @@ -72,24 +92,28 @@ extension UserDefaults { return nil } - private func _set(_ key: String, to value: T) { - if isNativelySupportedType(T.self) { - set(value, forKey: key) - return - } - + fileprivate func _encode(_ value: T) -> String? { do { // Some codable values like URL and enum are encoded as a top-level // string which JSON can't handle, so we need to wrap it in an array // We need this: https://forums.swift.org/t/allowing-top-level-fragments-in-jsondecoder/11750 let data = try JSONEncoder().encode([value]) - let string = String(data: data, encoding: .utf8)?.dropFirst().dropLast() - set(string, forKey: key) + return String(String(data: data, encoding: .utf8)!.dropFirst().dropLast()) } catch { print(error) + return nil } } + private func _set(_ key: String, to value: T) { + if UserDefaults.isNativelySupportedType(T.self) { + set(value, forKey: key) + return + } + + set(_encode(value), forKey: key) + } + public subscript(key: Defaults.Key) -> T { get { return _get(key.name) ?? key.defaultValue @@ -113,7 +137,7 @@ extension UserDefaults { } } - private func isNativelySupportedType(_ type: T.Type) -> Bool { + fileprivate static func isNativelySupportedType(_ type: T.Type) -> Bool { switch type { case is Bool.Type, is String.Type, @@ -128,3 +152,194 @@ extension UserDefaults { } } } + +/// TODO: Nest this inside `Defaults` if Swift ever supported nested protocols. +public protocol DefaultsObservation { + func invalidate() +} + +extension Defaults { + private static func deserialize(_ value: Any?, to type: T.Type) -> T? { + guard + let value = value, + !(value is NSNull) + else { + return nil + } + + // This handles the case where the value was a plist value using `isNativelySupportedType` + if let value = value as? T { + return value + } + + // Using the array trick as done below in `UserDefaults#_set()` + return [T].init(jsonString: "\([value])")?.first + } + + fileprivate final class BaseChange { + fileprivate let kind: NSKeyValueChange + fileprivate let indexes: IndexSet? + fileprivate let isPrior: Bool + fileprivate let newValue: Any? + fileprivate let oldValue: Any? + + fileprivate init(change: [NSKeyValueChangeKey: Any]) { + kind = NSKeyValueChange(rawValue: change[.kindKey] as! UInt)! + indexes = change[.indexesKey] as? IndexSet + isPrior = change[.notificationIsPriorKey] as? Bool ?? false + oldValue = change[.oldKey] + newValue = change[.newKey] + } + } + + public struct KeyChange { + public let kind: NSKeyValueChange + public let indexes: IndexSet? + public let isPrior: Bool + public let newValue: T + public let oldValue: T + + fileprivate init(change: BaseChange, defaultValue: T) { + self.kind = change.kind + self.indexes = change.indexes + self.isPrior = change.isPrior + self.oldValue = deserialize(change.oldValue, to: T.self) ?? defaultValue + self.newValue = deserialize(change.newValue, to: T.self) ?? defaultValue + } + } + + public struct OptionalKeyChange { + public let kind: NSKeyValueChange + public let indexes: IndexSet? + public let isPrior: Bool + public let newValue: T? + public let oldValue: T? + + fileprivate init(change: BaseChange) { + self.kind = change.kind + self.indexes = change.indexes + self.isPrior = change.isPrior + self.oldValue = deserialize(change.oldValue, to: T.self) + self.newValue = deserialize(change.newValue, to: T.self) + } + } + + private final class UserDefaultsKeyObservation: NSObject, DefaultsObservation { + fileprivate typealias Callback = (BaseChange) -> Void + + private weak var object: UserDefaults? + private let key: String + private let callback: Callback + + fileprivate init(object: UserDefaults, key: String, callback: @escaping Callback) { + self.object = object + self.key = key + self.callback = callback + } + + deinit { + invalidate() + } + + fileprivate func start(options: NSKeyValueObservingOptions) { + object?.addObserver(self, forKeyPath: key, options: options, context: nil) + } + + public func invalidate() { + object?.removeObserver(self, forKeyPath: key, context: nil) + object = nil + } + + // swiftlint:disable:next block_based_kvo + override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, // swiftlint:disable:this discouraged_optional_collection + context: UnsafeMutableRawPointer? + ) { + guard + let selfObject = self.object, + selfObject == object as? NSObject, + let change = change + else { + return + } + + callback(BaseChange(change: change)) + } + } + + /** + Observe a defaults key + + ``` + extension Defaults.Keys { + static let isUnicornMode = Key("isUnicornMode", default: false) + } + + let observer = defaults.observe(.isUnicornMode) { change in + print(change.newValue) + //=> false + } + ``` + */ + public func observe( + _ key: Defaults.Key, + options: NSKeyValueObservingOptions = [.initial, .old, .new], + handler: @escaping (KeyChange) -> Void + ) -> DefaultsObservation { + let observation = UserDefaultsKeyObservation(object: key.suite, key: key.name) { change in + handler( + KeyChange(change: change, defaultValue: key.defaultValue) + ) + } + observation.start(options: options) + return observation + } + + /** + Observe an optional defaults key + + ``` + extension Defaults.Keys { + static let isUnicornMode = OptionalKey("isUnicornMode") + } + + let observer = defaults.observe(.isUnicornMode) { change in + print(change.newValue) + //=> Optional(nil) + } + ``` + */ + public func observe( + _ key: Defaults.OptionalKey, + options: NSKeyValueObservingOptions = [.initial, .old, .new], + handler: @escaping (OptionalKeyChange) -> Void + ) -> DefaultsObservation { + let observation = UserDefaultsKeyObservation(object: key.suite, key: key.name) { change in + handler( + OptionalKeyChange(change: change) + ) + } + observation.start(options: options) + return observation + } +} + +extension Decodable { + init?(jsonData: Data) { + guard let value = try? JSONDecoder().decode(Self.self, from: jsonData) else { + return nil + } + + self = value + } + + init?(jsonString: String) { + guard let data = jsonString.data(using: .utf8) else { + return nil + } + + self.init(jsonData: data) + } +} diff --git a/Gifski/Vendor/DockProgress.swift b/Gifski/Vendor/DockProgress.swift index 155a4647..dfe990d8 100644 --- a/Gifski/Vendor/DockProgress.swift +++ b/Gifski/Vendor/DockProgress.swift @@ -4,7 +4,7 @@ import Cocoa public final class DockProgress { private static let appIcon = NSApp.applicationIconImage! - private static var previousProgressValue: Double = 0 + private static var previousProgress: Double = 0 private static var progressObserver: NSKeyValueObservation? private static var finishedObserver: NSKeyValueObservation? @@ -12,40 +12,41 @@ public final class DockProgress { NSApp.dockTile.contentView = $0 } - public static var progress: Progress? { + public static var progressInstance: Progress? { didSet { - if let progress = progress { - progressObserver = progress.observe(\.fractionCompleted) { sender, _ in + if let progressInstance = progressInstance { + progressObserver = progressInstance.observe(\.fractionCompleted) { sender, _ in guard !sender.isCancelled && !sender.isFinished else { return } - progressValue = sender.fractionCompleted + progress = sender.fractionCompleted } - finishedObserver = progress.observe(\.isFinished) { sender, _ in + finishedObserver = progressInstance.observe(\.isFinished) { sender, _ in guard !sender.isCancelled && sender.isFinished else { return } - progressValue = 1 + progress = 1 } } } } - public static var progressValue: Double = 0 { + public static var progress: Double = 0 { didSet { - if previousProgressValue == 0 || (progressValue - previousProgressValue).magnitude > 0.01 { - previousProgressValue = progressValue + if previousProgress == 0 || (progress - previousProgress).magnitude > 0.01 { + previousProgress = progress updateDockIcon() } } } + /// Reset the `progress` without animating public static func resetProgress() { - progressValue = 0 - previousProgressValue = 0 + progress = 0 + previousProgress = 0 updateDockIcon() } @@ -61,8 +62,8 @@ public final class DockProgress { // TODO: Make the progress smoother by also animating the steps between each call to `updateDockIcon()` private static func updateDockIcon() { - // TODO: If the `progressValue` is 1, draw the full circle, then schedule another draw in n milliseconds to hide it - let icon = (0..<1).contains(progressValue) ? draw() : appIcon + // TODO: If the `progress` is 1, draw the full circle, then schedule another draw in n milliseconds to hide it + let icon = (0..<1).contains(progress) ? draw() : appIcon DispatchQueue.main.async { // TODO: Make this better by drawing in the `contentView` directly instead of using an image dockImageView.image = icon @@ -104,7 +105,7 @@ public final class DockProgress { roundedRect(barInnerBg) var barProgress = bar.insetBy(dx: 1, dy: 1) - barProgress.size.width = barProgress.width * CGFloat(progressValue) + barProgress.size.width = barProgress.width * CGFloat(progress) NSColor.white.set() roundedRect(barProgress) } @@ -118,7 +119,7 @@ public final class DockProgress { progressCircle.strokeColor = color.cgColor progressCircle.lineWidth = 4 progressCircle.cornerRadius = 3 - progressCircle.progress = progressValue + progressCircle.progress = progress progressCircle.render(in: cgContext) } @@ -146,7 +147,7 @@ public final class DockProgress { progressCircle.strokeColor = color.cgColor progressCircle.lineWidth = lineWidth progressCircle.lineCap = .butt - progressCircle.progress = progressValue + progressCircle.progress = progress // Label let dimension = badge.bounds.height - 5 @@ -210,52 +211,6 @@ public final class DockProgress { /// util.swift /// -extension NSFont { - static let helveticaNeueBold = NSFont(name: "HelveticaNeue-Bold", size: 0) -} - - -/// Fixes the vertical alignment issue of the `CATextLayer` class. -final class VerticallyCenteredTextLayer: CATextLayer { - convenience init(frame rect: CGRect, center: CGPoint) { - self.init() - frame = rect - frame.center = center - contentsScale = NSScreen.main?.backingScaleFactor ?? 2 - } - - // From https://stackoverflow.com/a/44055040/6863743 - override func draw(in context: CGContext) { - let height = bounds.size.height - let deltaY = ((height - fontSize) / 2 - fontSize / 10) * -1 - - context.saveGState() - context.translateBy(x: 0, y: deltaY) - super.draw(in: context) - context.restoreGState() - } -} - - -/** -Convenience function for initializing an object and modifying its properties - -``` -let label = with(NSTextField()) { - $0.stringValue = "Foo" - $0.textColor = .systemBlue - view.addSubview($0) -} -``` -*/ -//@discardableResult -//private func with(_ item: T, update: (inout T) throws -> Void) rethrows -> T { -// var this = item -// try update(&this) -// return this -//} - - extension NSBezierPath { /// For making a circle progress indicator static func progressCircle(radius: Double, center: CGPoint) -> Self { @@ -273,58 +228,11 @@ extension NSBezierPath { } -final class ProgressCircleShapeLayer: CAShapeLayer { - convenience init(radius: Double, center: CGPoint) { - self.init() - fillColor = nil - lineCap = .round - position = center - strokeEnd = 0 - - let cgPath = NSBezierPath.progressCircle(radius: radius, center: center).cgPath - path = cgPath - bounds = cgPath.boundingBox - } - - var progress: Double { - get { - return Double(strokeEnd) - } - set { - strokeEnd = CGFloat(newValue) - } - } - - func resetProgress() { - CALayer.withoutImplicitAnimations { - strokeEnd = 0 - } - } +extension NSFont { + static let helveticaNeueBold = NSFont(name: "HelveticaNeue-Bold", size: 0) } -//private extension NSColor { -// func with(alpha: Double) -> NSColor { -// return withAlphaComponent(CGFloat(alpha)) -// } -//} - - -//private extension CGRect { -// var center: CGPoint { -// get { -// return CGPoint(x: midX, y: midY) -// } -// set { -// origin = CGPoint( -// x: newValue.x - (size.width / 2), -// y: newValue.y - (size.height / 2) -// ) -// } -// } -//} - - extension NSBezierPath { /// UIKit polyfill var cgPath: CGPath { @@ -355,3 +263,25 @@ extension NSBezierPath { self.init(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius) } } + + +/// Fixes the vertical alignment issue of the `CATextLayer` class. +final class VerticallyCenteredTextLayer: CATextLayer { + convenience init(frame rect: CGRect, center: CGPoint) { + self.init() + frame = rect + frame.center = center + contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + } + + // From https://stackoverflow.com/a/44055040/6863743 + override func draw(in context: CGContext) { + let height = bounds.size.height + let deltaY = ((height - fontSize) / 2 - fontSize / 10) * -1 + + context.saveGState() + context.translateBy(x: 0, y: deltaY) + super.draw(in: context) + context.restoreGState() + } +}