diff --git a/Package.resolved b/Package.resolved index 5fb976ba..c6259c6c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "cb5cf6c245fe60f647e4e106917dc2e6559eda5170af37e63e52e549b2eed566", + "originHash" : "21dad499f34492edb861e54fe1e03ee3b00aa3d7371af6b09253bf04495427b9", "pins" : [ { "identity" : "async-http-client", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/containerization.git", "state" : { - "revision" : "56c06be9a9473b6df9031dd217551e6ad9a97d29", - "version" : "0.1.0" + "revision" : "4b05e5f2313e881ee048f7063b30e73070fbd1b1", + "version" : "0.1.1" } }, { diff --git a/Package.swift b/Package.swift index 1ca4e9d1..084d16b7 100644 --- a/Package.swift +++ b/Package.swift @@ -26,7 +26,7 @@ if let path = ProcessInfo.processInfo.environment["CONTAINERIZATION_PATH"] { scDependency = .package(path: path) scVersion = "latest" } else { - scVersion = "0.1.0" + scVersion = "0.1.1" scDependency = .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)) } diff --git a/Sources/ContainerXPC/XPCClient.swift b/Sources/ContainerXPC/XPCClient.swift index 16bea468..dbe07bce 100644 --- a/Sources/ContainerXPC/XPCClient.swift +++ b/Sources/ContainerXPC/XPCClient.swift @@ -19,7 +19,6 @@ import ContainerizationError import Foundation public struct XPCClient: Sendable { - // Access to `connection` is protected by a lock private nonisolated(unsafe) let connection: xpc_connection_t private let q: DispatchQueue? private let service: String diff --git a/Sources/TerminalProgress/ProgressBar+Add.swift b/Sources/TerminalProgress/ProgressBar+Add.swift index 953b308d..7f5a68f9 100644 --- a/Sources/TerminalProgress/ProgressBar+Add.swift +++ b/Sources/TerminalProgress/ProgressBar+Add.swift @@ -61,23 +61,24 @@ extension ProgressBar { /// Performs a check to see if the progress bar should be finished. public func checkIfFinished() { - if let totalTasks = state.totalTasks { + var finished = true + var defined = false + if let totalTasks = state.totalTasks, totalTasks > 0 { // For tasks, we're showing the current task rather then the number of completed tasks. - guard state.tasks > totalTasks else { - return - } + finished = finished && state.tasks == totalTasks + defined = true } - if let totalItems = state.totalItems { - guard state.items == totalItems else { - return - } + if let totalItems = state.totalItems, totalItems > 0 { + finished = finished && state.items == totalItems + defined = true } - if let totalSize = state.totalSize { - guard state.size == totalSize else { - return - } + if let totalSize = state.totalSize, totalSize > 0 { + finished = finished && state.size == totalSize + defined = true + } + if defined && finished { + finish() } - finish() } /// Sets the current tasks. @@ -92,9 +93,14 @@ extension ProgressBar { /// Performs an addition to the current tasks. /// - Parameter tasks: The tasks to add to the current tasks. - public func add(tasks toAdd: Int, render: Bool = true) { - let newTasks = state.tasks + toAdd - set(tasks: newTasks, render: render) + public func add(tasks delta: Int, render: Bool = true) { + _state.withLock { + let newTasks = $0.tasks + delta + $0.tasks = newTasks + } + if render { + self.render() + } } /// Sets the total tasks. @@ -108,10 +114,15 @@ extension ProgressBar { /// Performs an addition to the total tasks. /// - Parameter totalTasks: The tasks to add to the total tasks. - public func add(totalTasks toAdd: Int, render: Bool = true) { - let totalTasks = state.totalTasks ?? 0 - let newTotalTasks = totalTasks + toAdd - set(totalTasks: newTotalTasks, render: render) + public func add(totalTasks delta: Int, render: Bool = true) { + _state.withLock { + let totalTasks = $0.totalTasks ?? 0 + let newTotalTasks = totalTasks + delta + $0.totalTasks = newTotalTasks + } + if render { + self.render() + } } /// Sets the items name. @@ -134,9 +145,14 @@ extension ProgressBar { /// Performs an addition to the current items. /// - Parameter items: The items to add to the current items. - public func add(items toAdd: Int, render: Bool = true) { - let newItems = state.items + toAdd - set(items: newItems, render: render) + public func add(items delta: Int, render: Bool = true) { + _state.withLock { + let newItems = $0.items + delta + $0.items = newItems + } + if render { + self.render() + } } /// Sets the total items. @@ -150,10 +166,15 @@ extension ProgressBar { /// Performs an addition to the total items. /// - Parameter totalItems: The items to add to the total items. - public func add(totalItems toAdd: Int, render: Bool = true) { - let totalItems = state.totalItems ?? 0 - let newTotalItems = totalItems + toAdd - set(totalItems: newTotalItems, render: render) + public func add(totalItems delta: Int, render: Bool = true) { + _state.withLock { + let totalItems = $0.totalItems ?? 0 + let newTotalItems = totalItems + delta + $0.totalItems = newTotalItems + } + if render { + self.render() + } } /// Sets the current size. @@ -167,9 +188,14 @@ extension ProgressBar { /// Performs an addition to the current size. /// - Parameter size: The size to add to the current size. - public func add(size toAdd: Int64, render: Bool = true) { - let newSize = state.size + toAdd - set(size: newSize, render: render) + public func add(size delta: Int64, render: Bool = true) { + _state.withLock { + let newSize = $0.size + delta + $0.size = newSize + } + if render { + self.render() + } } /// Sets the total size. @@ -183,9 +209,14 @@ extension ProgressBar { /// Performs an addition to the total size. /// - Parameter totalSize: The size to add to the total size. - public func add(totalSize toAdd: Int64, render: Bool = true) { - let totalSize = state.totalSize ?? 0 - let newTotalSize = totalSize + toAdd - set(totalSize: newTotalSize, render: render) + public func add(totalSize delta: Int64, render: Bool = true) { + _state.withLock { + let totalSize = $0.totalSize ?? 0 + let newTotalSize = totalSize + delta + $0.totalSize = newTotalSize + } + if render { + self.render() + } } } diff --git a/Sources/TerminalProgress/ProgressBar.swift b/Sources/TerminalProgress/ProgressBar.swift index c2810691..a4952965 100644 --- a/Sources/TerminalProgress/ProgressBar.swift +++ b/Sources/TerminalProgress/ProgressBar.swift @@ -20,8 +20,9 @@ import SendableProperty /// A progress bar that updates itself as tasks are completed. public final class ProgressBar: Sendable { let config: ProgressConfig + // `@SendableProperty` adds `_state: Synchronized`, which can be updated inside a lock using `_state.withLock()`. @SendableProperty - var state: State + var state = State() @SendableProperty var printedWidth = 0 let term: FileHandle? @@ -97,7 +98,7 @@ public final class ProgressBar: Sendable { printFullDescription() } - while !isFinished { + while !state.finished { let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000) render() state.iteration += 1 @@ -117,11 +118,15 @@ public final class ProgressBar: Sendable { /// Finishes the progress bar. public func finish() { - guard !isFinished else { + guard !state.finished else { return } state.finished = true + + // The last render. + render(force: true) + if !config.disableProgressUpdates && !config.clearOnFinish { displayText(state.output, terminating: "\n") } @@ -143,8 +148,8 @@ extension ProgressBar { return timeDifferenceSeconds } - func render() { - guard term != nil && !config.disableProgressUpdates && !isFinished else { + func render(force: Bool = false) { + guard term != nil && !config.disableProgressUpdates && (force || !state.finished) else { return } let output = draw() @@ -154,8 +159,12 @@ extension ProgressBar { func draw() -> String { var components = [String]() if config.showSpinner && !config.showProgressBar { - let spinnerIcon = config.theme.getSpinnerIcon(state.iteration) - components.append("\(spinnerIcon)") + if !state.finished { + let spinnerIcon = config.theme.getSpinnerIcon(state.iteration) + components.append("\(spinnerIcon)") + } else { + components.append("\(config.theme.done)") + } } if config.showTasks, let totalTasks = state.totalTasks { @@ -176,13 +185,13 @@ extension ProgressBar { let total = state.totalSize ?? Int64(state.totalItems ?? 0) if config.showPercent && total > 0 && allowProgress { - components.append("\(state.percent)") + components.append("\(state.finished ? "100%" : state.percent)") } if config.showProgressBar, total > 0, allowProgress { let usedWidth = components.joined(separator: " ").count + 45 /* the maximum number of characters we may need */ let remainingWidth = max(config.width - usedWidth, 1 /* the minumum width of a progress bar */) - let barLength = Int(Int64(remainingWidth) * value / total) + 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)|") @@ -195,40 +204,56 @@ extension ProgressBar { if !state.itemsName.isEmpty { itemsName = " \(state.itemsName)" } - if let totalItems = state.totalItems { - additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)") + if state.finished { + if let totalItems = state.totalItems { + additionalComponents.append("\(totalItems.formattedNumber())\(itemsName)") + } } else { - additionalComponents.append("\(state.items.formattedNumber())\(itemsName)") + if let totalItems = state.totalItems { + additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)") + } else { + additionalComponents.append("\(state.items.formattedNumber())\(itemsName)") + } } } if state.size > 0 && allowProgress { - var formattedCombinedSize = "" - if config.showSize { - var formattedSize = state.size.formattedSize() - formattedSize = adjustFormattedSize(formattedSize) - if let totalSize = state.totalSize { - var formattedTotalSize = totalSize.formattedSize() - formattedTotalSize = adjustFormattedSize(formattedTotalSize) - formattedCombinedSize = combineSize(size: formattedSize, totalSize: formattedTotalSize) - } else { - formattedCombinedSize = formattedSize + if state.finished { + if config.showSize { + if let totalSize = state.totalSize { + var formattedTotalSize = totalSize.formattedSize() + formattedTotalSize = adjustFormattedSize(formattedTotalSize) + additionalComponents.append(formattedTotalSize) + } + } + } else { + var formattedCombinedSize = "" + if config.showSize { + var formattedSize = state.size.formattedSize() + formattedSize = adjustFormattedSize(formattedSize) + if let totalSize = state.totalSize { + var formattedTotalSize = totalSize.formattedSize() + formattedTotalSize = adjustFormattedSize(formattedTotalSize) + formattedCombinedSize = combineSize(size: formattedSize, totalSize: formattedTotalSize) + } else { + formattedCombinedSize = formattedSize + } } - } - var formattedSpeed = "" - if config.showSpeed { - formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)" - formattedSpeed = adjustFormattedSize(formattedSpeed) - } + var formattedSpeed = "" + if config.showSpeed { + formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)" + formattedSpeed = adjustFormattedSize(formattedSpeed) + } - if config.showSize && config.showSpeed { - additionalComponents.append(formattedCombinedSize) - additionalComponents.append(formattedSpeed) - } else if config.showSize { - additionalComponents.append(formattedCombinedSize) - } else if config.showSpeed { - additionalComponents.append(formattedSpeed) + if config.showSize && config.showSpeed { + additionalComponents.append(formattedCombinedSize) + additionalComponents.append(formattedSpeed) + } else if config.showSize { + additionalComponents.append(formattedCombinedSize) + } else if config.showSpeed { + additionalComponents.append(formattedSpeed) + } } } diff --git a/Sources/TerminalProgress/ProgressTheme.swift b/Sources/TerminalProgress/ProgressTheme.swift index fc537d40..96577261 100644 --- a/Sources/TerminalProgress/ProgressTheme.swift +++ b/Sources/TerminalProgress/ProgressTheme.swift @@ -18,13 +18,16 @@ public protocol ProgressTheme: Sendable { /// The icons used to represent a spinner. var spinner: [String] { get } - /// The icons used to represent a progress bar. + /// The icon used to represent a progress bar. var bar: String { get } + /// The icon used to indicate that a progress bar finished. + var done: String { get } } public struct DefaultProgressTheme: ProgressTheme { public let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] public let bar = "█" + public let done = "✔" } extension ProgressTheme { diff --git a/Tests/TerminalProgressTests/ProgressBarTests.swift b/Tests/TerminalProgressTests/ProgressBarTests.swift index a1eb9b3f..2b432e03 100644 --- a/Tests/TerminalProgressTests/ProgressBarTests.swift +++ b/Tests/TerminalProgressTests/ProgressBarTests.swift @@ -30,6 +30,16 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task [0s]") } + func testSpinnerFinished() async throws { + let config = try ProgressConfig( + description: "Task" + ) + let progress = ProgressBar(config: config) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task [0s]") + } + func testNoSpinner() async throws { let config = try ProgressConfig( description: "Task", @@ -40,6 +50,17 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "Task [0s]") } + func testNoSpinnerFinished() async throws { + let config = try ProgressConfig( + description: "Task", + showSpinner: false + ) + let progress = ProgressBar(config: config) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "Task [0s]") + } + func testNoTasks() async throws { let config = try ProgressConfig( description: "Task", @@ -93,6 +114,18 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ [0/2] Task [0s]") } + func testTotalTasksFinished() async throws { + let config = try ProgressConfig( + description: "Task", + showTasks: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: config) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ [0/2] Task [0s]") + } + func testTotalTasksAdd() async throws { let config = try ProgressConfig( description: "Task", @@ -176,6 +209,19 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task 50% [0s]") } + func testPercentItemsFinished() async throws { + let config = try ProgressConfig( + description: "Task", + showPercent: true, + totalItems: 2 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task 100% [0s]") + } + func testPercentSize() async throws { let config = try ProgressConfig( description: "Task", @@ -190,6 +236,21 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task 50% [0s]") } + func testPercentSizeFinished() async throws { + let config = try ProgressConfig( + description: "Task", + showPercent: true, + showSize: false, + showSpeed: false, + totalSize: 2 + ) + let progress = ProgressBar(config: config) + progress.set(size: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task 100% [0s]") + } + func testNoProgressBar() async throws { let config = try ProgressConfig( description: "Task", @@ -216,6 +277,20 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "Task 50% |██ | [0s]") } + func testProgressBarFinished() async throws { + let config = try ProgressConfig( + description: "Task", + showProgressBar: true, + totalItems: 2, + width: 57 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "Task 100% |███| [0s]") + } + func testProgressBarMinWidth() async throws { let config = try ProgressConfig( description: "Task", @@ -229,6 +304,20 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "Task 50% | | [0s]") } + func testProgressBarMinWidthFinished() async throws { + let config = try ProgressConfig( + description: "Task", + showProgressBar: true, + totalItems: 2, + width: 13 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "Task 100% |█| [0s]") + } + func testNoItems() async throws { let config = try ProgressConfig( description: "Task", @@ -260,6 +349,18 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task (1 it) [0s]") } + func testItemsAddFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true + ) + let progress = ProgressBar(config: config) + progress.add(items: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task [0s]") + } + func testItemsSet() async throws { let config = try ProgressConfig( description: "Task", @@ -294,6 +395,19 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task 50% (1 of 2 it) [0s]") } + func testTotalItemsFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true, + totalItems: 2 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task 100% (2 it) [0s]") + } + func testTotalItemsAdd() async throws { let config = try ProgressConfig( description: "Task", @@ -361,6 +475,19 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task (1 byte) [0s]") } + func testSizeAddFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true, + showSpeed: false + ) + let progress = ProgressBar(config: config) + progress.add(size: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task [0s]") + } + func testSizeSet() async throws { let config = try ProgressConfig( description: "Task", @@ -397,6 +524,20 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task 50% (1 byte/2 bytes) [0s]") } + func testTotalSizeDifferentUnitsFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true, + showSpeed: false, + totalSize: 2 + ) + let progress = ProgressBar(config: config) + progress.set(size: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task 100% (2 bytes) [0s]") + } + func testTotalSizeSameUnits() async throws { let config = try ProgressConfig( description: "Task", @@ -410,6 +551,20 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task 50% (2/4 bytes) [0s]") } + func testTotalSizeSameUnitsFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true, + showSpeed: false, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(size: 2) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task 100% (4 bytes) [0s]") + } + func testTotalSizeAdd() async throws { let config = try ProgressConfig( description: "Task", @@ -463,6 +618,23 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task 50% (1 of 2 it, 2/4 bytes) [0s]") } + func testItemsAndSizeFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true, + showSize: true, + showSpeed: false, + totalItems: 2, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.set(size: 2) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task 100% (2 it, 4 bytes) [0s]") + } + func testNoSpeed() async throws { let config = try ProgressConfig( description: "Task", @@ -487,6 +659,19 @@ final class ProgressBarTests: XCTestCase { XCTAssertTrue(output.contains("/s")) } + func testSpeedFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showSpeed: true, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(size: 2) + progress.finish() + let output = progress.draw() + XCTAssertFalse(output.contains("/s")) + } + func testItemsSizeAndSpeed() async throws { let config = try ProgressConfig( description: "Task", @@ -504,6 +689,24 @@ final class ProgressBarTests: XCTestCase { XCTAssertTrue(output.contains("/s")) } + func testItemsSizeAndSpeedFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true, + showSize: true, + showSpeed: true, + totalItems: 2, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.set(size: 2) + progress.finish() + let output = progress.draw() + XCTAssertTrue(output.contains("2 it, 4 bytes")) + XCTAssertFalse(output.contains("/s")) + } + func testNoTime() async throws { let config = try ProgressConfig( description: "Task",