From 9fa6253c322bcc332fb4fc96bac76ac9edd17203 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 25 Jul 2025 17:45:04 +0200 Subject: [PATCH 01/10] Fail the activity indicator when the task fails --- Sources/ConsoleKit/Activity/ActivityIndicator.swift | 7 ++++--- Sources/ConsoleKit/Activity/CustomActivity.swift | 2 -- Sources/ConsoleKit/Activity/LoadingBar.swift | 1 - Sources/ConsoleKit/Activity/ProgressBar.swift | 2 +- Tests/ConsoleKitTests/ActivityTests.swift | 5 +---- 5 files changed, 6 insertions(+), 11 deletions(-) diff --git a/Sources/ConsoleKit/Activity/ActivityIndicator.swift b/Sources/ConsoleKit/Activity/ActivityIndicator.swift index 30c44ec8..122e3c3b 100644 --- a/Sources/ConsoleKit/Activity/ActivityIndicator.swift +++ b/Sources/ConsoleKit/Activity/ActivityIndicator.swift @@ -20,7 +20,6 @@ extension ActivityIndicatorType { /// let loadingBar = console.loadingBar(title: "Loading") /// try await foo.withActivityIndicator { /// try await Task.sleep(for: .seconds(2.5)) -/// return true /// } /// ``` /// @@ -109,7 +108,8 @@ public final class ActivityIndicator: Sendable where A: ActivityIndicatorType /// - Parameters: /// - refreshRate: The time interval (specified in milliseconds) to use when updating the activity. /// - body: The asynchronous body to execute while the activity indicator is running. - public func withActivityIndicator(refreshRate: Int = 40, _ body: () async throws -> Bool) async rethrows { + @discardableResult + public func withActivityIndicator(refreshRate: Int = 40, _ body: () async throws -> T) async rethrows -> T { let task = Task { await self.start(refreshRate: refreshRate) } @@ -117,7 +117,8 @@ public final class ActivityIndicator: Sendable where A: ActivityIndicatorType do { let result = try await body() task.cancel() - result ? self.succeed() : self.fail() + self.succeed() + return result } catch { task.cancel() self.fail() diff --git a/Sources/ConsoleKit/Activity/CustomActivity.swift b/Sources/ConsoleKit/Activity/CustomActivity.swift index da0fbd2e..c3feac24 100644 --- a/Sources/ConsoleKit/Activity/CustomActivity.swift +++ b/Sources/ConsoleKit/Activity/CustomActivity.swift @@ -8,7 +8,6 @@ extension Console { /// try await indicator.withActivityIndicator { /// // complete the indicator after 3 seconds /// try await Task.sleep(for: .seconds(3)) - /// return true /// } /// ``` /// @@ -37,7 +36,6 @@ extension Console { /// try await indicator.withActivityIndicator { /// // complete the indicator after 3 seconds /// try await Task.sleep(for: .seconds(3)) - /// return true /// } /// ``` /// diff --git a/Sources/ConsoleKit/Activity/LoadingBar.swift b/Sources/ConsoleKit/Activity/LoadingBar.swift index 94012d6a..111b7a91 100644 --- a/Sources/ConsoleKit/Activity/LoadingBar.swift +++ b/Sources/ConsoleKit/Activity/LoadingBar.swift @@ -9,7 +9,6 @@ extension Console { /// let loadingBar = console.loadingBar(title: "Loading") /// try await loadingBar.withActivityIndicator { /// try await Task.sleep(for: .seconds(3)) - /// return true /// } /// ``` /// diff --git a/Sources/ConsoleKit/Activity/ProgressBar.swift b/Sources/ConsoleKit/Activity/ProgressBar.swift index de39b0bb..3041605e 100644 --- a/Sources/ConsoleKit/Activity/ProgressBar.swift +++ b/Sources/ConsoleKit/Activity/ProgressBar.swift @@ -10,7 +10,7 @@ extension Console { /// try await progressBar.withActivityIndicator { /// while true { /// if progressBar.activity.currentProgress >= 1.0 { - /// return true + /// return /// } else { /// progressBar.activity.currentProgress += 0.1 /// try await Task.sleep(for: .seconds(0.25)) diff --git a/Tests/ConsoleKitTests/ActivityTests.swift b/Tests/ConsoleKitTests/ActivityTests.swift index 711b54ef..f47d6c45 100644 --- a/Tests/ConsoleKitTests/ActivityTests.swift +++ b/Tests/ConsoleKitTests/ActivityTests.swift @@ -11,7 +11,6 @@ struct ActivityTests { try await foo.withActivityIndicator { try await Task.sleep(for: .seconds(2.5)) - return false } enum TestError: Error { @@ -32,7 +31,7 @@ struct ActivityTests { try await foo.withActivityIndicator { while true { if foo.activity.currentProgress >= 1.0 { - return true + return } else { foo.activity.currentProgress += 0.1 try await Task.sleep(for: .seconds(0.1)) @@ -49,7 +48,6 @@ struct ActivityTests { try await indicator.withActivityIndicator { try await Task.sleep(for: .seconds(3)) - return true } } @@ -62,7 +60,6 @@ struct ActivityTests { try await indicator.withActivityIndicator { try await Task.sleep(for: .seconds(3)) - return true } } From 1fe116f6446b913b17c8c60b23c34a1315534045 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 25 Jul 2025 23:26:02 +0200 Subject: [PATCH 02/10] Add a title to CustomActivity --- .../Activity/ActivityIndicator.swift | 14 +++---- .../ConsoleKit/Activity/CustomActivity.swift | 38 +++++++++++-------- Tests/ConsoleKitTests/ActivityTests.swift | 4 +- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/Sources/ConsoleKit/Activity/ActivityIndicator.swift b/Sources/ConsoleKit/Activity/ActivityIndicator.swift index 122e3c3b..65b79a64 100644 --- a/Sources/ConsoleKit/Activity/ActivityIndicator.swift +++ b/Sources/ConsoleKit/Activity/ActivityIndicator.swift @@ -52,8 +52,8 @@ public final class ActivityIndicator: Sendable where A: ActivityIndicatorType /// - Parameters: /// - refreshRate: The time interval (specified in milliseconds) to use /// when updating the activity. - private func start(refreshRate: Int = 40) async { - guard console.supportsANSICommands else { + private func start(refreshRate: Int) async { + guard self.console.supportsANSICommands else { // Skip animations if the console does not support ANSI commands self.activity.outputActivityIndicator(to: self.console, state: .ready) return @@ -87,18 +87,18 @@ public final class ActivityIndicator: Sendable where A: ActivityIndicatorType /// /// Passes `ActivityIndicatorState.failure` to the `ActivityIndicatorType`. /// - /// Must be called after `start(on:)` and completes the future returned by that method. + /// Must be called after `start(refreshRate:)`. private func fail() { - activity.outputActivityIndicator(to: console, state: .failure) + self.activity.outputActivityIndicator(to: console, state: .failure) } /// Stops the `ActivityIndicator`, yielding a success / done appearance. /// /// Passes `ActivityIndicatorState.success` to the `ActivityIndicatorType`. /// - /// Must be called after `start(on:)` and completes the future returned by that method. + /// Must be called after `start(refreshRate:)`. private func succeed() { - activity.outputActivityIndicator(to: console, state: .success) + self.activity.outputActivityIndicator(to: console, state: .success) } /// Starts the ``ActivityIndicator`` and stops it after the provided body completes. @@ -109,7 +109,7 @@ public final class ActivityIndicator: Sendable where A: ActivityIndicatorType /// - refreshRate: The time interval (specified in milliseconds) to use when updating the activity. /// - body: The asynchronous body to execute while the activity indicator is running. @discardableResult - public func withActivityIndicator(refreshRate: Int = 40, _ body: () async throws -> T) async rethrows -> T { + public func withActivityIndicator(refreshRate: Int = 40, _ body: @Sendable () async throws -> T) async rethrows -> T { let task = Task { await self.start(refreshRate: refreshRate) } diff --git a/Sources/ConsoleKit/Activity/CustomActivity.swift b/Sources/ConsoleKit/Activity/CustomActivity.swift index c3feac24..a08ef53c 100644 --- a/Sources/ConsoleKit/Activity/CustomActivity.swift +++ b/Sources/ConsoleKit/Activity/CustomActivity.swift @@ -3,7 +3,7 @@ extension Console { /// /// ```swift /// // Create an activity indicator with the strings (frames) to loop over as it runs. - /// let indicator = console.activity(frames: ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"]) + /// let indicator = console.activity(title: "Loading", frames: ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"]) /// /// try await indicator.withActivityIndicator { /// // complete the indicator after 3 seconds @@ -15,6 +15,7 @@ extension Console { /// https://github.com/kiliankoe/CLISpinner/blob/master/Sources/CLISpinner/Pattern.swift#L88-L151 /// /// - Parameters: + /// - title: The title of the activity indicator. /// - frames: The strings to loop over as the activity indicator runs. /// - success: The string to replace the indicator with when the operation succeeds. The default value is `[Done]`. /// - failure: The string to replace the indicator with when the operation fails: The default value is `[Failed]`. @@ -22,16 +23,16 @@ extension Console { /// /// - Returns: An ``ActivityIndicator`` that can start and stop the indicator. public func customActivity( - frames: [String], success: String = "[Done]", failure: String = "[Failed]", color: ConsoleColor = .cyan + title: String, frames: [String], success: String = "[Done]", failure: String = "[Failed]", color: ConsoleColor = .cyan ) -> ActivityIndicator { - return CustomActivity(frames: frames, success: success, failure: failure, color: color).newActivity(for: self) + return CustomActivity(title: title, frames: frames, success: success, failure: failure, color: color).newActivity(for: self) } /// Creates an activity indicator with custom frames that are iterated over. /// /// ```swift /// // Create an activity indicator with the strings (frames) to loop over as it runs. - /// let indicator = console.activity(frames: ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"]) + /// let indicator = console.activity(title: "Loading", frames: ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"]) /// /// try await indicator.withActivityIndicator { /// // complete the indicator after 3 seconds @@ -43,15 +44,16 @@ extension Console { /// https://github.com/kiliankoe/CLISpinner/blob/master/Sources/CLISpinner/Pattern.swift#L88-L151 /// /// - Parameters: + /// - title: The title of the activity indicator. /// - frames: The text to loop over as the activity indicator runs. /// - success: The string to replace the indicator with when the operation succeeds. The default value is `[Done]`. /// - failure: The string to replace the indicator with when the operation fails: The default value is `[Failed]`. /// /// - Returns: An ``ActivityIndicator`` that can start and stop the indicator. public func customActivity( - frames: [ConsoleText], success: String = "[Done]", failure: String = "[Failed]" + title: String, frames: [ConsoleText], success: String = "[Done]", failure: String = "[Failed]" ) -> ActivityIndicator { - return CustomActivity(frames: frames, success: success, failure: failure).newActivity(for: self) + return CustomActivity(title: title, frames: frames, success: success, failure: failure).newActivity(for: self) } } @@ -59,6 +61,9 @@ extension Console { /// /// See ``Console/customActivity(frames:success:failure:color:)`` to make one. public struct CustomActivity: ActivityIndicatorType { + /// The title of the activity indicator. + public let title: String + /// The text that will be output on the indicator ticks, each frame corresponding to a single tick in a range of `0...(frames.count - 1)`. /// /// The index of the current frame is figured using the equation `tick % frames.count`, allowing the indicator to run indefinitely. @@ -73,10 +78,12 @@ public struct CustomActivity: ActivityIndicatorType { /// Creates a new ``CustomActivity`` instance. /// /// - Parameters: + /// - title: The title of the activity indicator. /// - frames: The text to loop over as the activity indicator runs. /// - success: The string to replace the indicator with when the operation succeeds. The default value is `[Done]`. /// - failure: The string to replace the indicator with when the operation fails: The default value is `[Failed]`. - public init(frames: [ConsoleText], success: String = "[Done]", failure: String = "[Failed]") { + public init(title: String, frames: [ConsoleText], success: String = "[Done]", failure: String = "[Failed]") { + self.title = title self.frames = frames.count > 0 ? frames : ["".consoleText(color: .cyan)] self.success = success self.failure = failure @@ -85,25 +92,26 @@ public struct CustomActivity: ActivityIndicatorType { /// Creates a new ``CustomActivity`` instance. /// /// - Parameters: + /// - title: The title of the activity indicator. /// - frames: The strings to loop over as the activity indicator runs. /// - success: The string to replace the indicator with when the operation succeeds. The default value is `[Done]`. /// - failure: The string to replace the indicator with when the operation fails: The default value is `[Failed]`. /// - color: The color of text when the frames are displayed. The default value is `.cyan`. - public init(frames: [String], success: String = "[Done]", failure: String = "[Failed]", color: ConsoleColor = .cyan) { - self.init(frames: frames.map { $0.consoleText(color: color) }, success: success, failure: failure) + public init(title: String, frames: [String], success: String = "[Done]", failure: String = "[Failed]", color: ConsoleColor = .cyan) { + self.init(title: title, frames: frames.map { $0.consoleText(color: color) }, success: success, failure: failure) } /// See ``ActivityIndicatorType/outputActivityIndicator(to:state:)``. public func outputActivityIndicator(to console: any Console, state: ActivityIndicatorState) { - let output: ConsoleText + let indicator: ConsoleText switch state { - case .ready: output = frames[0] - case .active(let tick): output = frames[Int(tick) % frames.count] - case .success: output = self.success.consoleText(.success) - case .failure: output = self.failure.consoleText(.error) + case .ready: indicator = frames[0] + case .active(let tick): indicator = frames[Int(tick) % frames.count] + case .success: indicator = self.success.consoleText(.success) + case .failure: indicator = self.failure.consoleText(.error) } - console.output(output) + console.output(indicator + " " + title.consoleText(.plain)) } } diff --git a/Tests/ConsoleKitTests/ActivityTests.swift b/Tests/ConsoleKitTests/ActivityTests.swift index f47d6c45..4595dcee 100644 --- a/Tests/ConsoleKitTests/ActivityTests.swift +++ b/Tests/ConsoleKitTests/ActivityTests.swift @@ -44,7 +44,7 @@ struct ActivityTests { func customIndicator() async throws { let console = Terminal() - let indicator = console.customActivity(frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]) + let indicator = console.customActivity(title: "Loading", frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]) try await indicator.withActivityIndicator { try await Task.sleep(for: .seconds(3)) @@ -56,7 +56,7 @@ struct ActivityTests { let console = Terminal() let frames: [ConsoleText] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - let indicator = console.customActivity(frames: frames) + let indicator = console.customActivity(title: "Loading", frames: frames) try await indicator.withActivityIndicator { try await Task.sleep(for: .seconds(3)) From 24723f179360c1b0db98dd519fa960060eb5b6d8 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 25 Jul 2025 23:39:00 +0200 Subject: [PATCH 03/10] Fix activity indicator bug --- .../ConsoleKit/Activity/ActivityIndicator.swift | 14 ++++++++------ Sources/ConsoleKit/Clear/Console+Ephemeral.swift | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Sources/ConsoleKit/Activity/ActivityIndicator.swift b/Sources/ConsoleKit/Activity/ActivityIndicator.swift index 65b79a64..f2b422ef 100644 --- a/Sources/ConsoleKit/Activity/ActivityIndicator.swift +++ b/Sources/ConsoleKit/Activity/ActivityIndicator.swift @@ -67,12 +67,6 @@ public final class ActivityIndicator: Sendable where A: ActivityIndicatorType var tick: UInt = 0 - defer { - if tick > 0 { - self.console.popEphemeral() - } - } - for await _ in timer { if tick > 0 { self.console.popEphemeral() @@ -117,10 +111,18 @@ public final class ActivityIndicator: Sendable where A: ActivityIndicatorType do { let result = try await body() task.cancel() + _ = await task.result + if self.console.supportsANSICommands, self.console.depth > 0 { + self.console.popEphemeral() + } self.succeed() return result } catch { task.cancel() + _ = await task.result + if self.console.supportsANSICommands, self.console.depth > 0 { + self.console.popEphemeral() + } self.fail() throw error } diff --git a/Sources/ConsoleKit/Clear/Console+Ephemeral.swift b/Sources/ConsoleKit/Clear/Console+Ephemeral.swift index 8dd46600..c1055763 100644 --- a/Sources/ConsoleKit/Clear/Console+Ephemeral.swift +++ b/Sources/ConsoleKit/Clear/Console+Ephemeral.swift @@ -105,7 +105,7 @@ extension Console { /// Tracks how many successive calls to ``Console/pushEphemeral()`` have been made. /// /// Calling ``Console/popEphemeral()`` will decrement this number. - private var depth: Int { + private(set) var depth: Int { get { return (self.userInfo["depth"] as? Int) ?? 0 } set { self.userInfo["depth"] = newValue } } From a6382270e3273bb4e22e16ad4df6a528b947ed34 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 25 Jul 2025 23:48:59 +0200 Subject: [PATCH 04/10] Update DocC --- Sources/ConsoleKit/Activity/ActivityIndicator.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/ConsoleKit/Activity/ActivityIndicator.swift b/Sources/ConsoleKit/Activity/ActivityIndicator.swift index f2b422ef..08055681 100644 --- a/Sources/ConsoleKit/Activity/ActivityIndicator.swift +++ b/Sources/ConsoleKit/Activity/ActivityIndicator.swift @@ -97,8 +97,6 @@ public final class ActivityIndicator: Sendable where A: ActivityIndicatorType /// Starts the ``ActivityIndicator`` and stops it after the provided body completes. /// - /// The body must return a `Bool` indicating whether the activity was successful or not. - /// /// - Parameters: /// - refreshRate: The time interval (specified in milliseconds) to use when updating the activity. /// - body: The asynchronous body to execute while the activity indicator is running. From ec32832cd83fb0ca26b2487c04e5f15a69191ff9 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 25 Jul 2025 23:52:01 +0200 Subject: [PATCH 05/10] Simplify code --- Sources/ConsoleKit/Activity/CustomActivity.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Sources/ConsoleKit/Activity/CustomActivity.swift b/Sources/ConsoleKit/Activity/CustomActivity.swift index a08ef53c..a251e955 100644 --- a/Sources/ConsoleKit/Activity/CustomActivity.swift +++ b/Sources/ConsoleKit/Activity/CustomActivity.swift @@ -103,14 +103,13 @@ public struct CustomActivity: ActivityIndicatorType { /// See ``ActivityIndicatorType/outputActivityIndicator(to:state:)``. public func outputActivityIndicator(to console: any Console, state: ActivityIndicatorState) { - let indicator: ConsoleText - - switch state { - case .ready: indicator = frames[0] - case .active(let tick): indicator = frames[Int(tick) % frames.count] - case .success: indicator = self.success.consoleText(.success) - case .failure: indicator = self.failure.consoleText(.error) - } + let indicator: ConsoleText = + switch state { + case .ready: frames[0] + case .active(let tick): frames[Int(tick) % frames.count] + case .success: self.success.consoleText(.success) + case .failure: self.failure.consoleText(.error) + } console.output(indicator + " " + title.consoleText(.plain)) } From 1bf0556b2afec244e8ca5543a5b7eeb9c7b20386 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Sat, 26 Jul 2025 00:19:04 +0200 Subject: [PATCH 06/10] Update tests --- Tests/ConsoleKitTests/ActivityTests.swift | 4 ++++ Tests/ConsoleKitTests/ConsoleTests.swift | 4 +++- Tests/ConsoleKitTests/TerminalTests.swift | 19 +++++++++---------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Tests/ConsoleKitTests/ActivityTests.swift b/Tests/ConsoleKitTests/ActivityTests.swift index 4595dcee..1f1a3a74 100644 --- a/Tests/ConsoleKitTests/ActivityTests.swift +++ b/Tests/ConsoleKitTests/ActivityTests.swift @@ -73,6 +73,10 @@ struct ActivityTests { #expect(dict[AnySendableHashable(ActivityBarWidthKey())] == "width key") #expect(dict[AnySendableHashable("ConsoleKit.ActivityBarWidthKey")] == "string key") + #expect(dict.keys.contains { $0.description == "ActivityBarWidthKey()" }) + #expect(dict.keys.contains { $0.debugDescription == "AnyHashable(ConsoleKit.ActivityBarWidthKey())" }) + #expect(dict.keys.first?.customMirror.displayStyle == nil) + let console = Terminal() #expect(console.activityBarWidth == 25) console.activityBarWidth = 30 diff --git a/Tests/ConsoleKitTests/ConsoleTests.swift b/Tests/ConsoleKitTests/ConsoleTests.swift index dd7960ac..82cdb71c 100644 --- a/Tests/ConsoleKitTests/ConsoleTests.swift +++ b/Tests/ConsoleKitTests/ConsoleTests.swift @@ -1,6 +1,7 @@ -import ConsoleKit import Testing +@testable import ConsoleKit + @Suite("Console Tests") struct ConsoleTests { @Test("Output String") @@ -224,6 +225,7 @@ struct ConsoleTests { #expect(consoleText.endIndex == 4) #expect(consoleText[0].string == "foo") #expect(consoleText.index(after: 0) == 1) + #expect(consoleText.consoleStylized() == "foobarbazqux") var emptyConsoleText: ConsoleText = "" #expect(emptyConsoleText.fragments.isEmpty) diff --git a/Tests/ConsoleKitTests/TerminalTests.swift b/Tests/ConsoleKitTests/TerminalTests.swift index 143e6388..150b530c 100644 --- a/Tests/ConsoleKitTests/TerminalTests.swift +++ b/Tests/ConsoleKitTests/TerminalTests.swift @@ -5,29 +5,28 @@ import Testing struct TerminalTests { @Test("Stylize Foreground") func stylizeForeground() throws { - #expect("TEST".consoleStylized(.init(color: .black)) == "\u{001b}[0;30mTEST\u{001b}[0m") + #expect("TEST".consoleStylized(color: .black) == "\u{001b}[0;30mTEST\u{001b}[0m") } @Test("Stylize Background") func stylizeBackground() throws { - #expect("TEST".consoleStylized(.init(color: .white, background: .red)) == "\u{001b}[0;37;41mTEST\u{001b}[0m") + #expect("TEST".consoleStylized(color: .white, background: .red) == "\u{001b}[0;37;41mTEST\u{001b}[0m") } @Test("Stylize Bold") func stylizeBold() throws { - #expect("TEST".consoleStylized(.init(color: .white, isBold: true)) == "\u{001b}[0;1;37mTEST\u{001b}[0m") + #expect("TEST".consoleStylized(color: .white, isBold: true) == "\u{001b}[0;1;37mTEST\u{001b}[0m") } @Test("Stylize Only Bold") func stylizeOnlyBold() throws { - #expect("TEST".consoleStylized(.init(color: nil, isBold: true)) == "\u{001b}[0;1mTEST\u{001b}[0m") + #expect("TEST".consoleStylized(color: nil, isBold: true) == "\u{001b}[0;1mTEST\u{001b}[0m") } @Test("Stylize All Attributes") func stylizeAllAttrs() throws { #expect( - "TEST".consoleStylized(.init(color: .brightWhite, background: .brightGreen, isBold: true)) - == "\u{001b}[0;1;97;102mTEST\u{001b}[0m" + "TEST".consoleStylized(color: .brightWhite, background: .brightGreen, isBold: true) == "\u{001b}[0;1;97;102mTEST\u{001b}[0m" ) } @@ -38,17 +37,17 @@ struct TerminalTests { @Test("Stylize Palette Color") func stylizePaletteColor() throws { - #expect("TEST".consoleStylized(.init(color: .palette(100))) == "\u{001b}[0;38;5;100mTEST\u{001b}[0m") - #expect("TEST".consoleStylized(.init(color: .white, background: .palette(100))) == "\u{001b}[0;37;48;5;100mTEST\u{001b}[0m") + #expect("TEST".consoleStylized(color: .palette(100)) == "\u{001b}[0;38;5;100mTEST\u{001b}[0m") + #expect("TEST".consoleStylized(color: .white, background: .palette(100)) == "\u{001b}[0;37;48;5;100mTEST\u{001b}[0m") } @Test("Stylize RGB Color") func stylizeRGBColor() throws { #expect( - "TEST".consoleStylized(.init(color: .custom(r: 100, g: 100, b: 100))) == "\u{001b}[0;38;2;100;100;100mTEST\u{001b}[0m" + "TEST".consoleStylized(color: .custom(r: 100, g: 100, b: 100)) == "\u{001b}[0;38;2;100;100;100mTEST\u{001b}[0m" ) #expect( - "TEST".consoleStylized(.init(color: .white, background: .custom(r: 100, g: 100, b: 100))) + "TEST".consoleStylized(color: .white, background: .custom(r: 100, g: 100, b: 100)) == "\u{001b}[0;37;48;2;100;100;100mTEST\u{001b}[0m" ) } From 7dac1c1177e7e3a3c388a740f7520331418a91f5 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Wed, 6 Aug 2025 17:56:07 +0200 Subject: [PATCH 07/10] Remove `@testable` --- Tests/ConsoleKitTests/ActivityTests.swift | 15 +++++---------- Tests/ConsoleKitTests/ConsoleTests.swift | 4 +--- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/Tests/ConsoleKitTests/ActivityTests.swift b/Tests/ConsoleKitTests/ActivityTests.swift index 1f1a3a74..f0dc0278 100644 --- a/Tests/ConsoleKitTests/ActivityTests.swift +++ b/Tests/ConsoleKitTests/ActivityTests.swift @@ -1,7 +1,6 @@ +import ConsoleKit import Testing -@testable import ConsoleKit - @Suite("Activity Tests") struct ActivityTests { @Test("Loading") @@ -66,15 +65,11 @@ struct ActivityTests { @Test("Activity Width Key") func activityWidthKey() { var dict = [AnySendableHashable: String]() + dict[AnySendableHashable("ConsoleKit.Tests")] = "string key" - dict[AnySendableHashable(ActivityBarWidthKey())] = "width key" - dict[AnySendableHashable("ConsoleKit.ActivityBarWidthKey")] = "string key" - - #expect(dict[AnySendableHashable(ActivityBarWidthKey())] == "width key") - #expect(dict[AnySendableHashable("ConsoleKit.ActivityBarWidthKey")] == "string key") - - #expect(dict.keys.contains { $0.description == "ActivityBarWidthKey()" }) - #expect(dict.keys.contains { $0.debugDescription == "AnyHashable(ConsoleKit.ActivityBarWidthKey())" }) + #expect(dict[AnySendableHashable("ConsoleKit.Tests")] == "string key") + #expect(dict.keys.contains { $0.description == "ConsoleKit.Tests" }) + #expect(dict.keys.contains { $0.debugDescription == "AnyHashable(\"ConsoleKit.Tests\")" }) #expect(dict.keys.first?.customMirror.displayStyle == nil) let console = Terminal() diff --git a/Tests/ConsoleKitTests/ConsoleTests.swift b/Tests/ConsoleKitTests/ConsoleTests.swift index 82cdb71c..dd7960ac 100644 --- a/Tests/ConsoleKitTests/ConsoleTests.swift +++ b/Tests/ConsoleKitTests/ConsoleTests.swift @@ -1,7 +1,6 @@ +import ConsoleKit import Testing -@testable import ConsoleKit - @Suite("Console Tests") struct ConsoleTests { @Test("Output String") @@ -225,7 +224,6 @@ struct ConsoleTests { #expect(consoleText.endIndex == 4) #expect(consoleText[0].string == "foo") #expect(consoleText.index(after: 0) == 1) - #expect(consoleText.consoleStylized() == "foobarbazqux") var emptyConsoleText: ConsoleText = "" #expect(emptyConsoleText.fragments.isEmpty) From 8e5e8e35dc2e8f882436d4fb37d8635c13c87628 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Wed, 6 Aug 2025 18:40:43 +0200 Subject: [PATCH 08/10] Formatting --- Sources/ConsoleKit/Activity/ActivityBar.swift | 14 +++++------ .../Activity/ActivityIndicator.swift | 24 +++++++++---------- ...erer.swift => ActivityIndicatorType.swift} | 0 3 files changed, 19 insertions(+), 19 deletions(-) rename Sources/ConsoleKit/Activity/{ActivityIndicatorRenderer.swift => ActivityIndicatorType.swift} (100%) diff --git a/Sources/ConsoleKit/Activity/ActivityBar.swift b/Sources/ConsoleKit/Activity/ActivityBar.swift index 05186c91..0aa52386 100644 --- a/Sources/ConsoleKit/Activity/ActivityBar.swift +++ b/Sources/ConsoleKit/Activity/ActivityBar.swift @@ -25,13 +25,13 @@ public protocol ActivityBar: ActivityIndicatorType { extension ActivityBar { /// See ``ActivityIndicatorType``. public func outputActivityIndicator(to console: any Console, state: ActivityIndicatorState) { - let bar: ConsoleText - switch state { - case .ready: bar = "[...]" - case .active(let tick): bar = renderActiveBar(tick: tick, width: console.activityBarWidth) - case .success: bar = "[Done]".consoleText(.success) - case .failure: bar = "[Failed]".consoleText(.error) - } + let bar: ConsoleText = + switch state { + case .ready: "[...]" + case .active(let tick): renderActiveBar(tick: tick, width: console.activityBarWidth) + case .success: "[Done]".consoleText(.success) + case .failure: "[Failed]".consoleText(.error) + } console.output(title.consoleText(.plain) + " " + bar) } } diff --git a/Sources/ConsoleKit/Activity/ActivityIndicator.swift b/Sources/ConsoleKit/Activity/ActivityIndicator.swift index 08055681..052d6443 100644 --- a/Sources/ConsoleKit/Activity/ActivityIndicator.swift +++ b/Sources/ConsoleKit/Activity/ActivityIndicator.swift @@ -25,7 +25,7 @@ extension ActivityIndicatorType { /// public final class ActivityIndicator: Sendable where A: ActivityIndicatorType { let _activity: Mutex - /// The generic `ActivityIndicatorType` powering this `ActivityIndicator`. + /// The generic ``ActivityIndicatorType`` powering this ``ActivityIndicator``. public var activity: A { get { self._activity.withLock { $0 } @@ -35,19 +35,19 @@ public final class ActivityIndicator: Sendable where A: ActivityIndicatorType } } - /// The `Console` this `ActivityIndicator` is running on. + /// The ``Console`` this ``ActivityIndicator`` is running on. private let console: any Console - /// Creates a new `ActivityIndicator`. Use `ActivityIndicatorType.newActivity(for:)`. + /// Creates a new ``ActivityIndicator``. Use ``ActivityIndicatorType/newActivity(for:)``. init(activity: A, console: any Console) { self.console = console self._activity = Mutex(activity) } - /// Starts the `ActivityIndicator`. Usually this means beginning the associated "loading" animation. + /// Starts the ``ActivityIndicator``. Usually this means beginning the associated "loading" animation. /// - /// Once started, `ActivityIndicator` will continue to redraw the `ActivityIndicatorType` at a fixed - /// refresh rate passing `ActivityIndicatorState.active`. + /// Once started, ``ActivityIndicator`` will continue to redraw the ``ActivityIndicatorType`` at a fixed + /// refresh rate passing ``ActivityIndicatorState/active``. /// /// - Parameters: /// - refreshRate: The time interval (specified in milliseconds) to use @@ -77,20 +77,20 @@ public final class ActivityIndicator: Sendable where A: ActivityIndicatorType } } - /// Stops the `ActivityIndicator`, yielding a failed / error appearance. + /// Stops the ``ActivityIndicator``, yielding a failed / error appearance. /// - /// Passes `ActivityIndicatorState.failure` to the `ActivityIndicatorType`. + /// Passes ``ActivityIndicatorState/failure`` to the ``ActivityIndicatorType``. /// - /// Must be called after `start(refreshRate:)`. + /// Must be called after ``ActivityIndicator/start(refreshRate:)``. private func fail() { self.activity.outputActivityIndicator(to: console, state: .failure) } - /// Stops the `ActivityIndicator`, yielding a success / done appearance. + /// Stops the ``ActivityIndicator``, yielding a success / done appearance. /// - /// Passes `ActivityIndicatorState.success` to the `ActivityIndicatorType`. + /// Passes ``ActivityIndicatorState/success`` to the ``ActivityIndicatorType``. /// - /// Must be called after `start(refreshRate:)`. + /// Must be called after ``ActivityIndicator/start(refreshRate:)``. private func succeed() { self.activity.outputActivityIndicator(to: console, state: .success) } diff --git a/Sources/ConsoleKit/Activity/ActivityIndicatorRenderer.swift b/Sources/ConsoleKit/Activity/ActivityIndicatorType.swift similarity index 100% rename from Sources/ConsoleKit/Activity/ActivityIndicatorRenderer.swift rename to Sources/ConsoleKit/Activity/ActivityIndicatorType.swift From dd03f27030e8ad22e6e7d9b27c09b0030a2cd87f Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Wed, 6 Aug 2025 18:44:45 +0200 Subject: [PATCH 09/10] Add `titleAfterIndicator` to `CustomActivity` --- .../ConsoleKit/Activity/CustomActivity.swift | 68 ++++++++++++++++--- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/Sources/ConsoleKit/Activity/CustomActivity.swift b/Sources/ConsoleKit/Activity/CustomActivity.swift index a251e955..48647214 100644 --- a/Sources/ConsoleKit/Activity/CustomActivity.swift +++ b/Sources/ConsoleKit/Activity/CustomActivity.swift @@ -16,6 +16,7 @@ extension Console { /// /// - Parameters: /// - title: The title of the activity indicator. + /// - titleAfterIndicator: If `true`, the title of the activity indicator will be printed after the indicator itself. /// - frames: The strings to loop over as the activity indicator runs. /// - success: The string to replace the indicator with when the operation succeeds. The default value is `[Done]`. /// - failure: The string to replace the indicator with when the operation fails: The default value is `[Failed]`. @@ -23,9 +24,21 @@ extension Console { /// /// - Returns: An ``ActivityIndicator`` that can start and stop the indicator. public func customActivity( - title: String, frames: [String], success: String = "[Done]", failure: String = "[Failed]", color: ConsoleColor = .cyan + title: String, + titleAfterIndicator: Bool = true, + frames: [String], + success: String = "[Done]", + failure: String = "[Failed]", + color: ConsoleColor = .cyan ) -> ActivityIndicator { - return CustomActivity(title: title, frames: frames, success: success, failure: failure, color: color).newActivity(for: self) + return CustomActivity( + title: title, + titleAfterIndicator: titleAfterIndicator, + frames: frames, + success: success, + failure: failure, + color: color + ).newActivity(for: self) } /// Creates an activity indicator with custom frames that are iterated over. @@ -45,15 +58,26 @@ extension Console { /// /// - Parameters: /// - title: The title of the activity indicator. + /// - titleAfterIndicator: If `true`, the title of the activity indicator will be printed after the indicator itself. /// - frames: The text to loop over as the activity indicator runs. /// - success: The string to replace the indicator with when the operation succeeds. The default value is `[Done]`. /// - failure: The string to replace the indicator with when the operation fails: The default value is `[Failed]`. /// /// - Returns: An ``ActivityIndicator`` that can start and stop the indicator. public func customActivity( - title: String, frames: [ConsoleText], success: String = "[Done]", failure: String = "[Failed]" + title: String, + titleAfterIndicator: Bool = true, + frames: [ConsoleText], + success: String = "[Done]", + failure: String = "[Failed]" ) -> ActivityIndicator { - return CustomActivity(title: title, frames: frames, success: success, failure: failure).newActivity(for: self) + return CustomActivity( + title: title, + titleAfterIndicator: titleAfterIndicator, + frames: frames, + success: success, + failure: failure + ).newActivity(for: self) } } @@ -64,6 +88,10 @@ public struct CustomActivity: ActivityIndicatorType { /// The title of the activity indicator. public let title: String + /// If `true`, the title of the activity indicator will be printed after the indicator itself. + /// If `false`, the title will be printed before the indicator. + public let titleAfterIndicator: Bool + /// The text that will be output on the indicator ticks, each frame corresponding to a single tick in a range of `0...(frames.count - 1)`. /// /// The index of the current frame is figured using the equation `tick % frames.count`, allowing the indicator to run indefinitely. @@ -79,11 +107,19 @@ public struct CustomActivity: ActivityIndicatorType { /// /// - Parameters: /// - title: The title of the activity indicator. + /// - titleAfterIndicator: If `true`, the title of the activity indicator will be printed after the indicator itself. /// - frames: The text to loop over as the activity indicator runs. /// - success: The string to replace the indicator with when the operation succeeds. The default value is `[Done]`. /// - failure: The string to replace the indicator with when the operation fails: The default value is `[Failed]`. - public init(title: String, frames: [ConsoleText], success: String = "[Done]", failure: String = "[Failed]") { + public init( + title: String, + titleAfterIndicator: Bool = true, + frames: [ConsoleText], + success: String = "[Done]", + failure: String = "[Failed]" + ) { self.title = title + self.titleAfterIndicator = titleAfterIndicator self.frames = frames.count > 0 ? frames : ["".consoleText(color: .cyan)] self.success = success self.failure = failure @@ -93,12 +129,26 @@ public struct CustomActivity: ActivityIndicatorType { /// /// - Parameters: /// - title: The title of the activity indicator. + /// - titleAfterIndicator: If `true`, the title of the activity indicator will be printed after the indicator itself. /// - frames: The strings to loop over as the activity indicator runs. /// - success: The string to replace the indicator with when the operation succeeds. The default value is `[Done]`. /// - failure: The string to replace the indicator with when the operation fails: The default value is `[Failed]`. /// - color: The color of text when the frames are displayed. The default value is `.cyan`. - public init(title: String, frames: [String], success: String = "[Done]", failure: String = "[Failed]", color: ConsoleColor = .cyan) { - self.init(title: title, frames: frames.map { $0.consoleText(color: color) }, success: success, failure: failure) + public init( + title: String, + titleAfterIndicator: Bool = true, + frames: [String], + success: String = "[Done]", + failure: String = "[Failed]", + color: ConsoleColor = .cyan + ) { + self.init( + title: title, + titleAfterIndicator: titleAfterIndicator, + frames: frames.map { $0.consoleText(color: color) }, + success: success, + failure: failure + ) } /// See ``ActivityIndicatorType/outputActivityIndicator(to:state:)``. @@ -111,6 +161,8 @@ public struct CustomActivity: ActivityIndicatorType { case .failure: self.failure.consoleText(.error) } - console.output(indicator + " " + title.consoleText(.plain)) + titleAfterIndicator + ? console.output(indicator + " " + title.consoleText(.plain)) + : console.output(title.consoleText(.plain) + " " + indicator) } } From 8efcf6d6783f90a39281015a0ff23f7be4c80bce Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Tue, 19 Aug 2025 18:54:40 +0200 Subject: [PATCH 10/10] Add `withActivityIndicator` convenience wrapper to `ProgressBar` --- .../ConsoleKit/Activity/CustomActivity.swift | 2 +- Sources/ConsoleKit/Activity/LoadingBar.swift | 2 +- Sources/ConsoleKit/Activity/ProgressBar.swift | 33 ++++++++++++++++++- Tests/ConsoleKitTests/ActivityTests.swift | 11 +++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/Sources/ConsoleKit/Activity/CustomActivity.swift b/Sources/ConsoleKit/Activity/CustomActivity.swift index 48647214..a74050c7 100644 --- a/Sources/ConsoleKit/Activity/CustomActivity.swift +++ b/Sources/ConsoleKit/Activity/CustomActivity.swift @@ -83,7 +83,7 @@ extension Console { /// An activity indicator with customizable frames and success and failure messages. /// -/// See ``Console/customActivity(frames:success:failure:color:)`` to make one. +/// See ``Console/customActivity(title:titleAfterIndicator:frames:success:failure:color:)`` to make one. public struct CustomActivity: ActivityIndicatorType { /// The title of the activity indicator. public let title: String diff --git a/Sources/ConsoleKit/Activity/LoadingBar.swift b/Sources/ConsoleKit/Activity/LoadingBar.swift index 111b7a91..5760e052 100644 --- a/Sources/ConsoleKit/Activity/LoadingBar.swift +++ b/Sources/ConsoleKit/Activity/LoadingBar.swift @@ -29,7 +29,7 @@ extension Console { /// See ``Console/loadingBar(title:)`` to create one. public struct LoadingBar: ActivityBar { /// See ``ActivityBar``. - public var title: String + public let title: String /// See ``ActivityBar``. public func renderActiveBar(tick: UInt, width: Int) -> ConsoleText { diff --git a/Sources/ConsoleKit/Activity/ProgressBar.swift b/Sources/ConsoleKit/Activity/ProgressBar.swift index 3041605e..ae00d10b 100644 --- a/Sources/ConsoleKit/Activity/ProgressBar.swift +++ b/Sources/ConsoleKit/Activity/ProgressBar.swift @@ -36,7 +36,7 @@ extension Console { /// See ``Console/progressBar(title:)`` to create one. public struct ProgressBar: ActivityBar { /// See ``ActivityBar``. - public var title: String + public let title: String /// Controls how the ``ProgressBar`` is rendered. /// @@ -60,3 +60,34 @@ public struct ProgressBar: ActivityBar { return barComponents.joined(separator: "").consoleText(.info) } } + +extension ActivityIndicator where A == ProgressBar { + /// Starts the ``ActivityIndicator`` with a default refresh rate of 40 milliseconds. + /// + /// This method is a convenience wrapper around ``ActivityIndicator/withActivityIndicator(refreshRate:_:)-(_,()->T)``. + /// It passes the progress bar to the body closure, allowing you to update the `currentProgress` property as needed. + /// + /// ```swift + /// try await console.progressBar(title: "Downloading").withActivityIndicator { progressBar in + /// while true { + /// if progressBar.activity.currentProgress >= 1.0 { + /// return + /// } else { + /// progressBar.activity.currentProgress += 0.1 + /// try await Task.sleep(for: .seconds(0.25)) + /// } + /// } + /// } + /// ``` + /// + /// See ``ActivityIndicator/withActivityIndicator(refreshRate:_:)-(_,()->T)`` for more information. + @discardableResult + public func withActivityIndicator( + refreshRate: Int = 40, + _ body: @Sendable (ActivityIndicator) async throws -> T + ) async rethrows -> T { + return try await self.withActivityIndicator(refreshRate: refreshRate) { + try await body(self) + } + } +} diff --git a/Tests/ConsoleKitTests/ActivityTests.swift b/Tests/ConsoleKitTests/ActivityTests.swift index f0dc0278..a29de6d2 100644 --- a/Tests/ConsoleKitTests/ActivityTests.swift +++ b/Tests/ConsoleKitTests/ActivityTests.swift @@ -37,6 +37,17 @@ struct ActivityTests { } } } + + try await console.progressBar(title: "Progress").withActivityIndicator { foo in + while true { + if foo.activity.currentProgress >= 1.0 { + return + } else { + foo.activity.currentProgress += 0.1 + try await Task.sleep(for: .seconds(0.1)) + } + } + } } @Test("Custom Indicator")