Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 10 additions & 0 deletions Sources/ContainerCommands/Container/ContainerRun.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ extension Application {
ignoreSmallSize: true,
totalTasks: 6
)
case .plain:
progressConfig = try ProgressConfig(
showSpinner: false,
showTasks: true,
showItems: true,
ignoreSmallSize: true,
totalTasks: 6,
clearOnFinish: false,
outputMode: .plain
)
}

let progress = ProgressBar(config: progressConfig)
Expand Down
10 changes: 10 additions & 0 deletions Sources/ContainerCommands/Image/ImagePull.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ extension Application {
ignoreSmallSize: true,
totalTasks: 2
)
case .plain:
progressConfig = try ProgressConfig(
showSpinner: false,
showTasks: true,
showItems: true,
ignoreSmallSize: true,
totalTasks: 2,
clearOnFinish: false,
outputMode: .plain
)
}

let progress = ProgressBar(config: progressConfig)
Expand Down
11 changes: 11 additions & 0 deletions Sources/ContainerCommands/Image/ImagePush.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ extension Application {
showSpeed: false,
ignoreSmallSize: true
)
case .plain:
progressConfig = try ProgressConfig(
description: "Pushing image \(image.reference)",
itemsName: "blobs",
showSpinner: false,
showItems: true,
showSpeed: false,
ignoreSmallSize: true,
clearOnFinish: false,
outputMode: .plain
)
}

let progress = ProgressBar(config: progressConfig)
Expand Down
3 changes: 2 additions & 1 deletion Sources/Services/ContainerAPIService/Client/Flags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 3 additions & 1 deletion Sources/TerminalProgress/ProgressBar+State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,13 @@ extension ProgressBar {
}

var startTime: DispatchTime
var lastPlainRenderTime: DispatchTime
var output = ""
var renderTask: Task<Void, Never>?

init(
description: String = "", subDescription: String = "", itemsName: String = "", tasks: Int = 0, totalTasks: Int? = nil, items: Int = 0, totalItems: Int? = nil,
size: Int64 = 0, totalSize: Int64? = nil, startTime: DispatchTime = .now()
size: Int64 = 0, totalSize: Int64? = nil, startTime: DispatchTime = .now(), lastPlainRenderTime: DispatchTime = .now()
) {
self.description = description
self.subDescription = subDescription
Expand All @@ -83,6 +84,7 @@ extension ProgressBar {
self.size = size
self.totalSize = totalSize
self.startTime = startTime
self.lastPlainRenderTime = lastPlainRenderTime
}

private mutating func calculateSizeSpeed() {
Expand Down
31 changes: 21 additions & 10 deletions Sources/TerminalProgress/ProgressBar+Terminal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

Expand Down Expand Up @@ -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..<lineCount {
lines += EscapeSequence.moveUp
switch config.outputMode {
case .plain:
guard !text.isEmpty else { return }
display("\(text)\n")
case .ansi:
// Clears previously printed lines.
var lines = ""
if terminating.hasSuffix("\r") && termWidth > 0 {
let lineCount = (text.count - 1) / termWidth
for _ in 0..<lineCount {
lines += EscapeSequence.moveUp
}
}
}

let output = "\(text)\(EscapeSequence.clearToEndOfLine)\(terminating)\(lines)"
display(output)
let output = "\(text)\(EscapeSequence.clearToEndOfLine)\(terminating)\(lines)"
display(output)
}
}
}
31 changes: 28 additions & 3 deletions Sources/TerminalProgress/ProgressBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,23 @@ public final class ProgressBar: Sendable {
/// - Parameter config: The configuration for the progress bar.
public init(config: ProgressConfig) {
self.config = config
term = isatty(config.terminal.fileDescriptor) == 1 ? config.terminal : nil
switch config.outputMode {
case .ansi:
term = isatty(config.terminal.fileDescriptor) == 1 ? config.terminal : nil
case .plain:
term = config.terminal
}
let state = State(
description: config.initialDescription, itemsName: config.initialItemsName, totalTasks: config.initialTotalTasks,
totalItems: config.initialTotalItems,
totalSize: config.initialTotalSize)
self.state = Mutex(state)
display(EscapeSequence.hideCursor)
switch config.outputMode {
case .ansi:
display(EscapeSequence.hideCursor)
case .plain:
break
}
}

/// Allows resetting the progress state.
Expand Down Expand Up @@ -129,7 +139,12 @@ public final class ProgressBar: Sendable {
if shouldClear {
clear(state: &s)
}
resetCursor()
switch config.outputMode {
case .ansi:
resetCursor()
case .plain:
break
}
}
}
}
Expand Down Expand Up @@ -157,6 +172,16 @@ extension ProgressBar {
guard force || !state.finished else {
return
}

if config.outputMode == .plain && !force {
let now = DispatchTime.now()
let elapsed = now.uptimeNanoseconds - state.lastPlainRenderTime.uptimeNanoseconds
guard elapsed >= 1_000_000_000 else {
return
}
state.lastPlainRenderTime = now
}

let output = draw(state: state)
displayText(output, state: &state)
}
Expand Down
16 changes: 15 additions & 1 deletion Sources/TerminalProgress/ProgressConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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 = "",
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
108 changes: 108 additions & 0 deletions Tests/TerminalProgressTests/ProgressBarTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -750,4 +750,112 @@ 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 testOutputModeDefaultIsAnsi() async throws {
let config = try ProgressConfig(description: "Task")
XCTAssertEqual(config.outputMode, .ansi)
}
}
Loading