Files
container/Sources/TerminalProgress/ProgressBar.swift
T
Kathryn Baldauf 8e9670c8f8 Initial commit
Co-authored-by: Aditya Ramani <a_ramani@apple.com>
Co-authored-by: Agam Dua <agam_dua@apple.com>
Co-authored-by: Danny Canter <danny_canter@apple.com>
Co-authored-by: Dmitry Kovba <dkovba@apple.com>
Co-authored-by: Eric Ernst <eric_ernst@apple.com>
Co-authored-by: Evan Hazlett <ehazlett@apple.com>
Co-authored-by: Gilbert Song <gilbertsong@apple.com>
Co-authored-by: Hugh Bussell <hbussell@apple.com>
Co-authored-by: John Logan <john_logan@apple.com>
Co-authored-by: Kathryn Baldauf <k_baldauf@apple.com>
Co-authored-by: Madhu Venugopal <mvenugopal@apple.com>
Co-authored-by: Michael Crosby <michael_crosby@apple.com>
Co-authored-by: Sidhartha Mani <sidhartha_mani@apple.com>
Co-authored-by: Tanweer Noor <tnoor@apple.com>
Co-authored-by: Ximena Perez Diaz <xperez528@gmail.com>
Co-authored-by: Yibo Zhuang <yzhuang@apple.com>
2025-06-05 15:51:55 -07:00

274 lines
10 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 SendableProperty
/// A progress bar that updates itself as tasks are completed.
public final class ProgressBar: Sendable {
let config: ProgressConfig
@SendableProperty
var state: State
static let term: FileHandle? = getTerminal()
static let termQueue = DispatchQueue(label: "com.apple.container.ProgressBar")
private let standardError = StandardError()
/// Creates a new progress bar.
/// - Parameter config: The configuration for the progress bar.
public init(config: ProgressConfig) {
self.config = config
state = State(
description: config.initialDescription, itemsName: config.initialItemsName, totalTasks: config.initialTotalTasks,
totalItems: config.initialTotalItems,
totalSize: config.initialTotalSize)
ProgressBar.display(EscapeSequence.hideCursor)
}
deinit {
ProgressBar.clear()
}
/// Allows resetting the progress state.
public func reset() {
state = State(description: config.initialDescription)
}
/// Allows resetting the progress state of the current task.
public func resetCurrentTask() {
ProgressBar.clear()
state = State(description: state.description, itemsName: state.itemsName, tasks: state.tasks, totalTasks: state.totalTasks, startTime: state.startTime)
}
/// Returns `true` if the progress bar has finished.
public func isFinished() -> Bool {
state.finished
}
private func printFullDescription() {
if state.subDescription != "" {
standardError.write("\(state.description) \(state.subDescription)")
} else {
standardError.write(state.description)
}
}
/// 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.description = description
state.subDescription = ""
if config.disableProgressUpdates {
printFullDescription()
}
state.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.subDescription = subDescription
if config.disableProgressUpdates {
printFullDescription()
}
}
private func start(intervalSeconds: TimeInterval) async {
if config.disableProgressUpdates && !state.description.isEmpty {
printFullDescription()
}
while !self.isFinished() {
let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000)
render()
state.iteration += 1
if (try? await Task.sleep(nanoseconds: intervalNanoseconds)) == nil {
return
}
}
}
/// Starts an animation of the progress bar.
/// - Parameter intervalSeconds: The time interval between updates in seconds.
public func start(intervalSeconds: TimeInterval = 0.04) {
Task(priority: .utility) {
await start(intervalSeconds: intervalSeconds)
}
}
/// Finishes the progress bar.
public func finish() {
guard !self.isFinished() else {
return
}
state.finished = true
if !config.disableProgressUpdates && !config.clearOnFinish {
displayText(state.output, terminating: "\n")
}
if config.clearOnFinish {
ProgressBar.clearAndResetCursor()
} else {
ProgressBar.resetCursor()
}
}
}
extension ProgressBar {
private func secondsSinceStart() -> Int {
let timeDifferenceNanoseconds = DispatchTime.now().uptimeNanoseconds - state.startTime.uptimeNanoseconds
let timeDifferenceSeconds = Int(floor(Double(timeDifferenceNanoseconds) / 1_000_000_000))
return timeDifferenceSeconds
}
func render() {
guard ProgressBar.term != nil && !config.disableProgressUpdates else {
return
}
let output = draw()
displayText(output)
}
func draw() -> String {
var components = [String]()
if config.showSpinner && !config.showProgressBar {
let spinnerIcon = config.theme.getSpinnerIcon(state.iteration)
components.append("\(spinnerIcon)")
}
if config.showTasks, let totalTasks = state.totalTasks {
let tasks = min(state.tasks, totalTasks)
components.append("[\(tasks)/\(totalTasks)]")
}
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)
if config.showPercent && total > 0 && allowProgress {
components.append("\(state.percent)")
}
if config.showProgressBar, total > 0, allowProgress {
let usedWidth = components.joined(separator: " ").count + 45 /* the maximum number of characters we may need */
let remainingWidth = max(config.width - usedWidth, 1 /* the minumum width of a progress bar */)
let barLength = 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)|")
}
var additionalComponents = [String]()
if config.showItems, state.items > 0 {
var itemsName = ""
if !state.itemsName.isEmpty {
itemsName = " \(state.itemsName)"
}
if let totalItems = state.totalItems {
additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)")
} else {
additionalComponents.append("\(state.items.formattedNumber())\(itemsName)")
}
}
if state.size > 0 && allowProgress {
var formattedCombinedSize = ""
if 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
}
}
var formattedSpeed = ""
if config.showSpeed {
formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)"
formattedSpeed = adjustFormattedSize(formattedSpeed)
}
if config.showSize && config.showSpeed {
additionalComponents.append(formattedCombinedSize)
additionalComponents.append(formattedSpeed)
} else if config.showSize {
additionalComponents.append(formattedCombinedSize)
} else if config.showSpeed {
additionalComponents.append(formattedSpeed)
}
}
if additionalComponents.count > 0 {
let joinedAdditionalComponents = additionalComponents.joined(separator: ", ")
components.append("(\(joinedAdditionalComponents))")
}
if config.showTime {
let timeDifferenceSeconds = secondsSinceStart()
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()
guard !size.contains("."), 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: ".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)"
}
}