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 30c44ec8..052d6443 100644 --- a/Sources/ConsoleKit/Activity/ActivityIndicator.swift +++ b/Sources/ConsoleKit/Activity/ActivityIndicator.swift @@ -20,13 +20,12 @@ extension ActivityIndicatorType { /// let loadingBar = console.loadingBar(title: "Loading") /// try await foo.withActivityIndicator { /// try await Task.sleep(for: .seconds(2.5)) -/// return true /// } /// ``` /// 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 } @@ -36,25 +35,25 @@ 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 /// 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 @@ -68,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() @@ -84,32 +77,31 @@ 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(on:)` and completes the future returned by that method. + /// Must be called after ``ActivityIndicator/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. + /// 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(on:)` and completes the future returned by that method. + /// Must be called after ``ActivityIndicator/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. /// - /// 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. - public func withActivityIndicator(refreshRate: Int = 40, _ body: () async throws -> Bool) async rethrows { + @discardableResult + public func withActivityIndicator(refreshRate: Int = 40, _ body: @Sendable () async throws -> T) async rethrows -> T { let task = Task { await self.start(refreshRate: refreshRate) } @@ -117,9 +109,18 @@ public final class ActivityIndicator: Sendable where A: ActivityIndicatorType do { let result = try await body() task.cancel() - result ? self.succeed() : self.fail() + _ = 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/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 diff --git a/Sources/ConsoleKit/Activity/CustomActivity.swift b/Sources/ConsoleKit/Activity/CustomActivity.swift index da0fbd2e..a74050c7 100644 --- a/Sources/ConsoleKit/Activity/CustomActivity.swift +++ b/Sources/ConsoleKit/Activity/CustomActivity.swift @@ -3,12 +3,11 @@ 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 /// try await Task.sleep(for: .seconds(3)) - /// return true /// } /// ``` /// @@ -16,6 +15,8 @@ extension Console { /// https://github.com/kiliankoe/CLISpinner/blob/master/Sources/CLISpinner/Pattern.swift#L88-L151 /// /// - 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,21 +24,32 @@ 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, + titleAfterIndicator: Bool = true, + 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, + titleAfterIndicator: titleAfterIndicator, + 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 /// try await Task.sleep(for: .seconds(3)) - /// return true /// } /// ``` /// @@ -45,22 +57,41 @@ extension Console { /// https://github.com/kiliankoe/CLISpinner/blob/master/Sources/CLISpinner/Pattern.swift#L88-L151 /// /// - 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( - frames: [ConsoleText], success: String = "[Done]", failure: String = "[Failed]" + title: String, + titleAfterIndicator: Bool = true, + frames: [ConsoleText], + success: String = "[Done]", + failure: String = "[Failed]" ) -> ActivityIndicator { - return CustomActivity(frames: frames, success: success, failure: failure).newActivity(for: self) + return CustomActivity( + title: title, + titleAfterIndicator: titleAfterIndicator, + frames: frames, + success: success, + failure: failure + ).newActivity(for: self) } } /// 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 + + /// 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. @@ -75,10 +106,20 @@ public struct CustomActivity: ActivityIndicatorType { /// Creates a new ``CustomActivity`` instance. /// /// - 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(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 @@ -87,25 +128,41 @@ public struct CustomActivity: ActivityIndicatorType { /// Creates a new ``CustomActivity`` instance. /// /// - 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(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, + 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:)``. public func outputActivityIndicator(to console: any Console, state: ActivityIndicatorState) { - let output: 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) - } + 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(output) + titleAfterIndicator + ? console.output(indicator + " " + title.consoleText(.plain)) + : console.output(title.consoleText(.plain) + " " + indicator) } } diff --git a/Sources/ConsoleKit/Activity/LoadingBar.swift b/Sources/ConsoleKit/Activity/LoadingBar.swift index 94012d6a..5760e052 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 /// } /// ``` /// @@ -30,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 de39b0bb..ae00d10b 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)) @@ -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/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 } } diff --git a/Tests/ConsoleKitTests/ActivityTests.swift b/Tests/ConsoleKitTests/ActivityTests.swift index 711b54ef..a29de6d2 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") @@ -11,7 +10,6 @@ struct ActivityTests { try await foo.withActivityIndicator { try await Task.sleep(for: .seconds(2.5)) - return false } enum TestError: Error { @@ -32,7 +30,18 @@ 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)) + } + } + } + + 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)) @@ -45,11 +54,10 @@ 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)) - return true } } @@ -58,23 +66,22 @@ 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)) - return true } } @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[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() #expect(console.activityBarWidth == 25) 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" ) }