mirror of
https://github.com/apple/container.git
synced 2026-07-12 12:37:02 +00:00
1c37384831
- Adds a color progress output mode (`--progress color`) that renders ANSI-colored progress output with visual differentiation between progress states. - Like `ansi` mode, this feature requires a TTY. - Closes #1366
409 lines
16 KiB
Swift
409 lines
16 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
|
|
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 {
|
|
let joinedComponents = components.joined(separator: " ")
|
|
// 45 reserves space for components rendered after the bar (size, speed, time, etc.)
|
|
let usedWidth = (useColor ? joinedComponents.visibleLength : joinedComponents.count) + 45
|
|
let remainingWidth = max(config.width - usedWidth, 1)
|
|
let barLength = min(remainingWidth, state.finished ? remainingWidth : Int(Int64(remainingWidth) * value / total))
|
|
let barPaddingLength = remainingWidth - barLength
|
|
if useColor {
|
|
let filledBar = EscapeSequence.colored(String(repeating: config.theme.bar, count: barLength), EscapeSequence.green)
|
|
let emptyBar = String(repeating: " ", count: barPaddingLength)
|
|
components.append("|\(filledBar)\(emptyBar)|")
|
|
} else {
|
|
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(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) }
|
|
}
|
|
}
|