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:
Danny Canter
2026-01-07 21:01:10 -08:00
committed by GitHub
parent 98410fdb57
commit b671690c17
3 changed files with 199 additions and 119 deletions
@@ -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)
}
}
+169 -97
View File
@@ -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) }
}
}