Skip to content

Commit cfa1bc1

Browse files
committed
Refactor ProgressAnimation code
SwiftPM uses ProgressAnimation from TSCUtilities in a variety of inconsistent manners across various swift-something commands. This commit replaces the uses of ProgressAnimation throughout SwiftPM with common entrypoints and lays the ground work for creating multi-line parallel progress animations as seen in tools like `bazel build` and `docker build`.
1 parent 6bcbf11 commit cfa1bc1

16 files changed

+402
-56
lines changed

Sources/Basics/CMakeLists.txt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ add_library(Basics
5151
Netrc.swift
5252
Observability.swift
5353
OSSignpost.swift
54-
ProgressAnimation.swift
54+
ProgressAnimation/NinjaProgressAnimation.swift
55+
ProgressAnimation/PercentProgressAnimation.swift
56+
ProgressAnimation/ProgressAnimationProtocol.swift
57+
ProgressAnimation/SingleLinePercentProgressAnimation.swift
58+
ProgressAnimation/ThrottledProgressAnimation.swift
5559
SQLite.swift
5660
Sandbox.swift
5761
SendableTimeInterval.swift
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import class TSCBasic.TerminalController
14+
import protocol TSCBasic.WritableByteStream
15+
16+
extension ProgressAnimation {
17+
/// A ninja-like progress animation that adapts to the provided output stream.
18+
@_spi(SwiftPMInternal_ProgressAnimation)
19+
public static func ninja(
20+
stream: WritableByteStream,
21+
verbose: Bool
22+
) -> any ProgressAnimationProtocol {
23+
Self.dynamic(
24+
stream: stream,
25+
verbose: verbose,
26+
ttyTerminalAnimationFactory: { RedrawingNinjaProgressAnimation(terminal: $0) },
27+
dumbTerminalAnimationFactory: { SingleLinePercentProgressAnimation(stream: stream, header: nil) },
28+
defaultAnimationFactory: { MultiLineNinjaProgressAnimation(stream: stream) })
29+
}
30+
}
31+
32+
/// A redrawing ninja-like progress animation.
33+
final class RedrawingNinjaProgressAnimation: ProgressAnimationProtocol {
34+
private let terminal: TerminalController
35+
private var hasDisplayedProgress = false
36+
37+
init(terminal: TerminalController) {
38+
self.terminal = terminal
39+
}
40+
41+
func update(step: Int, total: Int, text: String) {
42+
assert(step <= total)
43+
44+
terminal.clearLine()
45+
46+
let progressText = "[\(step)/\(total)] \(text)"
47+
let width = terminal.width
48+
if progressText.utf8.count > width {
49+
let suffix = ""
50+
terminal.write(String(progressText.prefix(width - suffix.utf8.count)))
51+
terminal.write(suffix)
52+
} else {
53+
terminal.write(progressText)
54+
}
55+
56+
hasDisplayedProgress = true
57+
}
58+
59+
func complete(success: Bool) {
60+
if hasDisplayedProgress {
61+
terminal.endLine()
62+
}
63+
}
64+
65+
func clear() {
66+
terminal.clearLine()
67+
}
68+
}
69+
70+
/// A multi-line ninja-like progress animation.
71+
final class MultiLineNinjaProgressAnimation: ProgressAnimationProtocol {
72+
private struct Info: Equatable {
73+
let step: Int
74+
let total: Int
75+
let text: String
76+
}
77+
78+
private let stream: WritableByteStream
79+
private var lastDisplayedText: String? = nil
80+
81+
init(stream: WritableByteStream) {
82+
self.stream = stream
83+
}
84+
85+
func update(step: Int, total: Int, text: String) {
86+
assert(step <= total)
87+
88+
guard text != lastDisplayedText else { return }
89+
90+
stream.send("[\(step)/\(total)] ").send(text)
91+
stream.send("\n")
92+
stream.flush()
93+
lastDisplayedText = text
94+
}
95+
96+
func complete(success: Bool) {
97+
}
98+
99+
func clear() {
100+
}
101+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import class TSCBasic.TerminalController
14+
import protocol TSCBasic.WritableByteStream
15+
16+
extension ProgressAnimation {
17+
/// A percent-based progress animation that adapts to the provided output stream.
18+
@_spi(SwiftPMInternal_ProgressAnimation)
19+
public static func percent(
20+
stream: WritableByteStream,
21+
verbose: Bool,
22+
header: String
23+
) -> any ProgressAnimationProtocol {
24+
Self.dynamic(
25+
stream: stream,
26+
verbose: verbose,
27+
ttyTerminalAnimationFactory: { RedrawingPercentProgressAnimation(terminal: $0, header: header) },
28+
dumbTerminalAnimationFactory: { SingleLinePercentProgressAnimation(stream: stream, header: header) },
29+
defaultAnimationFactory: { MultiLinePercentProgressAnimation(stream: stream, header: header) })
30+
}
31+
}
32+
33+
/// A redrawing lit-like progress animation.
34+
final class RedrawingPercentProgressAnimation: ProgressAnimationProtocol {
35+
private let terminal: TerminalController
36+
private let header: String
37+
private var hasDisplayedHeader = false
38+
39+
init(terminal: TerminalController, header: String) {
40+
self.terminal = terminal
41+
self.header = header
42+
}
43+
44+
/// Creates repeating string for count times.
45+
/// If count is negative, returns empty string.
46+
private func repeating(string: String, count: Int) -> String {
47+
return String(repeating: string, count: max(count, 0))
48+
}
49+
50+
func update(step: Int, total: Int, text: String) {
51+
assert(step <= total)
52+
53+
let width = terminal.width
54+
if !hasDisplayedHeader {
55+
let spaceCount = width / 2 - header.utf8.count / 2
56+
terminal.write(repeating(string: " ", count: spaceCount))
57+
terminal.write(header, inColor: .cyan, bold: true)
58+
terminal.endLine()
59+
hasDisplayedHeader = true
60+
} else {
61+
terminal.moveCursor(up: 1)
62+
}
63+
64+
terminal.clearLine()
65+
let percentage = step * 100 / total
66+
let paddedPercentage = percentage < 10 ? " \(percentage)" : "\(percentage)"
67+
let prefix = "\(paddedPercentage)% " + terminal.wrap("[", inColor: .green, bold: true)
68+
terminal.write(prefix)
69+
70+
let barWidth = width - prefix.utf8.count
71+
let n = Int(Double(barWidth) * Double(percentage) / 100.0)
72+
73+
terminal.write(repeating(string: "=", count: n) + repeating(string: "-", count: barWidth - n), inColor: .green)
74+
terminal.write("]", inColor: .green, bold: true)
75+
terminal.endLine()
76+
77+
terminal.clearLine()
78+
if text.utf8.count > width {
79+
let prefix = ""
80+
terminal.write(prefix)
81+
terminal.write(String(text.suffix(width - prefix.utf8.count)))
82+
} else {
83+
terminal.write(text)
84+
}
85+
}
86+
87+
func complete(success: Bool) {
88+
terminal.endLine()
89+
terminal.endLine()
90+
}
91+
92+
func clear() {
93+
terminal.clearLine()
94+
terminal.moveCursor(up: 1)
95+
terminal.clearLine()
96+
}
97+
}
98+
99+
/// A multi-line percent-based progress animation.
100+
final class MultiLinePercentProgressAnimation: ProgressAnimationProtocol {
101+
private struct Info: Equatable {
102+
let percentage: Int
103+
let text: String
104+
}
105+
106+
private let stream: WritableByteStream
107+
private let header: String
108+
private var hasDisplayedHeader = false
109+
private var lastDisplayedText: String? = nil
110+
111+
init(stream: WritableByteStream, header: String) {
112+
self.stream = stream
113+
self.header = header
114+
}
115+
116+
func update(step: Int, total: Int, text: String) {
117+
assert(step <= total)
118+
119+
if !hasDisplayedHeader, !header.isEmpty {
120+
stream.send(header)
121+
stream.send("\n")
122+
stream.flush()
123+
hasDisplayedHeader = true
124+
}
125+
126+
let percentage = step * 100 / total
127+
stream.send("\(percentage)%: ").send(text)
128+
stream.send("\n")
129+
stream.flush()
130+
lastDisplayedText = text
131+
}
132+
133+
func complete(success: Bool) {
134+
}
135+
136+
func clear() {
137+
}
138+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import class TSCBasic.TerminalController
14+
import class TSCBasic.LocalFileOutputByteStream
15+
import protocol TSCBasic.WritableByteStream
16+
import protocol TSCUtility.ProgressAnimationProtocol
17+
18+
@_spi(SwiftPMInternal_ProgressAnimation)
19+
public typealias ProgressAnimationProtocol = TSCUtility.ProgressAnimationProtocol
20+
21+
/// Namespace to nest public progress animations under.
22+
@_spi(SwiftPMInternal_ProgressAnimation)
23+
public enum ProgressAnimation {
24+
static func dynamic(
25+
stream: WritableByteStream,
26+
verbose: Bool,
27+
ttyTerminalAnimationFactory: (TerminalController) -> any ProgressAnimationProtocol,
28+
dumbTerminalAnimationFactory: () -> any ProgressAnimationProtocol,
29+
defaultAnimationFactory: () -> any ProgressAnimationProtocol
30+
) -> any ProgressAnimationProtocol {
31+
if let terminal = TerminalController(stream: stream), !verbose {
32+
return ttyTerminalAnimationFactory(terminal)
33+
} else if let fileStream = stream as? LocalFileOutputByteStream,
34+
TerminalController.terminalType(fileStream) == .dumb
35+
{
36+
return dumbTerminalAnimationFactory()
37+
} else {
38+
return defaultAnimationFactory()
39+
}
40+
}
41+
}
42+
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import class TSCBasic.TerminalController
14+
import protocol TSCBasic.WritableByteStream
15+
16+
/// A single line percent-based progress animation.
17+
final class SingleLinePercentProgressAnimation: ProgressAnimationProtocol {
18+
private let stream: WritableByteStream
19+
private let header: String?
20+
private var displayedPercentages: Set<Int> = []
21+
private var hasDisplayedHeader = false
22+
23+
init(stream: WritableByteStream, header: String?) {
24+
self.stream = stream
25+
self.header = header
26+
}
27+
28+
func update(step: Int, total: Int, text: String) {
29+
if let header = header, !hasDisplayedHeader {
30+
stream.send(header)
31+
stream.send("\n")
32+
stream.flush()
33+
hasDisplayedHeader = true
34+
}
35+
36+
let percentage = step * 100 / total
37+
let roundedPercentage = Int(Double(percentage / 10).rounded(.down)) * 10
38+
if percentage != 100, !displayedPercentages.contains(roundedPercentage) {
39+
stream.send(String(roundedPercentage)).send(".. ")
40+
displayedPercentages.insert(roundedPercentage)
41+
}
42+
43+
stream.flush()
44+
}
45+
46+
func complete(success: Bool) {
47+
if success {
48+
stream.send("OK")
49+
stream.flush()
50+
}
51+
}
52+
53+
func clear() {
54+
}
55+
}

0 commit comments

Comments
 (0)