//===----------------------------------------------------------------------===// // 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 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 switch config.outputMode { case .ansi, .color: term = isatty(config.terminal.fileDescriptor) == 1 ? config.terminal : nil case .plain: term = config.terminal } let state = State( description: config.initialDescription, itemsName: config.initialItemsName, totalTasks: config.initialTotalTasks, totalItems: config.initialTotalItems, totalSize: config.initialTotalSize) self.state = Mutex(state) switch config.outputMode { case .ansi, .color: display(EscapeSequence.hideCursor) case .plain: break } } /// 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) } switch config.outputMode { case .ansi, .color: resetCursor() case .plain: break } } } } 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 } if config.outputMode == .plain && !force { let now = DispatchTime.now() if let lastRender = state.lastPlainRenderTime { let elapsed = now.uptimeNanoseconds - lastRender.uptimeNanoseconds guard elapsed >= 1_000_000_000 else { return } } state.lastPlainRenderTime = now } let output = draw(state: state) let terminating = config.outputMode == .plain ? "\n" : "\r" displayText(output, state: &state, terminating: terminating) } /// 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) let length = config.outputMode == .color ? output.visibleLength : output.count if length <= targetWidth { return output } } return draw(state: state, detail: .minimal) } func draw(state: State, detail: DetailLevel) -> String { let useColor = config.outputMode == .color /// Wraps text in ANSI color when color mode is active; returns text unchanged otherwise. func colored(_ text: String, _ code: String) -> String { useColor ? EscapeSequence.colored(text, code) : text } 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(colored("\(spinnerIcon)", EscapeSequence.cyan)) } else { components.append(colored("\(config.theme.done)", EscapeSequence.green)) } } // Tasks [x/y] - always shown if configured if config.showTasks, let totalTasks = state.totalTasks { let tasks = min(state.tasks, totalTasks) components.append(colored("[\(tasks)/\(totalTasks)]", EscapeSequence.cyan)) } // Description - dropped at noDescription level if detail.rawValue < DetailLevel.noDescription.rawValue { if config.showDescription && !state.description.isEmpty { components.append(colored("\(state.description)", EscapeSequence.bold)) if !state.subDescription.isEmpty { components.append(colored("\(state.subDescription)", EscapeSequence.bold)) } } } 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 { let percentText = state.finished ? "100%" : state.percent let percentColor = state.finished ? EscapeSequence.green : EscapeSequence.yellow components.append(colored(percentText, percentColor)) } // Progress bar - always shown if configured if config.showProgressBar, total > 0, allowProgress { // reserve spaces between components, plus 45 for components rendered after the bar (size, speed, time, etc.) let joinedComponents = components.joined(separator: " ") let reservedWidth = (useColor ? joinedComponents.visibleLength : joinedComponents.count) + 45 let totalBarWidth = max(config.width - reservedWidth, 1) let completedWidth: Int if state.finished { completedWidth = totalBarWidth } else { let progressWidth = max(0, Int(Int64(totalBarWidth) * value / total)) completedWidth = min(totalBarWidth, progressWidth) } let uncompletedWidth = totalBarWidth - completedWidth if useColor { let filledBar = EscapeSequence.colored(String(repeating: config.theme.bar, count: completedWidth), EscapeSequence.green) let emptyBar = String(repeating: " ", count: uncompletedWidth) components.append("|\(filledBar)\(emptyBar)|") } else { let bar = "\(String(repeating: config.theme.bar, count: completedWidth))\(String(repeating: " ", count: uncompletedWidth))" 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(colored("(\(joinedAdditionalComponents))", EscapeSequence.dim)) } } // 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(colored("[\(formattedTime)]", EscapeSequence.dim)) } 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) } } }