Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Sources/ConsoleKit/Activity/ActivityBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
55 changes: 28 additions & 27 deletions Sources/ConsoleKit/Activity/ActivityIndicator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<A>: Sendable where A: ActivityIndicatorType {
let _activity: Mutex<A>
/// The generic `ActivityIndicatorType` powering this `ActivityIndicator`.
/// The generic ``ActivityIndicatorType`` powering this ``ActivityIndicator``.
public var activity: A {
get {
self._activity.withLock { $0 }
Expand All @@ -36,25 +35,25 @@ public final class ActivityIndicator<A>: 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
Expand All @@ -68,12 +67,6 @@ public final class ActivityIndicator<A>: 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()
Expand All @@ -84,42 +77,50 @@ public final class ActivityIndicator<A>: 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<T>(refreshRate: Int = 40, _ body: @Sendable () async throws -> T) async rethrows -> T {
let task = Task {
await self.start(refreshRate: refreshRate)
}

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
}
Expand Down
99 changes: 78 additions & 21 deletions Sources/ConsoleKit/Activity/CustomActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,64 +3,95 @@ 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
/// }
/// ```
///
/// > Note: If you want some ideas for indicator styles, take a look here:
/// 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]`.
/// - color: The color of text when the frames are displayed. The default value is ``ConsoleColor/cyan``.
///
/// - 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<CustomActivity> {
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
/// }
/// ```
///
/// > Note: If you want some ideas for indicator styles, take a look here:
/// 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<CustomActivity> {
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.
Expand All @@ -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
Expand All @@ -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)
}
}
3 changes: 1 addition & 2 deletions Sources/ConsoleKit/Activity/LoadingBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// }
/// ```
///
Expand All @@ -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 {
Expand Down
35 changes: 33 additions & 2 deletions Sources/ConsoleKit/Activity/ProgressBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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.
///
Expand All @@ -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<T>(
refreshRate: Int = 40,
_ body: @Sendable (ActivityIndicator<ProgressBar>) async throws -> T
) async rethrows -> T {
return try await self.withActivityIndicator(refreshRate: refreshRate) {
try await body(self)
}
}
}
Loading