Skip to content

Commit 1c37384

Browse files
authored
Add Color Progress Output Mode (apple#1384)
- Adds a color progress output mode (`--progress color`) that renders ANSI-colored progress output with visual differentiation between progress states. - Like `ansi` mode, this feature requires a TTY. - Closes apple#1366
1 parent 6b950f4 commit 1c37384

7 files changed

Lines changed: 337 additions & 25 deletions

File tree

Sources/ContainerCommands/Flags+ProgressConfig.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ extension Flags.Progress {
2222
///
2323
/// For `.none`, progress updates are disabled. For `.ansi`, the given parameters
2424
/// are used as-is. For `.plain`, ANSI-incompatible features (spinner, clear on finish)
25-
/// are disabled and the output mode is set to `.plain`.
25+
/// are disabled and the output mode is set to `.plain`. For `.color`, behavior matches
26+
/// `.ansi` but the output mode is set to `.color` to enable color-coded output.
2627
func makeConfig(
2728
description: String = "",
2829
itemsName: String = "it",
@@ -35,8 +36,14 @@ extension Flags.Progress {
3536
switch progress {
3637
case .none:
3738
return try ProgressConfig(disableProgressUpdates: true)
38-
case .ansi, .plain:
39+
case .ansi, .plain, .color:
3940
let isPlain = progress == .plain
41+
let outputMode: ProgressConfig.OutputMode
42+
switch progress {
43+
case .plain: outputMode = .plain
44+
case .color: outputMode = .color
45+
default: outputMode = .ansi
46+
}
4047
return try ProgressConfig(
4148
description: description,
4249
itemsName: itemsName,
@@ -47,7 +54,7 @@ extension Flags.Progress {
4754
ignoreSmallSize: ignoreSmallSize,
4855
totalTasks: totalTasks,
4956
clearOnFinish: !isPlain,
50-
outputMode: isPlain ? .plain : .ansi
57+
outputMode: outputMode
5158
)
5259
}
5360
}

Sources/Services/ContainerAPIService/Client/Flags.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,9 +354,10 @@ public struct Flags {
354354
case none
355355
case ansi
356356
case plain
357+
case color
357358
}
358359

359-
@Option(name: .long, help: ArgumentHelp("Progress type (format: none|ansi|plain)", valueName: "type"))
360+
@Option(name: .long, help: ArgumentHelp("Progress type (format: none|ansi|plain|color)", valueName: "type"))
360361
public var progress: ProgressType = .ansi
361362
}
362363

Sources/TerminalProgress/ProgressBar+Terminal.swift

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@ enum EscapeSequence {
2222
static let showCursor = "\u{001B}[?25h"
2323
static let moveUp = "\u{001B}[1A"
2424
static let clearToEndOfLine = "\u{001B}[K"
25+
26+
// Color codes
27+
static let reset = "\u{001B}[0m"
28+
static let bold = "\u{001B}[1m"
29+
static let dim = "\u{001B}[2m"
30+
static let green = "\u{001B}[32m"
31+
static let yellow = "\u{001B}[33m"
32+
static let cyan = "\u{001B}[36m"
33+
34+
/// Wraps text in an ANSI color code with a reset suffix.
35+
static func colored(_ text: String, _ code: String) -> String {
36+
"\(code)\(text)\(reset)"
37+
}
2538
}
2639

2740
extension ProgressBar {
@@ -41,7 +54,7 @@ extension ProgressBar {
4154
state.withLock { s in
4255
clear(state: &s)
4356
switch config.outputMode {
44-
case .ansi:
57+
case .ansi, .color:
4558
resetCursor()
4659
case .plain:
4760
break
@@ -89,11 +102,12 @@ extension ProgressBar {
89102
case .plain:
90103
guard !text.isEmpty else { return }
91104
display("\(text)\(terminating)")
92-
case .ansi:
105+
case .ansi, .color:
93106
// Clears previously printed lines.
94107
var lines = ""
95108
if terminating.hasSuffix("\r") && termWidth > 0 {
96-
let lineCount = (text.count - 1) / termWidth
109+
let textLength = config.outputMode == .color ? text.visibleLength : text.count
110+
let lineCount = (textLength - 1) / termWidth
97111
for _ in 0..<lineCount {
98112
lines += EscapeSequence.moveUp
99113
}
@@ -104,3 +118,10 @@ extension ProgressBar {
104118
}
105119
}
106120
}
121+
122+
extension String {
123+
/// The visible character count, excluding ANSI escape sequences.
124+
var visibleLength: Int {
125+
replacingOccurrences(of: "\u{001B}\\[[0-9;]*[a-zA-Z]", with: "", options: .regularExpression).count
126+
}
127+
}

Sources/TerminalProgress/ProgressBar.swift

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public final class ProgressBar: Sendable {
3434
public init(config: ProgressConfig) {
3535
self.config = config
3636
switch config.outputMode {
37-
case .ansi:
37+
case .ansi, .color:
3838
term = isatty(config.terminal.fileDescriptor) == 1 ? config.terminal : nil
3939
case .plain:
4040
term = config.terminal
@@ -45,7 +45,7 @@ public final class ProgressBar: Sendable {
4545
totalSize: config.initialTotalSize)
4646
self.state = Mutex(state)
4747
switch config.outputMode {
48-
case .ansi:
48+
case .ansi, .color:
4949
display(EscapeSequence.hideCursor)
5050
case .plain:
5151
break
@@ -140,7 +140,7 @@ public final class ProgressBar: Sendable {
140140
clear(state: &s)
141141
}
142142
switch config.outputMode {
143-
case .ansi:
143+
case .ansi, .color:
144144
resetCursor()
145145
case .plain:
146146
break
@@ -213,7 +213,8 @@ extension ProgressBar {
213213

214214
for detail in DetailLevel.allCases {
215215
let output = draw(state: state, detail: detail)
216-
if output.count <= targetWidth {
216+
let length = config.outputMode == .color ? output.visibleLength : output.count
217+
if length <= targetWidth {
217218
return output
218219
}
219220
}
@@ -222,30 +223,37 @@ extension ProgressBar {
222223
}
223224

224225
func draw(state: State, detail: DetailLevel) -> String {
226+
let useColor = config.outputMode == .color
227+
228+
/// Wraps text in ANSI color when color mode is active; returns text unchanged otherwise.
229+
func colored(_ text: String, _ code: String) -> String {
230+
useColor ? EscapeSequence.colored(text, code) : text
231+
}
232+
225233
var components = [String]()
226234

227235
// Spinner - always shown if configured (unless using progress bar)
228236
if config.showSpinner && !config.showProgressBar {
229237
if !state.finished {
230238
let spinnerIcon = config.theme.getSpinnerIcon(state.iteration)
231-
components.append("\(spinnerIcon)")
239+
components.append(colored("\(spinnerIcon)", EscapeSequence.cyan))
232240
} else {
233-
components.append("\(config.theme.done)")
241+
components.append(colored("\(config.theme.done)", EscapeSequence.green))
234242
}
235243
}
236244

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

243251
// Description - dropped at noDescription level
244252
if detail.rawValue < DetailLevel.noDescription.rawValue {
245253
if config.showDescription && !state.description.isEmpty {
246-
components.append("\(state.description)")
254+
components.append(colored("\(state.description)", EscapeSequence.bold))
247255
if !state.subDescription.isEmpty {
248-
components.append("\(state.subDescription)")
256+
components.append(colored("\(state.subDescription)", EscapeSequence.bold))
249257
}
250258
}
251259
}
@@ -256,17 +264,27 @@ extension ProgressBar {
256264

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

262272
// Progress bar - always shown if configured
263273
if config.showProgressBar, total > 0, allowProgress {
264-
let usedWidth = components.joined(separator: " ").count + 45
274+
let joinedComponents = components.joined(separator: " ")
275+
// 45 reserves space for components rendered after the bar (size, speed, time, etc.)
276+
let usedWidth = (useColor ? joinedComponents.visibleLength : joinedComponents.count) + 45
265277
let remainingWidth = max(config.width - usedWidth, 1)
266278
let barLength = min(remainingWidth, state.finished ? remainingWidth : Int(Int64(remainingWidth) * value / total))
267279
let barPaddingLength = remainingWidth - barLength
268-
let bar = "\(String(repeating: config.theme.bar, count: barLength))\(String(repeating: " ", count: barPaddingLength))"
269-
components.append("|\(bar)|")
280+
if useColor {
281+
let filledBar = EscapeSequence.colored(String(repeating: config.theme.bar, count: barLength), EscapeSequence.green)
282+
let emptyBar = String(repeating: " ", count: barPaddingLength)
283+
components.append("|\(filledBar)\(emptyBar)|")
284+
} else {
285+
let bar = "\(String(repeating: config.theme.bar, count: barLength))\(String(repeating: " ", count: barPaddingLength))"
286+
components.append("|\(bar)|")
287+
}
270288
}
271289

272290
// Additional components in parens - progressively dropped
@@ -340,15 +358,15 @@ extension ProgressBar {
340358

341359
if additionalComponents.count > 0 {
342360
let joinedAdditionalComponents = additionalComponents.joined(separator: ", ")
343-
components.append("(\(joinedAdditionalComponents))")
361+
components.append(colored("(\(joinedAdditionalComponents))", EscapeSequence.dim))
344362
}
345363
}
346364

347365
// Time - dropped at noTime level
348366
if detail.rawValue < DetailLevel.noTime.rawValue && config.showTime {
349367
let timeDifferenceSeconds = secondsSinceStart(from: state.startTime)
350368
let formattedTime = timeDifferenceSeconds.formattedTime()
351-
components.append("[\(formattedTime)]")
369+
components.append(colored("[\(formattedTime)]", EscapeSequence.dim))
352370
}
353371

354372
return components.joined(separator: " ")

Sources/TerminalProgress/ProgressConfig.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ extension ProgressConfig {
169169
case ansi
170170
/// Plain text mode with newline-separated output, no ANSI codes.
171171
case plain
172+
/// ANSI escape code mode with cursor control and color-coded output.
173+
case color
172174
}
173175

174176
/// An enumeration of errors that can occur when creating a `ProgressConfig`.

0 commit comments

Comments
 (0)