From dbb7784198de5139765ed5cd39df622f55f27fb7 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 24 Feb 2018 23:08:38 +0700 Subject: [PATCH] Show progress in the Dock icon --- Gifski/AppDelegate.swift | 122 +++++++++++++++++++++++++++++++++++++++ Gifski/Info.plist | 6 +- Gifski/util.swift | 30 ++++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) diff --git a/Gifski/AppDelegate.swift b/Gifski/AppDelegate.swift index 0f0cb70f..3bf2470e 100644 --- a/Gifski/AppDelegate.swift +++ b/Gifski/AppDelegate.swift @@ -1,6 +1,125 @@ import Cocoa import ProgressKit +/// TODO: Fade the progress-bar in on the first progress events +/// TODO: I will make this a separate SPM package soon +final class DockIconProgress { + private static let appIcon = NSApp.applicationIconImage! + private static var previousProgressValue: Double = 0 + private static var progressObserver: NSKeyValueObservation? + + private static var dockImageView: NSImageView = { + let dockImageView = NSImageView() + NSApp.dockTile.contentView = dockImageView + return dockImageView + }() + + static var progress: Progress? { + didSet { + if let progress = progress { + progressObserver = progress.observe(\.fractionCompleted, options: .new) { object, _ in + progressValue = object.fractionCompleted + } + } + } + } + + static var progressValue: Double = 0 { + didSet { + if previousProgressValue == 0 || (progressValue - previousProgressValue).magnitude > 0.001 { + previousProgressValue = progressValue + updateDockIcon() + } + } + } + + enum ProgressStyle { + case bar + /// TODO: Make `color` optional when https://github.com/apple/swift-evolution/blob/master/proposals/0155-normalize-enum-case-representation.md is shipping in Swift + case circle(radius: Double, color: NSColor) + case custom(drawHandler: (_ rect: CGRect) -> Void) + } + + static var style: ProgressStyle = .bar + + /// TODO: Make the progress smoother by also animating the steps between each call to `updateDockIcon()` + private static func updateDockIcon() { + DispatchQueue.global(qos: .utility).async { + /// 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(self.progressValue) ? self.draw() : appIcon + DispatchQueue.main.async { + /// TODO: Make this better by drawing in the `contentView` directly instead of using an image + dockImageView.image = icon + NSApp.dockTile.display() + } + } + } + + private static func draw() -> NSImage { + return NSImage(size: appIcon.size, flipped: false) { dstRect in + NSGraphicsContext.current?.imageInterpolation = .high + self.appIcon.draw(in: dstRect) + + switch self.style { + case .bar: + self.drawProgressBar(dstRect) + case let .circle(radius, color): + self.drawProgressCircle(dstRect, radius: radius, color: color) + case let .custom(drawingHandler): + drawingHandler(dstRect) + } + + return true + } + } + + private static func drawProgressBar(_ dstRect: CGRect) { + func roundedRect(_ rect: CGRect) { + NSBezierPath(roundedRect: rect, cornerRadius: rect.height / 2).fill() + } + + let bar = CGRect(x: 0, y: 20, width: dstRect.width, height: 10) + NSColor.white.withAlphaComponent(0.8).set() + roundedRect(bar) + + let barInnerBg = bar.insetBy(dx: 0.5, dy: 0.5) + NSColor.black.withAlphaComponent(0.8).set() + roundedRect(barInnerBg) + + var barProgress = bar.insetBy(dx: 1, dy: 1) + barProgress.size.width = barProgress.width * CGFloat(self.progressValue) + NSColor.white.set() + roundedRect(barProgress) + } + + private static func drawProgressCircle(_ dstRect: CGRect, radius: Double, color: NSColor) { + guard let cgContext = NSGraphicsContext.current?.cgContext else { + return + } + + let path = NSBezierPath() + let startAngle: CGFloat = 90 + let endAngle = startAngle - (360 * CGFloat(self.progressValue)) + path.appendArc( + withCenter: dstRect.center, + radius: CGFloat(radius), + startAngle: startAngle, + endAngle: endAngle, + clockwise: true + ) + + let arc = CAShapeLayer() + arc.path = path.cgPath + arc.lineCap = kCALineCapRound + arc.fillColor = nil + arc.strokeColor = color.cgColor + arc.lineWidth = 4 + arc.cornerRadius = 3 + arc.render(in: cgContext) + } +} + + extension NSColor { static let appTheme = NSColor(named: NSColor.Name("Theme"))! } @@ -189,5 +308,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { frameRate: choosenFrameRate ) progress?.publish() + + DockIconProgress.progress = progress + DockIconProgress.style = .circle(radius: 55, color: .appTheme) } } diff --git a/Gifski/Info.plist b/Gifski/Info.plist index 4d6d4371..89885c07 100644 --- a/Gifski/Info.plist +++ b/Gifski/Info.plist @@ -31,16 +31,14 @@ APPL CFBundleShortVersionString 1.0.0 - CFBundleURLTypes - - - CFBundleVersion 1 LSApplicationCategoryType public.app-category.photography LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) + MDItemKeywords + gif,convert,video NSMainNibFile MainMenu NSPrincipalClass diff --git a/Gifski/util.swift b/Gifski/util.swift index 5c5f4ae6..88c55657 100644 --- a/Gifski/util.swift +++ b/Gifski/util.swift @@ -55,6 +55,36 @@ class SSView: NSView { } +extension NSBezierPath { + /// UIKit polyfill + var cgPath: CGPath { + let path = CGMutablePath() + var points = [CGPoint](repeating: .zero, count: 3) + + for i in 0..