//===----------------------------------------------------------------------===// // 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 Synchronization /// A progress bar that updates itself as tasks are completed. public final class ProgressBar: Sendable { let config: ProgressConfig let state: Mutex let printedWidth = Mutex(0) let term: FileHandle? let termQueue = DispatchQueue(label: "com.apple.container.ProgressBar") private let standardError = StandardError() /// 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) } deinit { clear() } /// 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 !state.withLock({ $0.finished }) { let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000) render() state.withLock { $0.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 !state.withLock({ $0.finished }) else { return } state.withLock { $0.finished = true } // The last render. render(force: true) if !config.disableProgressUpdates && !config.clearOnFinish { displayText(state.withLock { $0.output }, terminating: "\n") } if config.clearOnFinish { clearAndResetCursor() } else { resetCursor() } // Allow printed output to flush. usleep(100_000) } } extension ProgressBar { private func secondsSinceStart() -> Int { let timeDifferenceNanoseconds = DispatchTime.now().uptimeNanoseconds - state.withLock { $0.startTime.uptimeNanoseconds } let timeDifferenceSeconds = Int(floor(Double(timeDifferenceNanoseconds) / 1_000_000_000)) return timeDifferenceSeconds } func render(force: Bool = false) { guard term != nil && !config.disableProgressUpdates && (force || !state.withLock { $0.finished }) else { return } let output = draw() displayText(output) } func draw() -> String { let state = self.state.withLock { $0 } var components = [String]() if config.showSpinner && !config.showProgressBar { if !state.finished { let spinnerIcon = config.theme.getSpinnerIcon(state.iteration) components.append("\(spinnerIcon)") } else { components.append("\(config.theme.done)") } } 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.finished ? "100%" : 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 minimum width of a progress bar */) 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)|") } var additionalComponents = [String]() 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)") } } } if state.size > 0 && allowProgress { if state.finished { if config.showSize { if let totalSize = state.totalSize { var formattedTotalSize = totalSize.formattedSize() formattedTotalSize = adjustFormattedSize(formattedTotalSize) additionalComponents.append(formattedTotalSize) } } } else { 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)" } }