Swift Executables Progress State ⏳
In the Swift Executables Guide we've seen how, among other things, we can use TSCUtility
to show progress to our users while our script is busy.
Since the outcome of these APIs are very visual, in this new article let's cover all TSCUtility
animations!
ProgressAnimationProtocol
When looking at TSCUtility
's ProgressAnimation.swift
file, the first thing that pops up is that all animations conform to the ProgressAnimationProtocol
protocol, here's its definition:
/// A protocol to operate on terminal based progress animations.
public protocol ProgressAnimationProtocol {
/// Update the animation with a new step.
/// - Parameters:
/// - step: The index of the operation's current step.
/// - total: The total number of steps before the operation is complete.
/// - text: The description of the current step.
func update(step: Int, total: Int, text: String)
/// Complete the animation.
/// - Parameters:
/// - success: Defines if the operation the animation represents was succesful.
func complete(success: Bool)
/// Clear the animation.
func clear()
}
Snippet from
TSCUtility
’sProgressAnimation.swift
.
Every animation is responsible to take care of any interpolation needed, and correctly display our progress (by means of progress percentage, or else).
To start an animation, we call update(step:total:text:)
, and we keep doing so until the job has been completed, which is when we let the animation know via complete(success:)
.
Lastly, the ProgressAnimationProtocol
requires a clear
function, which tells the animation to remove itself, allowing the terminal to proceed as if the animation was never shown.
Animations are just "print" statements: there's no requirement on the order of the update
/complete
/clear
calls, it's entirely up to us.
DynamicProgressAnimation
Different terminals types support different control codes. Some animations are possible only if their control codes are available (e.g. to control the terminal cursor position and clear terminal lines).
In order to support all terminal types, TSCUtility
defines DynamicProgressAnimation
, which selects a different animation based on the terminal capability:
/// A progress animation that adapts to the provided output stream.
public class DynamicProgressAnimation: ProgressAnimationProtocol {
private let animation: ProgressAnimationProtocol
public init(
stream: OutputByteStream,
ttyTerminalAnimationFactory: (TerminalController) -> ProgressAnimationProtocol,
dumbTerminalAnimationFactory: () -> ProgressAnimationProtocol,
defaultAnimationFactory: () -> ProgressAnimationProtocol
) {
if let terminal = TerminalController(stream: stream) {
animation = ttyTerminalAnimationFactory(terminal)
} else if let fileStream = stream as? LocalFileOutputByteStream,
TerminalController.terminalType(fileStream) == .dumb {
animation = dumbTerminalAnimationFactory()
} else {
animation = defaultAnimationFactory()
}
}
public func update(step: Int, total: Int, text: String) {
animation.update(step: step, total: total, text: text)
}
public func complete(success: Bool) {
animation.complete(success: success)
}
public func clear() {
animation.clear()
}
}
Snippet from
TSCUtility
’sProgressAnimation.swift
.
DynamicProgressAnimation
takes in three animation factories and uses one of them depending on the given stream
, which is an object conforming to the OutputByteStream
protocol, used to manage different output destinations.
TSCUtility
provides two DynamicProgressAnimation
subclasses ready for us to use, NinjaProgressAnimation
and PercentProgressAnimation
.
NinjaProgressAnimation
/// A ninja-like progress animation that adapts to the provided output stream.
public final class NinjaProgressAnimation: DynamicProgressAnimation {
public init(stream: OutputByteStream) {
super.init(
stream: stream,
ttyTerminalAnimationFactory: { RedrawingNinjaProgressAnimation(terminal: $0) },
dumbTerminalAnimationFactory: { SingleLinePercentProgressAnimation(stream: stream, header: nil) },
defaultAnimationFactory: { MultiLineNinjaProgressAnimation(stream: stream) }
)
}
}
Snippet from
TSCUtility
’sProgressAnimation.swift
.
NinjaProgressAnimation
defines the three factories for the different terminal types and nothing else:
- in a tty terminal we have
RedrawingNinjaProgressAnimation
:
the animation clears itself at every update and "redraws" the new state on the same line.
This animation commit its line if we call complete(:)
on it, or it removes itself if we call clear()
.
- in a dumb terminal we have
SingleLinePercentProgressAnimation
:
which draws the same animation without clearing the line first.
This animation also ignores clear()
calls.
- lastly, we have a default animation when our stream doesn't fit the cases above,
MultiLineNinjaProgressAnimation
:
This is a middle ground between the previous two animations, where this new animation has the capability to return and create a new line. However no clear capabilities are used, which means that also in this case clear()
calls are ignored.
If this animation looks familiar, it's because it is! For example, we use it every time a package needs to resolve, download, and compile any package dependency:
You can see the
RedrawingNinjaProgressAnimation
in the line before the last one in the terminal.
Here's the code to test the animation:
import Darwin
import TSCBasic
import TSCUtility
let animation = NinjaProgressAnimation(stream: stdoutStream)
for i in 0..<100 {
let second: Double = 1_000_000
usleep(UInt32(second * 0.05))
animation.update(step: i, total: 100, text: "Loading..")
}
animation.complete(success: true) // or animation.clear()
Create a new swift executable and replace the
main.swift
with the code above.
Why Ninja? I guess this refers to the
[currentStep/totalSteps]
pattern... The forward slash/
looks like a sword, right? 😅
Update: Ankit Aggarwal from the Swift team pointed out that this animation is based on the same animation from the ninja build tool (you can see it mentioned here), hence the name. Thank you Ankit!
PercentProgressAnimation
/// A percent-based progress animation that adapts to the provided output stream.
public final class PercentProgressAnimation: DynamicProgressAnimation {
public init(stream: OutputByteStream, header: String) {
super.init(
stream: stream,
ttyTerminalAnimationFactory: { RedrawingLitProgressAnimation(terminal: $0, header: header) },
dumbTerminalAnimationFactory: { SingleLinePercentProgressAnimation(stream: stream, header: header) },
defaultAnimationFactory: { MultiLinePercentProgressAnimation(stream: stream, header: header) })
}
}
Snippet from
TSCUtility
’sProgressAnimation.swift
.
Identically to NinjaProgressAnimation
, PercentProgressAnimation
defines the three factories for different terminal types:
- in a tty terminal we have
RedrawingLitProgressAnimation
:
as before, the animation clears and redraws itself.
- in a dumb terminal we get
SingleLinePercentProgressAnimation
, which is the same as we've seen inNinjaProgressAnimation
, plus a header:
- lastly, we have
MultiLinePercentProgressAnimation
for the default animation:
A way to see this animation in the wild is by using the swift test
tool, the progress animation will be shown when we do parallel testing and a special environment variable is set.
Here's an example of me running the FunctionalTests
in the swift-package-manager
:
SWIFTPM_TEST_RUNNER_PROGRESS_BAR=lit swift test --filter FunctionalTests --parallel
Here's the code to test this animation:
import Darwin
import TSCBasic
import TSCUtility
let animation = PercentProgressAnimation(stream: stdoutStream, header: "Five Stars")
for i in 0..<100 {
let second: Double = 1_000_000
usleep(UInt32(second * 0.05))
animation.update(step: i, total: 100, text: "Loading..")
}
animation.complete(success: true) // or animation.clear()
Create a new swift executable and replace the
main.swift
with the code above.
Conclusions
In this article we've seen how progress animations are defined in TSCUtility
, how different terminals require different animations, and how TSCUtility
standardizes them by defining three types of animations:
Redrawing
when the animation can redraw itself in placeSingleLine
when the whole progress is "printed" in one lineMultiLine
when we print the progress state in a new line at every update.
If you need to show progress to in your scripts, you now know where to look!
As always, any feedback and insight from your side is more than welcome.
Thank you for reading and stay tuned for more articles!