diff --git a/Sources/ContainerCommands/Container/ContainerRun.swift b/Sources/ContainerCommands/Container/ContainerRun.swift index c83fbf790..ab160b5d1 100644 --- a/Sources/ContainerCommands/Container/ContainerRun.swift +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -64,17 +64,12 @@ extension Application { var exitCode: Int32 = 127 let id = Utility.createContainerID(name: self.managementFlags.name) - var progressConfig: ProgressConfig - switch self.progressFlags.progress { - case .none: progressConfig = try ProgressConfig(disableProgressUpdates: true) - case .ansi: - progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - ignoreSmallSize: true, - totalTasks: 6 - ) - } + let progressConfig = try self.progressFlags.makeConfig( + showTasks: true, + showItems: true, + ignoreSmallSize: true, + totalTasks: 6 + ) let progress = ProgressBar(config: progressConfig) defer { diff --git a/Sources/ContainerCommands/Flags+ProgressConfig.swift b/Sources/ContainerCommands/Flags+ProgressConfig.swift new file mode 100644 index 000000000..67bbba9e0 --- /dev/null +++ b/Sources/ContainerCommands/Flags+ProgressConfig.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 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 ContainerAPIClient +import TerminalProgress + +extension Flags.Progress { + /// Creates a `ProgressConfig` based on the selected progress type. + /// + /// For `.none`, progress updates are disabled. For `.ansi`, the given parameters + /// are used as-is. For `.plain`, ANSI-incompatible features (spinner, clear on finish) + /// are disabled and the output mode is set to `.plain`. + func makeConfig( + description: String = "", + itemsName: String = "it", + showTasks: Bool = false, + showItems: Bool = false, + showSpeed: Bool = true, + ignoreSmallSize: Bool = false, + totalTasks: Int? = nil + ) throws -> ProgressConfig { + switch progress { + case .none: + return try ProgressConfig(disableProgressUpdates: true) + case .ansi, .plain: + let isPlain = progress == .plain + return try ProgressConfig( + description: description, + itemsName: itemsName, + showSpinner: !isPlain, + showTasks: showTasks, + showItems: showItems, + showSpeed: showSpeed, + ignoreSmallSize: ignoreSmallSize, + totalTasks: totalTasks, + clearOnFinish: !isPlain, + outputMode: isPlain ? .plain : .ansi + ) + } + } +} diff --git a/Sources/ContainerCommands/Image/ImagePull.swift b/Sources/ContainerCommands/Image/ImagePull.swift index e2bfbd402..6365de153 100644 --- a/Sources/ContainerCommands/Image/ImagePull.swift +++ b/Sources/ContainerCommands/Image/ImagePull.swift @@ -73,17 +73,12 @@ extension Application { let processedReference = try ClientImage.normalizeReference(reference) - var progressConfig: ProgressConfig - switch self.progressFlags.progress { - case .none: progressConfig = try ProgressConfig(disableProgressUpdates: true) - case .ansi: - progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - ignoreSmallSize: true, - totalTasks: 2 - ) - } + let progressConfig = try self.progressFlags.makeConfig( + showTasks: true, + showItems: true, + ignoreSmallSize: true, + totalTasks: 2 + ) let progress = ProgressBar(config: progressConfig) defer { diff --git a/Sources/ContainerCommands/Image/ImagePush.swift b/Sources/ContainerCommands/Image/ImagePush.swift index 60e8bf803..0319eaf9a 100644 --- a/Sources/ContainerCommands/Image/ImagePush.swift +++ b/Sources/ContainerCommands/Image/ImagePush.swift @@ -60,18 +60,13 @@ extension Application { let scheme = try RequestScheme(registry.scheme) let image = try await ClientImage.get(reference: reference) - var progressConfig: ProgressConfig - switch self.progressFlags.progress { - case .none: progressConfig = try ProgressConfig(disableProgressUpdates: true) - case .ansi: - progressConfig = try ProgressConfig( - description: "Pushing image \(image.reference)", - itemsName: "blobs", - showItems: true, - showSpeed: false, - ignoreSmallSize: true - ) - } + let progressConfig = try self.progressFlags.makeConfig( + description: "Pushing image \(image.reference)", + itemsName: "blobs", + showItems: true, + showSpeed: false, + ignoreSmallSize: true + ) let progress = ProgressBar(config: progressConfig) defer { diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index 88de209f9..58be59668 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -337,9 +337,10 @@ public struct Flags { public enum ProgressType: String, ExpressibleByArgument { case none case ansi + case plain } - @Option(name: .long, help: ArgumentHelp("Progress type (format: none|ansi)", valueName: "type")) + @Option(name: .long, help: ArgumentHelp("Progress type (format: none|ansi|plain)", valueName: "type")) public var progress: ProgressType = .ansi } diff --git a/Sources/TerminalProgress/ProgressBar+State.swift b/Sources/TerminalProgress/ProgressBar+State.swift index ecf032e15..8e42f7c6c 100644 --- a/Sources/TerminalProgress/ProgressBar+State.swift +++ b/Sources/TerminalProgress/ProgressBar+State.swift @@ -66,6 +66,7 @@ extension ProgressBar { } var startTime: DispatchTime + var lastPlainRenderTime: DispatchTime? var output = "" var renderTask: Task? diff --git a/Sources/TerminalProgress/ProgressBar+Terminal.swift b/Sources/TerminalProgress/ProgressBar+Terminal.swift index 9c8c02ff5..32a72249d 100644 --- a/Sources/TerminalProgress/ProgressBar+Terminal.swift +++ b/Sources/TerminalProgress/ProgressBar+Terminal.swift @@ -40,7 +40,12 @@ extension ProgressBar { public func clearAndResetCursor() { state.withLock { s in clear(state: &s) - resetCursor() + switch config.outputMode { + case .ansi: + resetCursor() + case .plain: + break + } } } @@ -80,16 +85,22 @@ extension ProgressBar { func displayText(_ text: String, state: inout State, terminating: String = "\r") { state.output = text - // Clears previously printed lines. - var lines = "" - if terminating.hasSuffix("\r") && termWidth > 0 { - let lineCount = (text.count - 1) / termWidth - for _ in 0.. 0 { + let lineCount = (text.count - 1) / termWidth + for _ in 0..= 1_000_000_000 else { + return + } + } + state.lastPlainRenderTime = now + } + let output = draw(state: state) - displayText(output, state: &state) + let terminating = config.outputMode == .plain ? "\n" : "\r" + displayText(output, state: &state, terminating: terminating) } /// Detail levels for progressive truncation. diff --git a/Sources/TerminalProgress/ProgressConfig.swift b/Sources/TerminalProgress/ProgressConfig.swift index 5a2f836a6..6346f192a 100644 --- a/Sources/TerminalProgress/ProgressConfig.swift +++ b/Sources/TerminalProgress/ProgressConfig.swift @@ -65,6 +65,9 @@ public struct ProgressConfig: Sendable { public let clearOnFinish: Bool /// The flag indicating whether to update the progress bar. public let disableProgressUpdates: Bool + /// The output mode for progress rendering. + public let outputMode: OutputMode + /// Creates a new instance of `ProgressConfig`. /// - Parameters: /// - terminal: The file handle for progress updates. The default value is `FileHandle.standardError`. @@ -88,6 +91,7 @@ public struct ProgressConfig: Sendable { /// - 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 rendering. The default is `.ansi`. public init( terminal: FileHandle = .standardError, description: String = "", @@ -109,7 +113,8 @@ public struct ProgressConfig: Sendable { width: Int = 120, theme: ProgressTheme? = nil, clearOnFinish: Bool = true, - disableProgressUpdates: Bool = false + disableProgressUpdates: Bool = false, + outputMode: OutputMode = .ansi ) throws { if let totalTasks { guard totalTasks > 0 else { @@ -151,10 +156,19 @@ public struct ProgressConfig: Sendable { self.theme = theme ?? DefaultProgressTheme() self.clearOnFinish = clearOnFinish self.disableProgressUpdates = disableProgressUpdates + self.outputMode = outputMode } } extension ProgressConfig { + /// The output mode for progress rendering. + public enum OutputMode: Sendable { + /// ANSI escape code mode with cursor control and line overwriting. + case ansi + /// Plain text mode with newline-separated output, no ANSI codes. + case plain + } + /// An enumeration of errors that can occur when creating a `ProgressConfig`. public enum Error: Swift.Error, CustomStringConvertible { case invalid(String) diff --git a/Tests/TerminalProgressTests/ProgressBarTests.swift b/Tests/TerminalProgressTests/ProgressBarTests.swift index 7232abf8c..8c7b02058 100644 --- a/Tests/TerminalProgressTests/ProgressBarTests.swift +++ b/Tests/TerminalProgressTests/ProgressBarTests.swift @@ -750,4 +750,175 @@ final class ProgressBarTests: XCTestCase { let output = progress.draw() XCTAssertEqual(output, "⠋ Task 50% (1 of 2 files) [0s]") } + + func testPlainModeConfig() async throws { + let config = try ProgressConfig( + description: "Task", + showSpinner: false, + outputMode: .plain + ) + XCTAssertEqual(config.outputMode, .plain) + } + + func testPlainModeDraw() async throws { + let config = try ProgressConfig( + description: "Task", + showSpinner: false, + outputMode: .plain + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "Task [0s]") + } + + func testPlainModeDrawWithTasks() async throws { + let config = try ProgressConfig( + description: "Task", + showSpinner: false, + showTasks: true, + totalTasks: 2, + outputMode: .plain + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "[0/2] Task [0s]") + } + + func testPlainModeDrawWithPercent() async throws { + let config = try ProgressConfig( + description: "Task", + showSpinner: false, + showItems: true, + totalItems: 2, + outputMode: .plain + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + let output = progress.draw() + XCTAssertEqual(output, "Task 50% (1 of 2 it) [0s]") + } + + func testPlainModeFinished() async throws { + let config = try ProgressConfig( + description: "Task", + showSpinner: false, + showTasks: true, + totalTasks: 2, + clearOnFinish: false, + outputMode: .plain + ) + let progress = ProgressBar(config: config) + progress.set(tasks: 2) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "[2/2] Task [0s]") + } + + func testPlainModeWithSize() async throws { + let config = try ProgressConfig( + description: "Task", + showSpinner: false, + showSize: true, + showSpeed: false, + totalSize: 4, + outputMode: .plain + ) + let progress = ProgressBar(config: config) + progress.set(size: 2) + let output = progress.draw() + XCTAssertEqual(output, "Task 50% (2/4 bytes) [0s]") + } + + func testPlainModeNoProgressBar() async throws { + let config = try ProgressConfig( + description: "Task", + showSpinner: false, + showProgressBar: false, + totalItems: 2, + outputMode: .plain + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + let output = progress.draw() + XCTAssertEqual(output, "Task 50% [0s]") + } + + func testPlainModeNoAnsiEscapes() async throws { + let config = try ProgressConfig( + description: "Task", + showSpinner: false, + outputMode: .plain + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertFalse(output.contains("\u{001B}")) + } + + func testPlainModeTerminalOutput() async throws { + let pipe = Pipe() + let config = try ProgressConfig( + terminal: pipe.fileHandleForWriting, + description: "Task", + showSpinner: false, + clearOnFinish: false, + outputMode: .plain + ) + let progress = ProgressBar(config: config) + progress.render(force: true) + progress.finish() + try pipe.fileHandleForWriting.close() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty } + // Expect exactly 2 lines: one from render, one from finish + XCTAssertEqual(lines.count, 2) + XCTAssertTrue(lines[0].contains("Task")) + XCTAssertTrue(lines[1].contains("Task")) + } + + func testPlainModeTerminalOutputNoAnsiEscapes() async throws { + let pipe = Pipe() + let config = try ProgressConfig( + terminal: pipe.fileHandleForWriting, + description: "Task", + showSpinner: false, + clearOnFinish: false, + outputMode: .plain + ) + let progress = ProgressBar(config: config) + progress.render(force: true) + progress.finish() + try pipe.fileHandleForWriting.close() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + XCTAssertFalse(output.contains("\u{001B}")) + } + + func testPlainModeTerminalOutputUsesNewlines() async throws { + let pipe = Pipe() + let config = try ProgressConfig( + terminal: pipe.fileHandleForWriting, + description: "Task", + showSpinner: false, + clearOnFinish: false, + outputMode: .plain + ) + let progress = ProgressBar(config: config) + progress.render(force: true) + progress.finish() + try pipe.fileHandleForWriting.close() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + // Plain mode should use newlines, not carriage returns + XCTAssertFalse(output.contains("\r")) + XCTAssertTrue(output.contains("\n")) + } + + func testOutputModeDefaultIsAnsi() async throws { + let config = try ProgressConfig(description: "Task") + XCTAssertEqual(config.outputMode, .ansi) + } } diff --git a/docs/command-reference.md b/docs/command-reference.md index 7aea2a329..6bd108754 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -89,7 +89,7 @@ container run [] [ ...] **Progress Options** -* `--progress `: Progress type (format: none|ansi) (default: ansi) +* `--progress `: Progress type (format: none|ansi|plain) (default: ansi) **Examples** @@ -510,7 +510,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) (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) @@ -532,7 +532,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) (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)