From b671690c17b4fc7b2d239faa8cfad3fa1026f513 Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Wed, 7 Jan 2026 21:01:10 -0800 Subject: [PATCH] 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. --- .../TerminalProgress/ProgressBar+State.swift | 8 +- .../ProgressBar+Terminal.swift | 44 +-- Sources/TerminalProgress/ProgressBar.swift | 266 +++++++++++------- 3 files changed, 199 insertions(+), 119 deletions(-) diff --git a/Sources/TerminalProgress/ProgressBar+State.swift b/Sources/TerminalProgress/ProgressBar+State.swift index a1500b76..ecf032e1 100644 --- a/Sources/TerminalProgress/ProgressBar+State.swift +++ b/Sources/TerminalProgress/ProgressBar+State.swift @@ -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? init( description: String = "", subDescription: String = "", itemsName: String = "", tasks: Int = 0, totalTasks: Int? = nil, items: Int = 0, totalItems: Int? = nil, diff --git a/Sources/TerminalProgress/ProgressBar+Terminal.swift b/Sources/TerminalProgress/ProgressBar+Terminal.swift index 068e93a3..9c8c02ff 100644 --- a/Sources/TerminalProgress/ProgressBar+Terminal.swift +++ b/Sources/TerminalProgress/ProgressBar+Terminal.swift @@ -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.. - 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) } + } }