diff --git a/Sources/ContainerBuild/BuildImageResolver.swift b/Sources/ContainerBuild/BuildImageResolver.swift index f0d45ee1..76601f55 100644 --- a/Sources/ContainerBuild/BuildImageResolver.swift +++ b/Sources/ContainerBuild/BuildImageResolver.swift @@ -66,7 +66,7 @@ struct BuildImageResolver: BuildPipelineHandler { showProgressBar: true, showSize: true, showSpeed: true, - disableProgressUpdates: self.quiet + outputMode: self.quiet ? .none : .ansi ) let progress = ProgressBar(config: progressConfig) defer { progress.finish() } diff --git a/Sources/ContainerClient/Flags.swift b/Sources/ContainerClient/Flags.swift index 4a5cfd27..c7b5f3d1 100644 --- a/Sources/ContainerClient/Flags.swift +++ b/Sources/ContainerClient/Flags.swift @@ -210,9 +210,11 @@ public struct Flags { public enum ProgressType: String, ExpressibleByArgument { case none case ansi + case plain + case color } - @Option(name: .long, help: ArgumentHelp("Progress type (format: none|ansi)", valueName: "type")) + @Option(name: .long, help: ArgumentHelp("Progress type (format: none|ansi|plain|color)", valueName: "type")) public var progress: ProgressType = .ansi } diff --git a/Sources/ContainerCommands/Container/ContainerRun.swift b/Sources/ContainerCommands/Container/ContainerRun.swift index 790f8064..0f81bf33 100644 --- a/Sources/ContainerCommands/Container/ContainerRun.swift +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -65,13 +65,14 @@ extension Application { var progressConfig: ProgressConfig switch self.progressFlags.progress { - case .none: progressConfig = try ProgressConfig(disableProgressUpdates: true) - case .ansi: + case .none: progressConfig = try ProgressConfig(outputMode: .none) + case .ansi, .plain, .color: progressConfig = try ProgressConfig( showTasks: true, showItems: true, ignoreSmallSize: true, - totalTasks: 6 + totalTasks: 6, + outputMode: self.progressFlags.progress.outputMode ) } diff --git a/Sources/ContainerCommands/Image/ImagePull.swift b/Sources/ContainerCommands/Image/ImagePull.swift index 55ca722d..69780ec6 100644 --- a/Sources/ContainerCommands/Image/ImagePull.swift +++ b/Sources/ContainerCommands/Image/ImagePull.swift @@ -82,13 +82,14 @@ extension Application { var progressConfig: ProgressConfig switch self.progressFlags.progress { - case .none: progressConfig = try ProgressConfig(disableProgressUpdates: true) - case .ansi: + case .none: progressConfig = try ProgressConfig(outputMode: .none) + case .ansi, .plain, .color: progressConfig = try ProgressConfig( showTasks: true, showItems: true, ignoreSmallSize: true, - totalTasks: 2 + totalTasks: 2, + outputMode: self.progressFlags.progress.outputMode ) } diff --git a/Sources/ContainerCommands/Image/ImagePush.swift b/Sources/ContainerCommands/Image/ImagePush.swift index 3bd57f71..5f9d07c5 100644 --- a/Sources/ContainerCommands/Image/ImagePush.swift +++ b/Sources/ContainerCommands/Image/ImagePush.swift @@ -69,14 +69,15 @@ extension Application { var progressConfig: ProgressConfig switch self.progressFlags.progress { - case .none: progressConfig = try ProgressConfig(disableProgressUpdates: true) - case .ansi: + case .none: progressConfig = try ProgressConfig(outputMode: .none) + case .ansi, .color, .plain: progressConfig = try ProgressConfig( description: "Pushing image \(image.reference)", itemsName: "blobs", showItems: true, showSpeed: false, - ignoreSmallSize: true + ignoreSmallSize: true, + outputMode: self.progressFlags.progress.outputMode ) } diff --git a/Sources/ContainerCommands/ProgressTypeExtension.swift b/Sources/ContainerCommands/ProgressTypeExtension.swift new file mode 100644 index 00000000..59d8585c --- /dev/null +++ b/Sources/ContainerCommands/ProgressTypeExtension.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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 ContainerClient +import TerminalProgress + +extension Flags.Progress.ProgressType { + /// Converts the progress type to a ProgressConfig output mode. + var outputMode: ProgressConfig.OutputMode { + switch self { + case .none: .none + case .ansi: .ansi + case .plain: .plain + case .color: .color + } + } +} diff --git a/Sources/TerminalProgress/ProgressBar+Terminal.swift b/Sources/TerminalProgress/ProgressBar+Terminal.swift index ab997fea..5f75ed28 100644 --- a/Sources/TerminalProgress/ProgressBar+Terminal.swift +++ b/Sources/TerminalProgress/ProgressBar+Terminal.swift @@ -63,12 +63,17 @@ extension ProgressBar { } func displayText(_ text: String, terminating: String = "\r") { + if config.outputMode == .plain { + display(text + "\n") + return + } var text = text + let visibleText = stripAnsi(text) // Clears previously printed characters if the new string is shorter. printedWidth.withLock { - text += String(repeating: " ", count: max($0 - text.count, 0)) - $0 = text.count + text += String(repeating: " ", count: max($0 - visibleText.count, 0)) + $0 = visibleText.count } state.withLock { $0.output = text @@ -77,7 +82,7 @@ extension ProgressBar { // Clears previously printed lines. var lines = "" if terminating.hasSuffix("\r") && terminalWidth > 0 { - let lineCount = (text.count - 1) / terminalWidth + let lineCount = (visibleText.count - 1) / terminalWidth for _ in 0.. String { + let pattern = #"\u001B\[[0-9;]*[A-Za-z]"# + return text.replacingOccurrences(of: pattern, with: "", options: .regularExpression) + } } diff --git a/Sources/TerminalProgress/ProgressBar.swift b/Sources/TerminalProgress/ProgressBar.swift index 61dca8ae..40f4a82c 100644 --- a/Sources/TerminalProgress/ProgressBar.swift +++ b/Sources/TerminalProgress/ProgressBar.swift @@ -72,6 +72,9 @@ public final class ProgressBar: Sendable { $0.subDescription = "" $0.tasks += 1 } + if config.outputMode == .plain { + render(force: true) + } } /// Updates the additional description of the progress bar. @@ -80,6 +83,9 @@ public final class ProgressBar: Sendable { resetCurrentTask() state.withLock { $0.subDescription = subDescription } + if config.outputMode == .plain { + render(force: true) + } } private func start(intervalSeconds: TimeInterval) async { @@ -96,6 +102,9 @@ 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) { + if config.outputMode == .plain { + return + } Task(priority: .utility) { await start(intervalSeconds: intervalSeconds) } @@ -112,7 +121,7 @@ public final class ProgressBar: Sendable { // The last render. render(force: true) - if !config.disableProgressUpdates && !config.clearOnFinish { + if config.outputMode != .none && !config.clearOnFinish { displayText(state.withLock { $0.output }, terminating: "\n") } @@ -134,7 +143,7 @@ extension ProgressBar { } func render(force: Bool = false) { - guard term != nil && !config.disableProgressUpdates && (force || !state.withLock { $0.finished }) else { + guard term != nil && config.outputMode != .none && (force || !state.withLock { $0.finished }) else { return } let output = draw() @@ -148,15 +157,15 @@ extension ProgressBar { if config.showSpinner && !config.showProgressBar { if !state.finished { let spinnerIcon = config.theme.getSpinnerIcon(state.iteration) - components.append("\(spinnerIcon)") + components.append(colorize("\(spinnerIcon)", Color.cyan)) } else { - components.append("\(config.theme.done)") + components.append(colorize("\(config.theme.done)", Color.green)) } } if config.showTasks, let totalTasks = state.totalTasks { let tasks = min(state.tasks, totalTasks) - components.append("[\(tasks)/\(totalTasks)]") + components.append(colorize("[\(tasks)/\(totalTasks)]", Color.cyan)) } if config.showDescription && !state.description.isEmpty { @@ -172,7 +181,7 @@ extension ProgressBar { let total = state.totalSize ?? Int64(state.totalItems ?? 0) if config.showPercent && total > 0 && allowProgress { - components.append("\(state.finished ? "100%" : state.percent)") + components.append(colorize("\(state.finished ? "100%" : state.percent)", Color.green)) } if config.showProgressBar, total > 0, allowProgress { @@ -181,7 +190,7 @@ extension ProgressBar { 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)|") + components.append("|\(colorize(bar, Color.green))|") } var additionalComponents = [String]() @@ -246,13 +255,13 @@ extension ProgressBar { if additionalComponents.count > 0 { let joinedAdditionalComponents = additionalComponents.joined(separator: ", ") - components.append("(\(joinedAdditionalComponents))") + components.append("(\(colorize(joinedAdditionalComponents, Color.cyan)))") } if config.showTime { let timeDifferenceSeconds = secondsSinceStart() let formattedTime = timeDifferenceSeconds.formattedTime() - components.append("[\(formattedTime)]") + components.append("[\(colorize(formattedTime, Color.blue))]") } return components.joined(separator: " ") @@ -287,4 +296,16 @@ extension ProgressBar { } return "\(sizeNumber)/\(totalSizeNumber) \(totalSizeUnit)" } + + private func colorize(_ text: String, _ color: String) -> String { + guard config.outputMode == .color else { return text } + return "\(color)\(text)\(Color.reset)" + } + + private enum Color { + static let green = "\u{001B}[32m" + static let cyan = "\u{001B}[36m" + static let blue = "\u{001B}[34m" + static let reset = "\u{001B}[0m" + } } diff --git a/Sources/TerminalProgress/ProgressConfig.swift b/Sources/TerminalProgress/ProgressConfig.swift index 37eb107a..1864f79a 100644 --- a/Sources/TerminalProgress/ProgressConfig.swift +++ b/Sources/TerminalProgress/ProgressConfig.swift @@ -18,6 +18,17 @@ import Foundation /// A configuration for displaying a progress bar. public struct ProgressConfig: Sendable { + /// The output mode for progress display. + public enum OutputMode: Sendable { + /// No progress output. + case none + /// ANSI control sequences without color (default). + case ansi + /// Plain text output, one line per update. + case plain + /// ANSI control sequences with color. + case color + } /// The file handle for progress updates. let terminal: FileHandle /// The initial description of the progress bar. @@ -63,8 +74,8 @@ public struct ProgressConfig: Sendable { public let theme: ProgressTheme /// The flag indicating whether to clear the progress bar before resetting the cursor. public let clearOnFinish: Bool - /// The flag indicating whether to update the progress bar. - public let disableProgressUpdates: Bool + /// The output mode for progress display. + public let outputMode: OutputMode /// Creates a new instance of `ProgressConfig`. /// - Parameters: /// - terminal: The file handle for progress updates. The default value is `FileHandle.standardError`. @@ -87,7 +98,7 @@ public struct ProgressConfig: Sendable { /// - width: The width of the progress bar in characters. The default value is `120`. /// - theme: The theme of the progress bar. The default value is `nil`. /// - clearOnFinish: The flag indicating whether to clear the progress bar before resetting the cursor. The default is `true`. - /// - disableProgressUpdates: The flag indicating whether to update the progress bar. The default is `false`. + /// - outputMode: The output mode for progress display. The default value is `.ansi`. public init( terminal: FileHandle = .standardError, description: String = "", @@ -109,7 +120,7 @@ public struct ProgressConfig: Sendable { width: Int = 120, theme: ProgressTheme? = nil, clearOnFinish: Bool = true, - disableProgressUpdates: Bool = false + outputMode: OutputMode = .ansi ) throws { if let totalTasks { guard totalTasks > 0 else { @@ -132,11 +143,11 @@ public struct ProgressConfig: Sendable { self.initialSubDescription = subDescription self.initialItemsName = itemsName - self.showSpinner = showSpinner + self.showSpinner = (outputMode == .plain || outputMode == .none) ? false : showSpinner self.showTasks = showTasks self.showDescription = showDescription self.showPercent = showPercent - self.showProgressBar = showProgressBar + self.showProgressBar = (outputMode == .plain || outputMode == .none) ? false : showProgressBar self.showItems = showItems self.showSize = showSize self.showSpeed = showSpeed @@ -150,7 +161,7 @@ public struct ProgressConfig: Sendable { self.width = width self.theme = theme ?? DefaultProgressTheme() self.clearOnFinish = clearOnFinish - self.disableProgressUpdates = disableProgressUpdates + self.outputMode = outputMode } } diff --git a/docs/command-reference.md b/docs/command-reference.md index 2b105bf7..0dc39744 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -84,7 +84,7 @@ container run [] [ ...] **Progress Options** -* `--progress `: Progress type (format: none|ansi) (default: ansi) +* `--progress `: Progress type (format: none|ansi|plain|color) (default: ansi) **Examples** @@ -449,7 +449,7 @@ container image pull [--debug] [--scheme ] [--progress ] [--arch < **Options** * `--scheme `: Scheme to use when connecting to the container registry. One of (http, https, auto) (default: auto) -* `--progress `: Progress type (format: none|ansi) (default: ansi) +* `--progress `: Progress type (format: none|ansi|plain|color) (default: ansi) * `-a, --arch `: Limit the pull to the specified architecture * `--os `: Limit the pull to the specified OS * `--platform `: Limit the pull to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch) @@ -471,7 +471,7 @@ container image push [--scheme ] [--progress ] [--arch ] [-- **Options** * `--scheme `: Scheme to use when connecting to the container registry. One of (http, https, auto) (default: auto) -* `--progress `: Progress type (format: none|ansi) (default: ansi) +* `--progress `: Progress type (format: none|ansi|plain|color) (default: ansi) * `-a, --arch `: Limit the push to the specified architecture * `--os `: Limit the push to the specified OS * `--platform `: Limit the push to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch)