Files
container/Sources/TerminalProgress/ProgressBar.swift
T
Danny Canter b671690c17 ProgressBar: Various fixes (#1025)
There's a couple things I don't think are intuitive about this.

1. Because of the internal task, render() can still be called even after
finish() completes. Ideally async defers are supported and we could just
await the final render completing after cancelling the task and setting
.finished, but alas. To fix this we can just lock across the methods for
now.
2. We always clear the screen in the destructor, even if we don't use
the
progress bar. I don't think we should honestly do anything in the
destructor.
Feels a programmer error not to defer { bar.finish() } or call it
somewhere.
3. Our spaces based line clearing. Use the ansi escape sequence for
clearing line;
I think our calculations were slightly off and it would leave trailing
output ( "s]" )
in some cases.
4. Shrinking the window until the output is smaller than the terminal
window (and vice
versa) is wonky on various term emulators. Truthfully, this is just a
hard problem,
but we can truncate our output and still provide some useful info.

This fixes some single line output (cat /etc/hostname etc.) getting
cleared in our atexit handler, as well as the need for the usleep.
2026-01-07 21:01:10 -08:00

363 lines
14 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Foundation
import Synchronization
/// A progress bar that updates itself as tasks are completed.
public final class ProgressBar: Sendable {
let config: ProgressConfig
let state: Mutex<State>
let term: FileHandle?
let termQueue = DispatchQueue(label: "com.apple.container.ProgressBar")
/// Returns `true` if the progress bar has finished.
public var isFinished: Bool {
state.withLock { $0.finished }
}
/// Creates a new progress bar.
/// - Parameter config: The configuration for the progress bar.
public init(config: ProgressConfig) {
self.config = config
term = isatty(config.terminal.fileDescriptor) == 1 ? config.terminal : nil
let state = State(
description: config.initialDescription, itemsName: config.initialItemsName, totalTasks: config.initialTotalTasks,
totalItems: config.initialTotalItems,
totalSize: config.initialTotalSize)
self.state = Mutex(state)
display(EscapeSequence.hideCursor)
}
/// Allows resetting the progress state.
public func reset() {
state.withLock {
$0 = State(description: config.initialDescription)
}
}
/// Allows resetting the progress state of the current task.
public func resetCurrentTask() {
state.withLock {
$0 = State(description: $0.description, itemsName: $0.itemsName, tasks: $0.tasks, totalTasks: $0.totalTasks, startTime: $0.startTime)
}
}
/// Updates the description of the progress bar and increments the tasks by one.
/// - Parameter description: The description of the action being performed.
public func set(description: String) {
resetCurrentTask()
state.withLock {
$0.description = description
$0.subDescription = ""
$0.tasks += 1
}
}
/// Updates the additional description of the progress bar.
/// - Parameter subDescription: The additional description of the action being performed.
public func set(subDescription: String) {
resetCurrentTask()
state.withLock { $0.subDescription = subDescription }
}
private func start(intervalSeconds: TimeInterval) async {
while true {
let done = state.withLock { s -> Bool in
guard !s.finished else {
return true
}
render(state: &s)
s.iteration += 1
return false
}
if done {
return
}
let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000)
guard (try? await Task.sleep(nanoseconds: intervalNanoseconds)) != nil else {
return
}
}
}
/// Starts an animation of the progress bar.
/// - Parameter intervalSeconds: The time interval between updates in seconds.
public func start(intervalSeconds: TimeInterval = 0.04) {
state.withLock {
if $0.renderTask != nil {
return
}
$0.renderTask = Task(priority: .utility) {
await start(intervalSeconds: intervalSeconds)
}
}
}
/// Finishes the progress bar.
/// - Parameter clearScreen: If true, clears the progress bar from the screen.
public func finish(clearScreen: Bool = false) {
state.withLock { s in
guard !s.finished else { return }
s.finished = true
s.renderTask?.cancel()
let shouldClear = clearScreen || config.clearOnFinish
if !config.disableProgressUpdates && !shouldClear {
let output = draw(state: s)
displayText(output, state: &s, terminating: "\n")
}
if shouldClear {
clear(state: &s)
}
resetCursor()
}
}
}
extension ProgressBar {
private func secondsSinceStart(from startTime: DispatchTime) -> Int {
let timeDifferenceNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds
let timeDifferenceSeconds = Int(floor(Double(timeDifferenceNanoseconds) / 1_000_000_000))
return timeDifferenceSeconds
}
func render(force: Bool = false) {
guard term != nil && !config.disableProgressUpdates else {
return
}
state.withLock { s in
render(state: &s, force: force)
}
}
func render(state: inout State, force: Bool = false) {
guard term != nil && !config.disableProgressUpdates else {
return
}
guard force || !state.finished else {
return
}
let output = draw(state: state)
displayText(output, state: &state)
}
/// Detail levels for progressive truncation.
enum DetailLevel: Int, CaseIterable {
case full = 0 // Everything shown
case noSpeed // Drop speed from parens
case noSize // Drop size from parens
case noParens // Drop parens entirely (items, size, speed)
case noTime // Drop time
case noDescription // Drop description/subdescription
case minimal // Just spinner, tasks, percent
}
func draw(state: State) -> String {
let width = termWidth
// If no terminal or width unknown, use full detail
guard width > 0 else {
return draw(state: state, detail: .full)
}
// Add a small buffer to prevent wrapping issues during resize
let bufferChars = 4
let targetWidth = max(1, width - bufferChars)
for detail in DetailLevel.allCases {
let output = draw(state: state, detail: detail)
if output.count <= targetWidth {
return output
}
}
return draw(state: state, detail: .minimal)
}
func draw(state: State, detail: DetailLevel) -> String {
var components = [String]()
// Spinner - always shown if configured (unless using progress bar)
if config.showSpinner && !config.showProgressBar {
if !state.finished {
let spinnerIcon = config.theme.getSpinnerIcon(state.iteration)
components.append("\(spinnerIcon)")
} else {
components.append("\(config.theme.done)")
}
}
// Tasks [x/y] - always shown if configured
if config.showTasks, let totalTasks = state.totalTasks {
let tasks = min(state.tasks, totalTasks)
components.append("[\(tasks)/\(totalTasks)]")
}
// Description - dropped at noDescription level
if detail.rawValue < DetailLevel.noDescription.rawValue {
if config.showDescription && !state.description.isEmpty {
components.append("\(state.description)")
if !state.subDescription.isEmpty {
components.append("\(state.subDescription)")
}
}
}
let allowProgress = !config.ignoreSmallSize || state.totalSize == nil || state.totalSize! > Int64(1024 * 1024)
let value = state.totalSize != nil ? state.size : Int64(state.items)
let total = state.totalSize ?? Int64(state.totalItems ?? 0)
// Percent - always shown if configured
if config.showPercent && total > 0 && allowProgress {
components.append("\(state.finished ? "100%" : state.percent)")
}
// Progress bar - always shown if configured
if config.showProgressBar, total > 0, allowProgress {
let usedWidth = components.joined(separator: " ").count + 45
let remainingWidth = max(config.width - usedWidth, 1)
let barLength = state.finished ? remainingWidth : Int(Int64(remainingWidth) * value / total)
let barPaddingLength = remainingWidth - barLength
let bar = "\(String(repeating: config.theme.bar, count: barLength))\(String(repeating: " ", count: barPaddingLength))"
components.append("|\(bar)|")
}
// Additional components in parens - progressively dropped
if detail.rawValue < DetailLevel.noParens.rawValue {
var additionalComponents = [String]()
// Items - dropped at noParens level
if config.showItems, state.items > 0 {
var itemsName = ""
if !state.itemsName.isEmpty {
itemsName = " \(state.itemsName)"
}
if state.finished {
if let totalItems = state.totalItems {
additionalComponents.append("\(totalItems.formattedNumber())\(itemsName)")
}
} else {
if let totalItems = state.totalItems {
additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)")
} else {
additionalComponents.append("\(state.items.formattedNumber())\(itemsName)")
}
}
}
// Size and speed - progressively dropped
if state.size > 0 && allowProgress {
if state.finished {
// Size - dropped at noSize level
if detail.rawValue < DetailLevel.noSize.rawValue {
if config.showSize {
if let totalSize = state.totalSize {
var formattedTotalSize = totalSize.formattedSize()
formattedTotalSize = adjustFormattedSize(formattedTotalSize)
additionalComponents.append(formattedTotalSize)
}
}
}
} else {
// Size - dropped at noSize level
var formattedCombinedSize = ""
if detail.rawValue < DetailLevel.noSize.rawValue && config.showSize {
var formattedSize = state.size.formattedSize()
formattedSize = adjustFormattedSize(formattedSize)
if let totalSize = state.totalSize {
var formattedTotalSize = totalSize.formattedSize()
formattedTotalSize = adjustFormattedSize(formattedTotalSize)
formattedCombinedSize = combineSize(size: formattedSize, totalSize: formattedTotalSize)
} else {
formattedCombinedSize = formattedSize
}
}
// Speed - dropped at noSpeed level
var formattedSpeed = ""
if detail.rawValue < DetailLevel.noSpeed.rawValue && config.showSpeed {
formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)"
formattedSpeed = adjustFormattedSize(formattedSpeed)
}
if !formattedCombinedSize.isEmpty && !formattedSpeed.isEmpty {
additionalComponents.append(formattedCombinedSize)
additionalComponents.append(formattedSpeed)
} else if !formattedCombinedSize.isEmpty {
additionalComponents.append(formattedCombinedSize)
} else if !formattedSpeed.isEmpty {
additionalComponents.append(formattedSpeed)
}
}
}
if additionalComponents.count > 0 {
let joinedAdditionalComponents = additionalComponents.joined(separator: ", ")
components.append("(\(joinedAdditionalComponents))")
}
}
// Time - dropped at noTime level
if detail.rawValue < DetailLevel.noTime.rawValue && config.showTime {
let timeDifferenceSeconds = secondsSinceStart(from: state.startTime)
let formattedTime = timeDifferenceSeconds.formattedTime()
components.append("[\(formattedTime)]")
}
return components.joined(separator: " ")
}
private func adjustFormattedSize(_ size: String) -> String {
// Ensure we always have one digit after the decimal point to prevent flickering.
let zero = Int64(0).formattedSize()
let decimalSep = Locale.current.decimalSeparator ?? "."
guard !size.contains(decimalSep), let first = size.first, first.isNumber || !size.contains(zero) else {
return size
}
var size = size
for unit in ["MB", "GB", "TB"] {
size = size.replacingOccurrences(of: " \(unit)", with: "\(decimalSep)0 \(unit)")
}
return size
}
private func combineSize(size: String, totalSize: String) -> String {
let sizeComponents = size.split(separator: " ", maxSplits: 1)
let totalSizeComponents = totalSize.split(separator: " ", maxSplits: 1)
guard sizeComponents.count == 2, totalSizeComponents.count == 2 else {
return "\(size)/\(totalSize)"
}
let sizeNumber = sizeComponents[0]
let sizeUnit = sizeComponents[1]
let totalSizeNumber = totalSizeComponents[0]
let totalSizeUnit = totalSizeComponents[1]
guard sizeUnit == totalSizeUnit else {
return "\(size)/\(totalSize)"
}
return "\(sizeNumber)/\(totalSizeNumber) \(totalSizeUnit)"
}
func draw() -> String {
state.withLock { draw(state: $0) }
}
}