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’s ProgressAnimation.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’s ProgressAnimation.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’s ProgressAnimation.swift.

NinjaProgressAnimation defines the three factories for the different terminal types and nothing else:

large-contents

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().

large-contents

which draws the same animation without clearing the line first.
This animation also ignores clear() calls.

large-contents

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:

large-contents

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’s ProgressAnimation.swift.

Identically to NinjaProgressAnimation, PercentProgressAnimation defines the three factories for different terminal types:

large-contents

as before, the animation clears and redraws itself.

large-contentslarge-contents

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
large-contents

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 place
  • SingleLine when the whole progress is "printed" in one line
  • MultiLine 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!

⭑⭑⭑⭑⭑

Further Reading

Explore Swift

Browse all