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) } + } }