Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/ContainerBuild/BuildImageResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
4 changes: 3 additions & 1 deletion Sources/ContainerClient/Flags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
7 changes: 4 additions & 3 deletions Sources/ContainerCommands/Container/ContainerRun.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
7 changes: 4 additions & 3 deletions Sources/ContainerCommands/Image/ImagePull.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
7 changes: 4 additions & 3 deletions Sources/ContainerCommands/Image/ImagePush.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
30 changes: 30 additions & 0 deletions Sources/ContainerCommands/ProgressTypeExtension.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
16 changes: 13 additions & 3 deletions Sources/TerminalProgress/ProgressBar+Terminal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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..<lineCount {
lines += EscapeSequence.moveUp
}
Expand All @@ -86,4 +91,9 @@ extension ProgressBar {
text = "\(text)\(terminating)\(lines)"
display(text)
}

private func stripAnsi(_ text: String) -> String {
let pattern = #"\u001B\[[0-9;]*[A-Za-z]"#
return text.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
}
}
39 changes: 30 additions & 9 deletions Sources/TerminalProgress/ProgressBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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")
}

Expand All @@ -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()
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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]()
Expand Down Expand Up @@ -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: " ")
Expand Down Expand Up @@ -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"
}
}
25 changes: 18 additions & 7 deletions Sources/TerminalProgress/ProgressConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand All @@ -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 = "",
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -150,7 +161,7 @@ public struct ProgressConfig: Sendable {
self.width = width
self.theme = theme ?? DefaultProgressTheme()
self.clearOnFinish = clearOnFinish
self.disableProgressUpdates = disableProgressUpdates
self.outputMode = outputMode
}
}

Expand Down
6 changes: 3 additions & 3 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ container run [<options>] <image> [<arguments> ...]

**Progress Options**

* `--progress <type>`: Progress type (format: none|ansi) (default: ansi)
* `--progress <type>`: Progress type (format: none|ansi|plain|color) (default: ansi)

**Examples**

Expand Down Expand Up @@ -449,7 +449,7 @@ container image pull [--debug] [--scheme <scheme>] [--progress <type>] [--arch <
**Options**

* `--scheme <scheme>`: Scheme to use when connecting to the container registry. One of (http, https, auto) (default: auto)
* `--progress <type>`: Progress type (format: none|ansi) (default: ansi)
* `--progress <type>`: Progress type (format: none|ansi|plain|color) (default: ansi)
* `-a, --arch <arch>`: Limit the pull to the specified architecture
* `--os <os>`: Limit the pull to the specified OS
* `--platform <platform>`: Limit the pull to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch)
Expand All @@ -471,7 +471,7 @@ container image push [--scheme <scheme>] [--progress <type>] [--arch <arch>] [--
**Options**

* `--scheme <scheme>`: Scheme to use when connecting to the container registry. One of (http, https, auto) (default: auto)
* `--progress <type>`: Progress type (format: none|ansi) (default: ansi)
* `--progress <type>`: Progress type (format: none|ansi|plain|color) (default: ansi)
* `-a, --arch <arch>`: Limit the push to the specified architecture
* `--os <os>`: Limit the push to the specified OS
* `--platform <platform>`: Limit the push to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch)
Expand Down