mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
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.
This commit is contained in:
@@ -17,10 +17,10 @@
|
||||
import Foundation
|
||||
|
||||
extension ProgressBar {
|
||||
/// A configuration struct for the progress bar.
|
||||
public struct State {
|
||||
/// State for the progress bar.
|
||||
struct State {
|
||||
/// A flag indicating whether the progress bar is finished.
|
||||
public var finished = false
|
||||
var finished = false
|
||||
var iteration = 0
|
||||
private let speedInterval: DispatchTimeInterval = .seconds(1)
|
||||
|
||||
@@ -41,6 +41,7 @@ extension ProgressBar {
|
||||
calculateSizeSpeed()
|
||||
}
|
||||
}
|
||||
|
||||
var totalSize: Int64?
|
||||
private var sizeUpdateSpeed: String?
|
||||
var sizeSpeed: String? {
|
||||
@@ -66,6 +67,7 @@ extension ProgressBar {
|
||||
|
||||
var startTime: DispatchTime
|
||||
var output = ""
|
||||
var renderTask: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
description: String = "", subDescription: String = "", itemsName: String = "", tasks: Int = 0, totalTasks: Int? = nil, items: Int = 0, totalItems: Int? = nil,
|
||||
|
||||
@@ -21,10 +21,11 @@ enum EscapeSequence {
|
||||
static let hideCursor = "\u{001B}[?25l"
|
||||
static let showCursor = "\u{001B}[?25h"
|
||||
static let moveUp = "\u{001B}[1A"
|
||||
static let clearToEndOfLine = "\u{001B}[K"
|
||||
}
|
||||
|
||||
extension ProgressBar {
|
||||
private var terminalWidth: Int {
|
||||
var termWidth: Int {
|
||||
guard
|
||||
let terminalHandle = term,
|
||||
let terminal = try? Terminal(descriptor: terminalHandle.fileDescriptor)
|
||||
@@ -32,19 +33,27 @@ extension ProgressBar {
|
||||
return 0
|
||||
}
|
||||
|
||||
let terminalWidth = (try? Int(terminal.size.width)) ?? 0
|
||||
return terminalWidth
|
||||
return (try? Int(terminal.size.width)) ?? 0
|
||||
}
|
||||
|
||||
/// Clears the progress bar and resets the cursor.
|
||||
public func clearAndResetCursor() {
|
||||
clear()
|
||||
resetCursor()
|
||||
state.withLock { s in
|
||||
clear(state: &s)
|
||||
resetCursor()
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the progress bar.
|
||||
public func clear() {
|
||||
displayText("")
|
||||
state.withLock { s in
|
||||
clear(state: &s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the progress bar (caller must hold state lock).
|
||||
func clear(state: inout State) {
|
||||
displayText("", state: &state)
|
||||
}
|
||||
|
||||
/// Resets the cursor.
|
||||
@@ -63,27 +72,24 @@ extension ProgressBar {
|
||||
}
|
||||
|
||||
func displayText(_ text: String, terminating: String = "\r") {
|
||||
var text = text
|
||||
state.withLock { s in
|
||||
displayText(text, state: &s, terminating: terminating)
|
||||
}
|
||||
}
|
||||
|
||||
// Clears previously printed characters if the new string is shorter.
|
||||
printedWidth.withLock {
|
||||
text += String(repeating: " ", count: max($0 - text.count, 0))
|
||||
$0 = text.count
|
||||
}
|
||||
state.withLock {
|
||||
$0.output = text
|
||||
}
|
||||
func displayText(_ text: String, state: inout State, terminating: String = "\r") {
|
||||
state.output = text
|
||||
|
||||
// Clears previously printed lines.
|
||||
var lines = ""
|
||||
if terminating.hasSuffix("\r") && terminalWidth > 0 {
|
||||
let lineCount = (text.count - 1) / terminalWidth
|
||||
if terminating.hasSuffix("\r") && termWidth > 0 {
|
||||
let lineCount = (text.count - 1) / termWidth
|
||||
for _ in 0..<lineCount {
|
||||
lines += EscapeSequence.moveUp
|
||||
}
|
||||
}
|
||||
|
||||
text = "\(text)\(terminating)\(lines)"
|
||||
display(text)
|
||||
let output = "\(text)\(EscapeSequence.clearToEndOfLine)\(terminating)\(lines)"
|
||||
display(output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,8 @@ import Synchronization
|
||||
public final class ProgressBar: Sendable {
|
||||
let config: ProgressConfig
|
||||
let state: Mutex<State>
|
||||
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 {
|
||||
@@ -44,10 +42,6 @@ public final class ProgressBar: Sendable {
|
||||
display(EscapeSequence.hideCursor)
|
||||
}
|
||||
|
||||
deinit {
|
||||
clear()
|
||||
}
|
||||
|
||||
/// Allows resetting the progress state.
|
||||
public func reset() {
|
||||
state.withLock {
|
||||
@@ -83,11 +77,22 @@ public final class ProgressBar: Sendable {
|
||||
}
|
||||
|
||||
private func start(intervalSeconds: TimeInterval) async {
|
||||
while !state.withLock({ $0.finished }) {
|
||||
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)
|
||||
render()
|
||||
state.withLock { $0.iteration += 1 }
|
||||
if (try? await Task.sleep(nanoseconds: intervalNanoseconds)) == nil {
|
||||
guard (try? await Task.sleep(nanoseconds: intervalNanoseconds)) != nil else {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -96,55 +101,102 @@ public final class ProgressBar: Sendable {
|
||||
/// 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)
|
||||
state.withLock {
|
||||
if $0.renderTask != nil {
|
||||
return
|
||||
}
|
||||
$0.renderTask = Task(priority: .utility) {
|
||||
await start(intervalSeconds: intervalSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finishes the progress bar.
|
||||
public func finish() {
|
||||
guard !state.withLock({ $0.finished }) else {
|
||||
return
|
||||
}
|
||||
/// - 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 }
|
||||
|
||||
state.withLock { $0.finished = true }
|
||||
s.finished = true
|
||||
s.renderTask?.cancel()
|
||||
|
||||
// The last render.
|
||||
render(force: true)
|
||||
let shouldClear = clearScreen || config.clearOnFinish
|
||||
if !config.disableProgressUpdates && !shouldClear {
|
||||
let output = draw(state: s)
|
||||
displayText(output, state: &s, terminating: "\n")
|
||||
}
|
||||
|
||||
if !config.disableProgressUpdates && !config.clearOnFinish {
|
||||
displayText(state.withLock { $0.output }, terminating: "\n")
|
||||
}
|
||||
|
||||
if config.clearOnFinish {
|
||||
clearAndResetCursor()
|
||||
} else {
|
||||
if shouldClear {
|
||||
clear(state: &s)
|
||||
}
|
||||
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 }
|
||||
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 && (force || !state.withLock { $0.finished }) else {
|
||||
guard term != nil && !config.disableProgressUpdates else {
|
||||
return
|
||||
}
|
||||
let output = draw()
|
||||
displayText(output)
|
||||
state.withLock { s in
|
||||
render(state: &s, force: force)
|
||||
}
|
||||
}
|
||||
|
||||
func draw() -> String {
|
||||
let state = self.state.withLock { $0 }
|
||||
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)
|
||||
@@ -154,103 +206,119 @@ extension ProgressBar {
|
||||
}
|
||||
}
|
||||
|
||||
// Tasks [x/y] - always shown if configured
|
||||
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)")
|
||||
// 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 /* the maximum number of characters we may need */
|
||||
let remainingWidth = max(config.width - usedWidth, 1 /* the minimum width of a progress bar */)
|
||||
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)|")
|
||||
}
|
||||
|
||||
var additionalComponents = [String]()
|
||||
// Additional components in parens - progressively dropped
|
||||
if detail.rawValue < DetailLevel.noParens.rawValue {
|
||||
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)")
|
||||
// Items - dropped at noParens level
|
||||
if config.showItems, state.items > 0 {
|
||||
var itemsName = ""
|
||||
if !state.itemsName.isEmpty {
|
||||
itemsName = " \(state.itemsName)"
|
||||
}
|
||||
} else {
|
||||
if let totalItems = state.totalItems {
|
||||
additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)")
|
||||
if state.finished {
|
||||
if let totalItems = state.totalItems {
|
||||
additionalComponents.append("\(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)
|
||||
if let totalItems = state.totalItems {
|
||||
additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)")
|
||||
} else {
|
||||
formattedCombinedSize = formattedSize
|
||||
additionalComponents.append("\(state.items.formattedNumber())\(itemsName)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var formattedSpeed = ""
|
||||
if config.showSpeed {
|
||||
formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)"
|
||||
formattedSpeed = adjustFormattedSize(formattedSpeed)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// 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))")
|
||||
}
|
||||
}
|
||||
|
||||
if additionalComponents.count > 0 {
|
||||
let joinedAdditionalComponents = additionalComponents.joined(separator: ", ")
|
||||
components.append("(\(joinedAdditionalComponents))")
|
||||
}
|
||||
|
||||
if config.showTime {
|
||||
let timeDifferenceSeconds = secondsSinceStart()
|
||||
// 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)]")
|
||||
}
|
||||
@@ -287,4 +355,8 @@ extension ProgressBar {
|
||||
}
|
||||
return "\(sizeNumber)/\(totalSizeNumber) \(totalSizeUnit)"
|
||||
}
|
||||
|
||||
func draw() -> String {
|
||||
state.withLock { draw(state: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user