Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 4 additions & 3 deletions Sources/ContainerCommands/Flags+ProgressConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ extension Flags.Progress {
///
/// 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`.
/// are disabled and the output mode is set to `.plain`. For `.color`, behavior matches
/// `.ansi` but the output mode is set to `.color` to enable color-coded output.
func makeConfig(
description: String = "",
itemsName: String = "it",
Expand All @@ -35,7 +36,7 @@ extension Flags.Progress {
switch progress {
case .none:
return try ProgressConfig(disableProgressUpdates: true)
case .ansi, .plain:
case .ansi, .plain, .color:
let isPlain = progress == .plain
return try ProgressConfig(
description: description,
Expand All @@ -47,7 +48,7 @@ extension Flags.Progress {
ignoreSmallSize: ignoreSmallSize,
totalTasks: totalTasks,
clearOnFinish: !isPlain,
outputMode: isPlain ? .plain : .ansi
outputMode: isPlain ? .plain : (progress == .color ? .color : .ansi)
Comment thread
dkovba marked this conversation as resolved.
Outdated
)
}
}
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 @@ -338,9 +338,10 @@ public struct Flags {
case none
case ansi
case plain
case color
}

@Option(name: .long, help: ArgumentHelp("Progress type (format: none|ansi|plain)", valueName: "type"))
@Option(name: .long, help: ArgumentHelp("Progress type (format: none|ansi|plain|color)", valueName: "type"))
public var progress: ProgressType = .ansi
}

Expand Down
27 changes: 24 additions & 3 deletions Sources/TerminalProgress/ProgressBar+Terminal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ enum EscapeSequence {
static let showCursor = "\u{001B}[?25h"
static let moveUp = "\u{001B}[1A"
static let clearToEndOfLine = "\u{001B}[K"

// Color codes
static let reset = "\u{001B}[0m"
static let bold = "\u{001B}[1m"
static let dim = "\u{001B}[2m"
static let green = "\u{001B}[32m"
static let yellow = "\u{001B}[33m"
static let cyan = "\u{001B}[36m"

/// Wraps text in an ANSI color code with a reset suffix.
static func colored(_ text: String, _ code: String) -> String {
"\(code)\(text)\(reset)"
}
}

extension ProgressBar {
Expand All @@ -41,7 +54,7 @@ extension ProgressBar {
state.withLock { s in
clear(state: &s)
switch config.outputMode {
case .ansi:
case .ansi, .color:
resetCursor()
case .plain:
break
Expand Down Expand Up @@ -89,11 +102,12 @@ extension ProgressBar {
case .plain:
guard !text.isEmpty else { return }
display("\(text)\(terminating)")
case .ansi:
case .ansi, .color:
// Clears previously printed lines.
var lines = ""
if terminating.hasSuffix("\r") && termWidth > 0 {
let lineCount = (text.count - 1) / termWidth
let textLength = config.outputMode == .color ? text.visibleLength : text.count
let lineCount = (textLength - 1) / termWidth
for _ in 0..<lineCount {
lines += EscapeSequence.moveUp
}
Expand All @@ -104,3 +118,10 @@ extension ProgressBar {
}
}
}

extension String {
/// The visible character count, excluding ANSI escape sequences.
var visibleLength: Int {
replacingOccurrences(of: "\u{001B}\\[[0-9;]*[a-zA-Z]", with: "", options: .regularExpression).count
}
}
47 changes: 32 additions & 15 deletions Sources/TerminalProgress/ProgressBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public final class ProgressBar: Sendable {
public init(config: ProgressConfig) {
self.config = config
switch config.outputMode {
case .ansi:
case .ansi, .color:
term = isatty(config.terminal.fileDescriptor) == 1 ? config.terminal : nil
case .plain:
term = config.terminal
Expand All @@ -45,7 +45,7 @@ public final class ProgressBar: Sendable {
totalSize: config.initialTotalSize)
self.state = Mutex(state)
switch config.outputMode {
case .ansi:
case .ansi, .color:
display(EscapeSequence.hideCursor)
case .plain:
break
Expand Down Expand Up @@ -140,7 +140,7 @@ public final class ProgressBar: Sendable {
clear(state: &s)
}
switch config.outputMode {
case .ansi:
case .ansi, .color:
resetCursor()
case .plain:
break
Expand Down Expand Up @@ -213,7 +213,8 @@ extension ProgressBar {

for detail in DetailLevel.allCases {
let output = draw(state: state, detail: detail)
if output.count <= targetWidth {
let length = config.outputMode == .color ? output.visibleLength : output.count
if length <= targetWidth {
return output
}
}
Expand All @@ -222,30 +223,37 @@ extension ProgressBar {
}

func draw(state: State, detail: DetailLevel) -> String {
let useColor = config.outputMode == .color

/// Wraps text in ANSI color when color mode is active; returns text unchanged otherwise.
func colored(_ text: String, _ code: String) -> String {
useColor ? EscapeSequence.colored(text, code) : text
}

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)
components.append("\(spinnerIcon)")
components.append(colored("\(spinnerIcon)", EscapeSequence.cyan))
} else {
components.append("\(config.theme.done)")
components.append(colored("\(config.theme.done)", EscapeSequence.green))
}
}

// Tasks [x/y] - always shown if configured
if config.showTasks, let totalTasks = state.totalTasks {
let tasks = min(state.tasks, totalTasks)
components.append("[\(tasks)/\(totalTasks)]")
components.append(colored("[\(tasks)/\(totalTasks)]", EscapeSequence.cyan))
}

// Description - dropped at noDescription level
if detail.rawValue < DetailLevel.noDescription.rawValue {
if config.showDescription && !state.description.isEmpty {
components.append("\(state.description)")
components.append(colored("\(state.description)", EscapeSequence.bold))
if !state.subDescription.isEmpty {
components.append("\(state.subDescription)")
components.append(colored("\(state.subDescription)", EscapeSequence.bold))
}
}
}
Expand All @@ -256,17 +264,26 @@ extension ProgressBar {

// Percent - always shown if configured
if config.showPercent && total > 0 && allowProgress {
components.append("\(state.finished ? "100%" : state.percent)")
let percentText = state.finished ? "100%" : state.percent
let percentColor = state.finished ? EscapeSequence.green : EscapeSequence.yellow
components.append(colored(percentText, percentColor))
}

// Progress bar - always shown if configured
if config.showProgressBar, total > 0, allowProgress {
let usedWidth = components.joined(separator: " ").count + 45
let joinedComponents = components.joined(separator: " ")
let usedWidth = (useColor ? joinedComponents.visibleLength : joinedComponents.count) + 45
Comment thread
dkovba marked this conversation as resolved.
let remainingWidth = max(config.width - usedWidth, 1)
let barLength = min(remainingWidth, 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)|")
if useColor {
let filledBar = EscapeSequence.colored(String(repeating: config.theme.bar, count: barLength), EscapeSequence.green)
let emptyBar = String(repeating: " ", count: barPaddingLength)
components.append("|\(filledBar)\(emptyBar)|")
} else {
let bar = "\(String(repeating: config.theme.bar, count: barLength))\(String(repeating: " ", count: barPaddingLength))"
components.append("|\(bar)|")
}
}

// Additional components in parens - progressively dropped
Expand Down Expand Up @@ -340,15 +357,15 @@ extension ProgressBar {

if additionalComponents.count > 0 {
let joinedAdditionalComponents = additionalComponents.joined(separator: ", ")
components.append("(\(joinedAdditionalComponents))")
components.append(colored("(\(joinedAdditionalComponents))", EscapeSequence.dim))
}
}

// 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)]")
components.append(colored("[\(formattedTime)]", EscapeSequence.dim))
}

return components.joined(separator: " ")
Expand Down
2 changes: 2 additions & 0 deletions Sources/TerminalProgress/ProgressConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ extension ProgressConfig {
case ansi
/// Plain text mode with newline-separated output, no ANSI codes.
case plain
/// ANSI escape code mode with cursor control and color-coded output.
case color
}

/// An enumeration of errors that can occur when creating a `ProgressConfig`.
Expand Down
Loading
Loading