diff --git a/Sources/Noora/Components/CLIProgressBar.swift b/Sources/Noora/Components/CLIProgressBar.swift new file mode 100644 index 0000000..42133ef --- /dev/null +++ b/Sources/Noora/Components/CLIProgressBar.swift @@ -0,0 +1,106 @@ +import Foundation +import Rainbow + + +class CLIProgressBar { + + let message: String + let successMessage: String? + let errorMessage: String? + let total: Int + let action: (@escaping (String) -> Void) async throws -> Void + let theme: Theme + let terminal: Terminaling + let renderer: Rendering + let standardPipelines: StandardPipelines + var progressBar: ProgressBar + + init( + message: String, + successMessage: String?, + errorMessage: String?, + total: Int, + action: @escaping (@escaping (String) -> Void) async throws -> Void, + theme: Theme, + terminal: Terminaling, + renderer: Rendering, + standardPipelines: StandardPipelines, + progressBar: ProgressBar = DefaultProgressBar() + ) { + self.message = message + self.successMessage = successMessage + self.errorMessage = errorMessage + self.total = total + self.action = action + self.theme = theme + self.terminal = terminal + self.renderer = renderer + self.standardPipelines = standardPipelines + self.progressBar = progressBar + } + + func run() async throws { + if terminal.isInteractive { + try await runInteractive() + } else { + try await runNonInteractive() + } + } + + func runInteractive() async throws { + + var bar: String = "" + var progressPercentage = 0 + var lastMessage = message + + progressBar.startProgress(total: total, interval: 0.05) { progressBarState, percentage in + bar = progressBarState + progressPercentage = percentage + self.render(lastMessage, bar, progressPercentage) + } + + var _error: Error? + do { + self.render(lastMessage, bar, progressPercentage) + try await action { progressMessage in + lastMessage = progressMessage + self.render(lastMessage, bar, progressPercentage) + } + } catch { + _error = error + } + + + if _error != nil { + renderer.render( + "\("⨯".hexIfColoredTerminal(theme.danger, terminal)) \(errorMessage ?? message)", + standardPipeline: standardPipelines.error + ) + } else { + renderer.render( + CLIProgressBar + .completionMessage(lastMessage, bar, progressPercentage, theme: theme, terminal: terminal), + standardPipeline: standardPipelines.output + ) + } + + if let _error { + throw _error + } + } + // TODO: Implement runNonInteractive logic + func runNonInteractive() async throws { + + } + + static func completionMessage(_ message: String, _ bar: String, _ percentage: Int, theme: Theme, terminal: Terminaling) -> String { + "\(message) \(bar.hexIfColoredTerminal(theme.success, terminal)) \(percentage)% | Completed" + } + + private func render(_ message: String, _ bar: String, _ percentage: Int) { + renderer.render( + "\(message) \(bar.hexIfColoredTerminal(theme.primary, terminal)) \(percentage)% |", + standardPipeline: standardPipelines.output + ) + } +} diff --git a/Sources/Noora/Noora.swift b/Sources/Noora/Noora.swift index 6e2c6e8..5103e0a 100644 --- a/Sources/Noora/Noora.swift +++ b/Sources/Noora/Noora.swift @@ -111,6 +111,15 @@ public protocol Noorable { /// - Parameters: /// - alerts: The warning messages. func warning(_ alerts: WarningAlert...) + + /// + func progressBar( + message: String, + successMessage: String?, + errorMessage: String?, + total: Int, + action: @escaping ((String) -> Void) async throws -> Void + ) async throws } public struct Noora: Noorable { @@ -200,4 +209,24 @@ public struct Noora: Noorable { theme: theme ).run() } + public func progressBar( + message: String, + successMessage: String? = nil, + errorMessage: String? = nil, + total: Int, + action: @escaping ((String) -> Void) async throws -> Void + ) async throws { + let progressBar = CLIProgressBar( + message: message, + successMessage: successMessage, + errorMessage: errorMessage, + total: total, + action: action, + theme: theme, + terminal: terminal, + renderer: Renderer(), + standardPipelines: StandardPipelines() + ) + try await progressBar.run() + } } diff --git a/Sources/Noora/Utilities/ProgressBar.swift b/Sources/Noora/Utilities/ProgressBar.swift new file mode 100644 index 0000000..b092409 --- /dev/null +++ b/Sources/Noora/Utilities/ProgressBar.swift @@ -0,0 +1,53 @@ +import Foundation + +protocol ProgressBar { + func startProgress(total: Int, interval: TimeInterval?, block: @escaping (String, Int) -> Void) + func stop() +} +class DefaultProgressBar: ProgressBar { + private static let complete = "█" + private static let incomplete = "▒" + private static let width = 30 + + private var isLoading = true + private var timer: Timer? + private var completed = 0 + private var progressPercent = 0 + + func startProgress(total: Int, interval: TimeInterval? , block: @escaping (String, Int) -> Void) { + isLoading = true + + DispatchQueue.global(qos: .userInitiated).async { + let runLoop = RunLoop.current + var index = 1 + + // Schedule the timer in the current run loop + self.timer = Timer.scheduledTimer(withTimeInterval: interval ?? 0.05, repeats: true) { _ in + if index <= total { + self.update(total, index) + let completedBar = String(repeating: DefaultProgressBar.complete, count: self.completed) + let incompleteBar = String(repeating: DefaultProgressBar.incomplete, count: DefaultProgressBar.width - self.completed) + block(completedBar+incompleteBar, self.progressPercent) + index += 1 + } else { + self.timer?.invalidate() + } + } + + // Start the run loop to allow the timer to fire + while self.isLoading, runLoop.run(mode: .default, before: .distantFuture) {} + } + } + + private func update(_ total: Int, _ index: Int) { + let percentage = Double(index) / Double(total) + completed = Int(percentage * Double(DefaultProgressBar.width)) + progressPercent = Int(percentage * 100) + } + + func stop() { + isLoading = false + timer?.invalidate() + timer = nil + } +} diff --git a/Sources/examples-cli/Commands/ProgressBarCommand.swift b/Sources/examples-cli/Commands/ProgressBarCommand.swift new file mode 100644 index 0000000..24532d0 --- /dev/null +++ b/Sources/examples-cli/Commands/ProgressBarCommand.swift @@ -0,0 +1,28 @@ +import ArgumentParser +import Foundation +import Noora + +struct ProgressBarCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "progress-bar", + abstract: "A component to shows a progress bar" + ) + func run() async throws { + try await Noora().progressBar( + message: "Loading", + successMessage: "Manifests loaded", + errorMessage: "Failed to load manifests", + total: 100 + ) { _ in + try await Task.sleep(nanoseconds: 5_000_000_000) + } + try await Noora().progressBar( + message: "Loading", + successMessage: "Manifests loaded", + errorMessage: "Failed to load manifests", + total: 200 + ) { _ in + try await Task.sleep(nanoseconds: 10_000_000_000) + } + } +} diff --git a/Sources/examples-cli/ExamplesCLI.swift b/Sources/examples-cli/ExamplesCLI.swift index 1227e64..6ed9f17 100644 --- a/Sources/examples-cli/ExamplesCLI.swift +++ b/Sources/examples-cli/ExamplesCLI.swift @@ -6,6 +6,6 @@ import Rainbow struct ExamplesCLI: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "A command line tool to test the different components available in Noora.", - subcommands: [SingleChoicePromptCommand.self, YesOrNoChoicePromptCommand.self, AlertCommand.self] + subcommands: [SingleChoicePromptCommand.self, YesOrNoChoicePromptCommand.self, AlertCommand.self, ProgressBarCommand.self] ) }