diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..6da1365 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: ['DockProgress'] diff --git a/.swiftlint.yml b/.swiftlint.yml index e23b8c2..8f2a2dd 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,6 +1,7 @@ only_rules: - - anyobject_protocol + - accessibility_trait_for_button - array_init + - blanket_disable_command - block_based_kvo - class_delegate_protocol - closing_brace @@ -10,6 +11,7 @@ only_rules: - collection_alignment - colon - comma + - comma_inheritance - compiler_protocol_init - computed_accessors_order - conditional_returns_on_newline @@ -20,6 +22,7 @@ only_rules: - control_statement - custom_rules - deployment_target + - direct_return - discarded_notification_center_observer - discouraged_assert - discouraged_direct_init @@ -27,6 +30,7 @@ only_rules: - discouraged_object_literal - discouraged_optional_boolean - discouraged_optional_collection + - duplicate_conditions - duplicate_enum_cases - duplicate_imports - duplicated_key_in_dictionary_literal @@ -52,7 +56,7 @@ only_rules: - implicit_getter - implicit_return - inclusive_language - - inert_defer + - invalid_swiftlint_command - is_disjoint - joined_default_parameter - last_where @@ -68,7 +72,6 @@ only_rules: - lower_acl_than_parent - mark - modifier_order - - multiline_arguments - multiline_function_chains - multiline_literal_brackets - multiline_parameters @@ -78,13 +81,14 @@ only_rules: - no_fallthrough_only - no_space_in_method_call - notification_center_detachment + - ns_number_init_as_function_reference - nsobject_prefer_isequal - number_separator - opening_brace - operator_usage_whitespace - operator_whitespace - - orphaned_doc_comment - overridden_super_call + - prefer_self_in_static_references - prefer_self_type_over_type_of_self - prefer_zero_over_explicit_init - private_action @@ -105,12 +109,17 @@ only_rules: - redundant_void_return - required_enum_case - return_arrow_whitespace + - return_value_from_void_function + - self_binding + - self_in_property_initialization - shorthand_operator + - shorthand_optional_binding - sorted_first_last - statement_position - static_operator - strong_iboutlet - superfluous_disable_command + - superfluous_else - switch_case_alignment - switch_case_on_newline - syntactic_sugar @@ -121,12 +130,12 @@ only_rules: - trailing_newline - trailing_semicolon - trailing_whitespace + - unavailable_condition - unavailable_function - unneeded_break_in_switch - unneeded_parentheses_in_closure_argument - unowned_variable_capture - untyped_error_in_catch - - unused_capture_list - unused_closure_parameter - unused_control_flow_label - unused_enumerated @@ -134,9 +143,9 @@ only_rules: - unused_setter_value - valid_ibinspectable - vertical_parameter_alignment - - vertical_parameter_alignment_on_call - vertical_whitespace_closing_braces - vertical_whitespace_opening_braces + - void_function_in_ternary - void_return - xct_specific_matcher - xctfail_message @@ -145,6 +154,9 @@ analyzer_rules: - capture_variable - unused_declaration - unused_import + - typesafe_array_init +for_where: + allow_for_as_filter: true number_separator: minimum_length: 5 identifier_name: @@ -154,7 +166,6 @@ identifier_name: min_length: warning: 2 error: 2 - validates_start_with_lowercase: false allowed_symbols: - '_' excluded: @@ -199,3 +210,6 @@ custom_rules: final_class: regex: '^class [a-zA-Z\d]+[^{]+\{' message: 'Classes should be marked as final whenever possible. If you actually need it to be subclassable, just add `// swiftlint:disable:next final_class`.' + no_alignment_center: + regex: '\b\(alignment: .center\b' + message: 'This alignment is the default.' diff --git a/Example/DockProgress Example/AppState.swift b/Example/DockProgress Example/AppState.swift index 72a4f0f..6ae077d 100644 --- a/Example/DockProgress Example/AppState.swift +++ b/Example/DockProgress Example/AppState.swift @@ -20,7 +20,7 @@ final class AppState: ObservableObject { .bar, .squircle(color: .systemGray), .circle(radius: 30, color: .white), - .badge(color: .systemBlue) { Int(DockProgress.animatedProgress * 12) }, + .badge(color: .systemBlue) { Int(DockProgress.displayedProgress * 12) }, .pie(color: .systemBlue) ] @@ -30,15 +30,17 @@ final class AppState: ObservableObject { DockProgress.resetProgress() Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in - DockProgress.progress += 0.2 + Task { @MainActor in + DockProgress.progress += 0.2 - if DockProgress.animatedProgress >= 1 { - if let style = stylesIterator.next() { - DockProgress.resetProgress() - DockProgress.style = style - } else { - // Reset iterator when all is looped. - stylesIterator = styles.makeIterator() + if DockProgress.displayedProgress >= 1 { + if let style = stylesIterator.next() { + DockProgress.resetProgress() + DockProgress.style = style + } else { + // Reset iterator when all is looped. + stylesIterator = styles.makeIterator() + } } } } diff --git a/Package.swift b/Package.swift index 1de6ebf..2ce211e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.7 +// swift-tools-version:5.8 import PackageDescription let package = Package( diff --git a/Sources/DockProgress/DockProgress.swift b/Sources/DockProgress/DockProgress.swift index 7d1f182..384332c 100644 --- a/Sources/DockProgress/DockProgress.swift +++ b/Sources/DockProgress/DockProgress.swift @@ -1,22 +1,34 @@ import Cocoa +/** +Show progress in your app's Dock icon. + +Use either ``progress`` or ``progressInstance``. +*/ @MainActor public enum DockProgress { private static var progressObserver: NSKeyValueObservation? private static var finishedObserver: NSKeyValueObservation? - private static var elapsedTimeSinceLastRefresh = 0.0 - private static var displayLinkObserver = DisplayLinkObserver { (displayLinkObserver, refreshPeriod) in + + private static var displayLinkObserver = DisplayLinkObserver { displayLinkObserver, refreshPeriod in DispatchQueue.main.async { let speed = 1.0 + elapsedTimeSinceLastRefresh += speed * refreshPeriod - if (animatedProgress - progress).magnitude <= 0.01 { - animatedProgress = progress + + if (displayedProgress - progress).magnitude <= 0.01 { + displayedProgress = progress elapsedTimeSinceLastRefresh = 0 displayLinkObserver.stop() } else { - animatedProgress = Easing.lerp(animatedProgress, progress, Easing.easeInOut(elapsedTimeSinceLastRefresh)); + displayedProgress = Easing.linearInterpolation( + start: displayedProgress, + end: progress, + progress: Easing.easeInOut(progress: elapsedTimeSinceLastRefresh) + ) } + updateDockIcon() } } @@ -25,6 +37,23 @@ public enum DockProgress { NSApp.dockTile.contentView = $0 } + /** + Assign a [`Progress`](https://developer.apple.com/documentation/foundation/progress) instance to track the progress status. + + When set to `nil`, the progress will be reset. + + The given `Progress` instance is weakly stored. It's up to you to retain it. + + ```swift + import Foundation + import DockProgress + + let progress = Progress(totalUnitCount: 1) + progress?.becomeCurrent(withPendingUnitCount: 1) + + DockProgress.progressInstance = progress + ``` + */ public static weak var progressInstance: Progress? { didSet { guard let progressInstance else { @@ -63,6 +92,17 @@ public enum DockProgress { } } + /** + Indicates the current progress from 0.0 to 1.0. Setting this value will start the animation towards the set value. + + ```swift + import DockProgress + + foo.onUpdate = { progress in + DockProgress.progress = progress + } + ``` + */ public static var progress: Double = 0 { didSet { if progress > 0 { @@ -74,46 +114,120 @@ public enum DockProgress { } /** - The currently displayed progress (readonly). Animates towards `progress` + The currently displayed progress. Animates towards ``progress``. */ - public private(set) static var animatedProgress = 0.0 + public private(set) static var displayedProgress = 0.0 /** - Reset the `progress` without animating. + Reset the progress without animating. */ public static func resetProgress() { displayLinkObserver.stop() progress = 0 - animatedProgress = 0 - elapsedTimeSinceLastRefresh = 0; + displayedProgress = 0 + elapsedTimeSinceLastRefresh = 0 updateDockIcon() } + /** + The available progress styles. + + - `.bar` ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-bar.gif?raw=true) + - `.squircle` ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-squircle.gif?raw=true) + - `.circle` ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-circle.gif?raw=true) + - `.badge` ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-badge.gif?raw=true) + - `.pie` ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-pie.gif?raw=true) + */ public enum Style { + /** + Progress bar style. + + ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-bar.gif?raw=true) + */ case bar + + /** + Progress line animating around the edges of the app icon. + + - Parameters: + - inset: Inset value to adjust the squircle shape. By default, it should fit a normal macOS icon. + - color: The color of the progress. + + ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-squircle.gif?raw=true) + */ case squircle(inset: Double? = nil, color: NSColor = .controlAccentColor) + + /** + Circle style. + + - Parameters: + - radius: The radius of the circle. + - color: The color of the progress. + + ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-circle.gif?raw=true) + */ case circle(radius: Double, color: NSColor = .controlAccentColor) + + /** + Badge style. + + - Parameters: + - color: The color of the badge. + - badgeValue: A closure that returns the badge value as an integer. + + - Note: It is not meant to be used as a numeric percentage. It's for things like count of downloads, number of files being converted, etc. + + Large badge value numbers will be written in kilo short notation, for example, `1012` → `1k`. + + ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-badge.gif?raw=true) + */ case badge(color: NSColor = .controlAccentColor, badgeValue: () -> Int) + + /** + Pie style. + + - Parameters: + - color: The color of the pie. + + ![](https://github.com/sindresorhus/DockProgress/blob/main/screenshot-pie.gif?raw=true) + */ case pie(color: NSColor = .controlAccentColor) + + + /** + Custom style. + + - Parameters: + - drawHandler: A closure that is responsible for drawing the custom progress. + */ case custom(drawHandler: (_ rect: CGRect) -> Void) } + /** + The style to be used for displaying progress. + + The default style is `.bar`. + + Check out the example app in the Xcode project for a demo of the styles. + */ public static var style = Style.bar - // TODO: Make the progress smoother by also animating the steps between each call to `updateDockIcon()` private static func updateDockIcon() { - dockContentView.needsDisplay = true; + dockContentView.needsDisplay = true NSApp.dockTile.display() } - private class ContentView: NSView { + private final class ContentView: NSView { override func draw(_ dirtyRect: NSRect) { NSGraphicsContext.current?.imageInterpolation = .high NSApp.applicationIconImage?.draw(in: dirtyRect) // TODO: If the `progress` is 1, draw the full circle, then schedule another draw in n milliseconds to hide it - if (animatedProgress <= 0 || animatedProgress >= 1) { + guard + displayedProgress > 0, + displayedProgress < 1 + else { return } @@ -148,7 +262,7 @@ public enum DockProgress { roundedRect(barInnerBg) var barProgress = bar.insetBy(dx: 1, dy: 1) - barProgress.size.width = barProgress.width * animatedProgress + barProgress.size.width = barProgress.width * displayedProgress NSColor.white.set() roundedRect(barProgress) } @@ -169,7 +283,7 @@ public enum DockProgress { let progressSquircle = ProgressSquircleShapeLayer(rect: rect) progressSquircle.strokeColor = color.cgColor progressSquircle.lineWidth = 5 - progressSquircle.progress = animatedProgress + progressSquircle.progress = displayedProgress progressSquircle.render(in: cgContext) } @@ -181,7 +295,7 @@ public enum DockProgress { let progressCircle = ProgressCircleShapeLayer(radius: radius, center: dstRect.center) progressCircle.strokeColor = color.cgColor progressCircle.lineWidth = 4 - progressCircle.progress = animatedProgress + progressCircle.progress = displayedProgress progressCircle.render(in: cgContext) } @@ -209,7 +323,7 @@ public enum DockProgress { progressCircle.strokeColor = color.cgColor progressCircle.lineWidth = lineWidth progressCircle.lineCap = .butt - progressCircle.progress = animatedProgress + progressCircle.progress = displayedProgress // Label if !isPie { @@ -246,11 +360,13 @@ public enum DockProgress { if absNumber < 1000 { return "\(number)" - } else if absNumber < 10_000 { - return "\(sign * Int(absNumber / 1000))k" - } else { - return "\(sign * 9)k+" } + + if absNumber < 10_000 { + return "\(sign * Int(absNumber / 1000))k" + } + + return "\(sign * 9)k+" } private static func scaledBadgeFontSize(text: String) -> Double { diff --git a/Sources/DockProgress/Utilities.swift b/Sources/DockProgress/Utilities.swift index 7bf69fb..a89ce5d 100644 --- a/Sources/DockProgress/Utilities.swift +++ b/Sources/DockProgress/Utilities.swift @@ -271,56 +271,112 @@ final class VerticallyCenteredTextLayer: CATextLayer { } } + +/** +Provides functions for linear interpolation and easing effects. + +These functions are useful for animations and transitions, or anywhere you want to smoothly transition between two values. +*/ enum Easing { - static func lerp(_ start: Double, _ end: Double, _ t: Double) -> Double { - return Double(simd_mix(Float(start), Float(end), Float(t))) + /** + Linearly interpolates between two values. + + Also known as `lerp`. + + - Parameters: + - start: The start value. + - end: The end value. + - progress: The interpolation progress as a decimal between 0.0 and 1.0. + + - Returns: The interpolated value. + */ + static func linearInterpolation(start: Double, end: Double, progress: Double) -> Double { + assert(0...1 ~= progress, "Progress must be between 0.0 and 1.0") + return Double(simd_mix(Float(start), Float(end), Float(progress))) } - static private func easeIn(_ t: Double) -> Double { - return Double(simd_smoothstep(0.0, 1.0, Float(t))) + /** + Provides an ease-in effect. + + - Parameter progress: The progress as a decimal between 0.0 and 1.0. + + - Returns: The eased value. + */ + static private func easeIn(progress: Double) -> Double { + assert(0...1 ~= progress, "Progress must be between 0.0 and 1.0") + return Double(simd_smoothstep(0.0, 1.0, Float(progress))) } - static private func easeOut(_ t: Double) -> Double { - return 1 - easeIn(1 - t) + /** + Provides an ease-out effect. + + - Parameter progress: The progress as a decimal between 0.0 and 1.0. + + - Returns: The eased value. + */ + static private func easeOut(progress: Double) -> Double { + assert(0...1 ~= progress, "Progress must be between 0.0 and 1.0") + return 1 - easeIn(progress: 1 - progress) } - static func easeInOut(_ t: Double) -> Double { - return lerp(easeIn(t), easeOut(t), t) + /** + Provides an ease-in-out effect. + + - Parameter progress: The progress as a decimal between 0.0 and 1.0. + + - Returns: The eased value. + */ + static func easeInOut(progress: Double) -> Double { + assert(0...1 ~= progress, "Progress must be between 0.0 and 1.0") + + return linearInterpolation( + start: easeIn(progress: progress), + end: easeOut(progress: progress), + progress: progress + ) } } -typealias DisplayLinkObserverCallback = (DisplayLinkObserver, Double) -> Void; -class DisplayLinkObserver { +/** +An observer that invokes a callback for each screen refresh. + +This is useful for creating smooth animations that synchronize with the screen's refresh rate. +*/ +final class DisplayLinkObserver { private var displayLink: CVDisplayLink? - var callback: DisplayLinkObserverCallback + fileprivate let callback: (DisplayLinkObserver, Double) -> Void - init(_ callback: @escaping DisplayLinkObserverCallback) { + init(_ callback: @escaping (DisplayLinkObserver, Double) -> Void) { self.callback = callback - let result = CVDisplayLinkCreateWithActiveCGDisplays(&displayLink) - assert(result == kCVReturnSuccess, "Failed to create CVDisplayLink") + assert(CVDisplayLinkCreateWithActiveCGDisplays(&displayLink) == kCVReturnSuccess, "Failed to create CVDisplayLink") + } + + deinit { + stop() } func start() { - if let displayLink { - let result = CVDisplayLinkSetOutputCallback( - displayLink, - displayLinkOutputCallback, - UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - ) - assert(result == kCVReturnSuccess, "Failed to set CVDisplayLink output callback") - if (CVDisplayLinkStart(displayLink) != kCVReturnSuccess) { - print("Warning: CVDisplayLink already running") - } + guard let displayLink else { + return } + + let result = CVDisplayLinkSetOutputCallback( + displayLink, + displayLinkOutputCallback, + UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) + assert(result == kCVReturnSuccess, "Failed to set CVDisplayLink output callback") + + CVDisplayLinkStart(displayLink) } func stop() { - if let displayLink { - if (CVDisplayLinkStop(displayLink) != kCVReturnSuccess) { - print("Warning: CVDisplayLink already stopped") - } + guard let displayLink else { + return } + + CVDisplayLinkStop(displayLink) } } @@ -333,11 +389,14 @@ private func displayLinkOutputCallback( displayLinkContext: UnsafeMutableRawPointer? ) -> CVReturn { let observer = unsafeBitCast(displayLinkContext, to: DisplayLinkObserver.self) + var refreshPeriod = CVDisplayLinkGetActualOutputVideoRefreshPeriod(displayLink) if (refreshPeriod == 0) { print("Warning: CVDisplayLinkGetActualOutputVideoRefreshPeriod failed. Assuming 60 Hz...") refreshPeriod = 1.0 / 60.0 } + observer.callback(observer, refreshPeriod) + return kCVReturnSuccess } diff --git a/readme.md b/readme.md index 2004738..380ccc6 100644 --- a/readme.md +++ b/readme.md @@ -4,7 +4,7 @@ -This package is used in production by the [Gifski app](https://github.com/sindresorhus/Gifski). You might also like some of my [other apps](https://sindresorhus.com/apps). +This package is used in production by the [Gifski app](https://github.com/sindresorhus/Gifski). You may also like some of my [other apps](https://sindresorhus.com/apps). ## Requirements @@ -84,8 +84,6 @@ import DockProgress DockProgress.style = .circle(radius: 55, color: .systemBlue) ``` -Make sure to set a `radius` that matches your app icon. - ### Badge ![](screenshot-badge.gif) @@ -113,8 +111,6 @@ DockProgress.style = .pie(color: .systemBlue) ## Related - [Defaults](https://github.com/sindresorhus/Defaults) - Swifty and modern UserDefaults -- [Preferences](https://github.com/sindresorhus/Preferences) - Add a preferences window to your macOS app in minutes - [KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts) - Add user-customizable global keyboard shortcuts to your macOS app - [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin) - Add "Launch at Login" functionality to your macOS app -- [Regex](https://github.com/sindresorhus/Regex) - Swifty regular expressions - [More…](https://github.com/search?q=user%3Asindresorhus+language%3Aswift)