Files
Mike Renoir ad36d42c23 bugfixes
2024-01-05 16:38:24 +04:00

554 lines
20 KiB
Swift

//
// RadialProgressLayer.swift
// TGUIKit
//
// Created by keepcoder on 17/09/16.
// Copyright © 2016 Telegram. All rights reserved.
//
import Cocoa
import SwiftSignalKit
private func progressInteractiveThumb(backgroundColor: NSColor, foregroundColor: NSColor) -> CGImage {
let context = DrawingContext(size: NSMakeSize(40, 40), scale: 1.0, clear: true)
context.withContext { (ctx) in
ctx.round(context.size, context.size.height/2.0)
ctx.setFillColor(backgroundColor.cgColor)
let image = #imageLiteral(resourceName: "Icon_MessageFile").precomposed(foregroundColor)
ctx.fill(NSMakeRect(0, 0, context.size.width, context.size.height))
ctx.draw(image, in: NSMakeRect(floorToScreenPixels(System.backingScale, (context.size.width - image.backingSize.width) / 2.0), floorToScreenPixels(System.backingScale, (context.size.height - image.backingSize.height) / 2.0), image.backingSize.width, image.backingSize.height))
}
return context.generateImage()!
}
public struct FetchControls {
public let fetch: () -> Void
public init(fetch:@escaping()->Void) {
self.fetch = fetch
}
}
private class RadialProgressParameters: NSObject {
let theme: RadialProgressTheme
let diameter: CGFloat
let twist: Bool
let state: RadialProgressState
let clockwise: Bool
init(theme: RadialProgressTheme, diameter: CGFloat, state: RadialProgressState, twist: Bool = true) {
self.theme = theme
self.diameter = diameter
self.state = state
self.twist = twist
self.clockwise = theme.clockwise
super.init()
}
}
public struct RadialProgressTheme : Equatable {
public let backgroundColor: NSColor
public let foregroundColor: NSColor
public let icon: CGImage?
public let cancelFetchingIcon: CGImage?
public let iconInset:NSEdgeInsets
public let diameter:CGFloat?
public let lineWidth: CGFloat
public let clockwise: Bool
public let blendMode: CGBlendMode
public init(backgroundColor:NSColor, foregroundColor:NSColor, icon:CGImage? = nil, cancelFetchingIcon: CGImage? = nil, iconInset:NSEdgeInsets = NSEdgeInsets(), diameter: CGFloat? = nil, lineWidth: CGFloat = 2, clockwise: Bool = true, blendMode: CGBlendMode = .normal) {
self.iconInset = iconInset
self.backgroundColor = backgroundColor
self.foregroundColor = foregroundColor
self.icon = icon
self.cancelFetchingIcon = cancelFetchingIcon
self.diameter = diameter
self.lineWidth = lineWidth
self.clockwise = clockwise
self.blendMode = blendMode
}
}
public func ==(lhs:RadialProgressTheme, rhs:RadialProgressTheme) -> Bool {
return lhs.backgroundColor == rhs.backgroundColor && lhs.foregroundColor == rhs.foregroundColor && ((lhs.icon == nil) == (rhs.icon == nil)) && lhs.blendMode == rhs.blendMode
}
public enum RadialProgressState: Equatable {
case None
case Remote
case Fetching(progress: Float, force: Bool)
case ImpossibleFetching(progress: Float, force: Bool)
case Play
case Icon(image:CGImage)
case Success
}
public func ==(lhs:RadialProgressState, rhs:RadialProgressState) -> Bool {
switch lhs {
case .None:
if case .None = rhs {
return true
} else {
return false
}
case .Remote:
if case .Remote = rhs {
return true
} else {
return false
}
case .Success:
if case .Success = rhs {
return true
} else {
return false
}
case .Play:
if case .Play = rhs {
return true
} else {
return false
}
case let .Fetching(progress, force):
if case .Fetching(progress, force) = rhs {
return true
} else {
return false
}
case let .ImpossibleFetching(progress, force):
if case .ImpossibleFetching(progress, force) = rhs {
return true
} else {
return false
}
case .Icon:
if case .Icon = rhs {
return true
} else {
return false
}
}
}
private class RadialProgressOverlayLayer {
var theme: RadialProgressTheme
let twist: Bool
private var timer: SwiftSignalKit.Timer?
private var _progress: Float = 0
private var progress: Float = 0
var display:(()->Void)? = nil
var parameters:RadialProgressParameters {
return RadialProgressParameters(theme: self.theme, diameter: theme.diameter ?? 40, state: self.state, twist: twist)
}
func setNeedsDisplay() {
display?()
}
var state: RadialProgressState = .None {
didSet {
switch state {
case .None, .Play, .Remote, .Icon, .Success:
self.progress = 0
self._progress = 0
mayAnimate(false)
case let .Fetching(progress, f), let .ImpossibleFetching(progress, f):
self.progress = twist ? max(progress, 0.05) : progress
if f {
_progress = progress
}
mayAnimate(true)
}
let fps: Float = 60
let difference = progress - _progress
let tick: Float = Float(difference / (fps * 0.2))
let clockwise = theme.clockwise
if (clockwise && difference > 0) || (!clockwise && difference != 0) {
timer = SwiftSignalKit.Timer(timeout: TimeInterval(1 / fps), repeat: true, completion: { [weak self] in
if let strongSelf = self {
strongSelf._progress += tick
strongSelf.setNeedsDisplay()
if strongSelf._progress == strongSelf.progress || strongSelf._progress < 0 || (strongSelf._progress >= 1 && !clockwise) || (strongSelf._progress > strongSelf.progress && clockwise) {
strongSelf.stopAnimation()
}
}
}, queue: Queue.mainQueue())
timer?.start()
} else {
stopAnimation()
_progress = progress
}
self.setNeedsDisplay()
}
}
func stopAnimation() {
timer?.invalidate()
timer = nil
self.setNeedsDisplay()
}
init(theme: RadialProgressTheme, twist: Bool) {
self.theme = theme
self.twist = twist
}
func draw(in ctx: CGContext, diameter: CGFloat) {
ctx.setStrokeColor(theme.foregroundColor.cgColor)
let startAngle = 2.0 * (CGFloat.pi) * CGFloat(_progress) - CGFloat.pi / 2 + rotation
let endAngle = -(CGFloat.pi / 2) + rotation
let pathDiameter = !twist ? diameter - parameters.theme.lineWidth : diameter - parameters.theme.lineWidth - parameters.theme.lineWidth * parameters.theme.lineWidth
ctx.addArc(center: NSMakePoint(diameter / 2.0, floorToScreenPixels(System.backingScale, diameter / 2.0)), radius: pathDiameter / 2.0, startAngle: startAngle, endAngle: endAngle, clockwise: parameters.clockwise)
ctx.setLineWidth(parameters.theme.lineWidth);
ctx.setLineCap(.round);
ctx.strokePath()
}
private var rotator: ConstantDisplayLinkAnimator?
private var rotation : CGFloat = 0 {
didSet {
setNeedsDisplay()
}
}
fileprivate func mayAnimate(_ animate: Bool) {
if animate, parameters.twist {
self.rotator = ConstantDisplayLinkAnimator(update: { [weak self] in
self?.rotation += (CGFloat.pi * 2.0) / 120.0
})
self.rotator?.isPaused = false
} else {
self.rotator?.isPaused = true
self.rotator = nil
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
public class RadialProgressView: Control {
public var fetchControls:FetchControls? {
didSet {
self.removeAllHandlers()
if let fetchControls = fetchControls {
set(handler: { _ in
fetchControls.fetch()
}, for: .Click)
}
}
}
public var theme:RadialProgressTheme {
didSet {
overlay.theme = theme
self.setNeedsDisplay()
}
}
private let overlay: RadialProgressOverlayLayer
private var parameters:RadialProgressParameters {
return RadialProgressParameters(theme: self.theme, diameter: NSWidth(self.frame), state: self.state)
}
public var badge: CGRect? {
didSet {
_layer.setNeedsDisplay()
}
}
public var state: RadialProgressState = .None {
didSet {
self.overlay.state = self.state
self.setNeedsDisplay()
}
}
public override func setNeedsDisplay() {
super.setNeedsDisplay()
_layer.setNeedsDisplay()
overlay.setNeedsDisplay()
}
public override func viewDidMoveToSuperview() {
overlay.mayAnimate(superview != nil)
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
required public init(frame frameRect: NSRect) {
fatalError("init(frame:) has not been implemented")
}
override public var frame: CGRect {
get {
return super.frame
} set (value) {
let redraw = value.size != self.frame.size
super.frame = value
if redraw {
self.setNeedsDisplay()
self.overlay.setNeedsDisplay()
self._layer.frame = CGRect(origin: CGPoint(), size: value.size)
}
}
}
private let _layer = SimpleLayer()
public init(theme: RadialProgressTheme = RadialProgressTheme(backgroundColor: .blackTransparent, foregroundColor: .white, icon: nil), twist: Bool = true, size: NSSize = NSMakeSize(40, 40)) {
self.theme = theme
self.overlay = RadialProgressOverlayLayer(theme: theme, twist: twist)
super.init()
self.frame = NSMakeRect(0, 0, size.width, size.height)
self.layer?.addSublayer(_layer)
overlay.display = { [weak self] in
self?._layer.setNeedsDisplay()
}
layer?.masksToBounds = false
_layer.masksToBounds = false
_layer.frame = self.bounds
_layer.onDraw = { [weak self] _, ctx in
self?.draw(context: ctx)
}
_layer.setNeedsDisplay()
}
public override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
}
func draw(context: CGContext) {
context.setFillColor(parameters.theme.backgroundColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: parameters.diameter, height: parameters.diameter)))
context.saveGState()
switch parameters.state {
case .None:
break
case .Success:
let diameter = bounds.size.width
let progress: CGFloat = 1.0
var pathLineWidth: CGFloat = 2.0
if (abs(diameter - 37.0) < 0.1) {
pathLineWidth = 2.5
} else if (abs(diameter - 32.0) < 0.1) {
pathLineWidth = 2.0
} else {
pathLineWidth = 2.5
}
let center = CGPoint(x: diameter / 2.0, y: diameter / 2.0)
context.setBlendMode(parameters.theme.blendMode)
context.setStrokeColor(parameters.theme.foregroundColor.cgColor)
context.setLineWidth(pathLineWidth)
context.setLineCap(.round)
context.setLineJoin(.round)
context.setMiterLimit(10.0)
let firstSegment: CGFloat = max(0.0, min(1.0, progress * 3.0))
var s = CGPoint(x: center.x - 10.0, y: center.y + 1.0)
var p1 = CGPoint(x: 7.0, y: 7.0)
var p2 = CGPoint(x: 15.0, y: -16.0)
if diameter < 36.0 {
s = CGPoint(x: center.x - 7.0, y: center.y + 1.0)
p1 = CGPoint(x: 4.5, y: 4.5)
p2 = CGPoint(x: 10.0, y: -11.0)
}
if !firstSegment.isZero {
if firstSegment < 1.0 {
context.move(to: CGPoint(x: s.x + p1.x * firstSegment, y: s.y + p1.y * firstSegment))
context.addLine(to: s)
} else {
let secondSegment = (progress - 0.33) * 1.5
context.move(to: CGPoint(x: s.x + p1.x + p2.x * secondSegment, y: s.y + p1.y + p2.y * secondSegment))
context.addLine(to: CGPoint(x: s.x + p1.x, y: s.y + p1.y))
context.addLine(to: s)
}
}
context.strokePath()
case .Fetching:
if let icon = parameters.theme.cancelFetchingIcon {
var f = focus(icon.backingSize)
f.origin.x += parameters.theme.iconInset.left
f.origin.x -= parameters.theme.iconInset.right
f.origin.y += parameters.theme.iconInset.top
f.origin.y -= parameters.theme.iconInset.bottom
if parameters.theme.blendMode == .clear {
context.clip(to: f, mask: icon)
context.clear(f)
} else {
context.setBlendMode(parameters.theme.blendMode)
context.draw(icon, in: f)
}
context.setBlendMode(parameters.theme.blendMode)
overlay.draw(in: context, diameter: parameters.diameter)
} else {
context.setBlendMode(parameters.theme.blendMode)
overlay.draw(in: context, diameter: parameters.diameter)
context.setStrokeColor(parameters.theme.foregroundColor.cgColor)
context.setLineWidth(2.0)
context.setLineCap(.round)
let crossSize: CGFloat = parameters.diameter < 40 ? 9 : 14.0
context.move(to: CGPoint(x: parameters.diameter / 2.0 - crossSize / 2.0, y: parameters.diameter / 2.0 - crossSize / 2.0))
context.addLine(to: CGPoint(x: parameters.diameter / 2.0 + crossSize / 2.0, y: parameters.diameter / 2.0 + crossSize / 2.0))
context.strokePath()
context.move(to: CGPoint(x: parameters.diameter / 2.0 + crossSize / 2.0, y: parameters.diameter / 2.0 - crossSize / 2.0))
context.addLine(to: CGPoint(x: parameters.diameter / 2.0 - crossSize / 2.0, y: parameters.diameter / 2.0 + crossSize / 2.0))
context.strokePath()
}
case .Remote:
let color = parameters.theme.foregroundColor
let diameter = _layer.frame.height
context.saveGState()
context.setBlendMode(parameters.theme.blendMode)
context.setStrokeColor(color.cgColor)
var lineWidth: CGFloat = 2.0
if diameter < 24.0 {
lineWidth = 1.3
}
context.setLineWidth(lineWidth)
context.setLineCap(.round)
context.setLineJoin(.round)
let factor = diameter / 50.0
let arrowHeadSize: CGFloat = 15.0 * factor
let arrowLength: CGFloat = 18.0 * factor
let arrowHeadOffset: CGFloat = 1.0 * factor
context.move(to: CGPoint(x: diameter / 2.0, y: diameter / 2.0 - arrowLength / 2.0 + arrowHeadOffset))
context.addLine(to: CGPoint(x: diameter / 2.0, y: diameter / 2.0 + arrowLength / 2.0 - 1.0 + arrowHeadOffset))
context.strokePath()
context.move(to: CGPoint(x: diameter / 2.0 - arrowHeadSize / 2.0, y: diameter / 2.0 + arrowLength / 2.0 - arrowHeadSize / 2.0 + arrowHeadOffset))
context.addLine(to: CGPoint(x: diameter / 2.0, y: diameter / 2.0 + arrowLength / 2.0 + arrowHeadOffset))
context.addLine(to: CGPoint(x: diameter / 2.0 + arrowHeadSize / 2.0, y: diameter / 2.0 + arrowLength / 2.0 - arrowHeadSize / 2.0 + arrowHeadOffset))
context.strokePath()
context.restoreGState()
case .Play:
let color = parameters.theme.foregroundColor
let diameter = _layer.frame.height
context.setFillColor(color.cgColor)
context.setBlendMode(parameters.theme.blendMode)
let factor = diameter / 50.0
let size = CGSize(width: 15.0, height: 18.0)
context.translateBy(x: (diameter - size.width) / 2.0 + 1.5, y: (diameter - size.height) / 2.0)
if (diameter < 40.0) {
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
context.scaleBy(x: factor, y: factor)
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
}
let _ = try? drawSvgPath(context, path: "M1.71891969,0.209353049 C0.769586558,-0.350676705 0,0.0908839327 0,1.18800046 L0,16.8564753 C0,17.9569971 0.750549162,18.357187 1.67393713,17.7519379 L14.1073836,9.60224049 C15.0318735,8.99626906 15.0094718,8.04970371 14.062401,7.49100858 L1.71891969,0.209353049 ")
context.fillPath()
if (diameter < 40.0) {
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
context.scaleBy(x: 1.0 / 0.8, y: 1.0 / 0.8)
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
}
context.translateBy(x: -(diameter - size.width) / 2.0 - 1.5, y: -(diameter - size.height) / 2.0)
case .ImpossibleFetching:
context.setBlendMode(parameters.theme.blendMode)
overlay.draw(in: context, diameter: parameters.diameter)
case let .Icon(icon):
var f = focus(icon.backingSize)
f.origin.x += parameters.theme.iconInset.left
f.origin.x -= parameters.theme.iconInset.right
f.origin.y += parameters.theme.iconInset.top
f.origin.y -= parameters.theme.iconInset.bottom
if parameters.theme.blendMode == .clear {
context.clip(to: f, mask: icon)
context.clear(f)
} else {
context.setBlendMode(parameters.theme.blendMode)
context.draw(icon, in: f)
}
}
context.restoreGState()
if let badge = badge {
context.saveGState()
let rect = badge
let path = CGMutablePath()
path.addEllipse(in: rect)
context.addPath(path)
context.clip()
context.clear(rect)
context.restoreGState()
}
}
public override func copy() -> Any {
let view = NSView()
view.wantsLayer = true
view.frame = self.frame
view.layer?.contents = progressInteractiveThumb(backgroundColor: parameters.theme.backgroundColor, foregroundColor: parameters.theme.foregroundColor)
return view
}
public override func apply(state: ControlState) {
}
}