diff --git a/Package.resolved b/Package.resolved index 18307df..942415a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,23 +1,5 @@ { "pins" : [ - { - "identity" : "asynchrone", - "kind" : "remoteSourceControl", - "location" : "https://github.com/reddavis/Asynchrone", - "state" : { - "revision" : "1ddfcd3bc93277f68dffb793fc60001902f2517b", - "version" : "0.22.0" - } - }, - { - "identity" : "combinex", - "kind" : "remoteSourceControl", - "location" : "https://github.com/cx-org/CombineX", - "state" : { - "revision" : "98096c6b2a51481cb6e4bae8da0a808d8cab09a1", - "version" : "0.4.0" - } - }, { "identity" : "rainbow", "kind" : "remoteSourceControl", @@ -35,15 +17,6 @@ "revision" : "41982a3656a71c768319979febd796c6fd111d5c", "version" : "1.5.0" } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "3e95ba32cd1b4c877f6163e8eea54afc4e63bf9f", - "version" : "0.0.3" - } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index dc9d44b..55a82ee 100644 --- a/Package.swift +++ b/Package.swift @@ -19,8 +19,6 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/onevcat/Rainbow", .upToNextMajor(from: "4.0.1")), - .package(url: "https://github.com/cx-org/CombineX", .upToNextMajor(from: "0.4.0")), - .package(url: "https://github.com/reddavis/Asynchrone", .upToNextMajor(from: "0.22.0")), .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMajor(from: "1.5.0")), ], targets: [ @@ -32,8 +30,6 @@ let package = Package( name: "Noora", dependencies: [ "Rainbow", - "CombineX", - "Asynchrone", ], swiftSettings: [ .define("MOCKING", .when(configuration: .debug)), diff --git a/Sources/Noora/Components/ProgressStep.swift b/Sources/Noora/Components/ProgressStep.swift new file mode 100644 index 0000000..f4018b9 --- /dev/null +++ b/Sources/Noora/Components/ProgressStep.swift @@ -0,0 +1,76 @@ +import Foundation +import Rainbow + +class ProgressStep { + // MARK: - Attributes + + let message: String + let successMessage: String? + let errorMessage: String? + let showSpinner: Bool + let action: (@escaping (String) -> Void) async throws -> Void + let theme: Theme + let terminal: Terminaling + let renderer: Rendering + let standardPipelines: StandardPipelines + var spinner = Spinner() + + init(message: String, successMessage: String?, errorMessage: String?, showSpinner: Bool, action: @escaping (@escaping (String) -> Void) async throws -> Void, theme: Theme, terminal: Terminaling, renderer: Rendering, standardPipelines: StandardPipelines, spinner: Spinner = Spinner()) { + self.message = message + self.successMessage = successMessage + self.errorMessage = errorMessage + self.showSpinner = showSpinner + self.action = action + self.theme = theme + self.terminal = terminal + self.renderer = renderer + self.standardPipelines = standardPipelines + self.spinner = spinner + } + + func run() async throws { + let start = DispatchTime.now() + + defer { spinner.stop() } + + var spinnerIcon: String? + var lastMessage = message + + if showSpinner { + spinner.spin { icon in + spinnerIcon = icon + self.render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") + } + } + + var _error: Error? = nil + do { + render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") + try await action({ progressMessage in + lastMessage = progressMessage + self.render(message: lastMessage, icon: spinnerIcon ?? "ℹ︎") + }) + } catch { + _error = error + } + + let elapsedTime = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000 + let timeString = "[\(String(format: "%.1f", elapsedTime))s]".hexIfColoredTerminal(theme.muted, terminal) + + if _error != nil { + renderer.render("\("⨯".hexIfColoredTerminal(theme.danger, terminal)) \(errorMessage ?? message) \(timeString)", standardPipeline: standardPipelines.output) + } else { + renderer.render("\("✔︎".hexIfColoredTerminal(theme.success, terminal)) \(successMessage ?? message) \(timeString)", standardPipeline: standardPipelines.output) + } + + if let _error = _error { + throw _error + } + } + + // MARK: - Private + + private func render(message: String, icon: String) { + renderer.render("\(icon.hexIfColoredTerminal(theme.primary, terminal)) \(message)", standardPipeline: standardPipelines.output) + } +} diff --git a/Sources/Noora/Noora.swift b/Sources/Noora/Noora.swift index 797320f..4bf27f9 100644 --- a/Sources/Noora/Noora.swift +++ b/Sources/Noora/Noora.swift @@ -4,7 +4,7 @@ public protocol Noorable { func singleChoicePrompt( question: String ) -> T - + /// It shows multiple options to the user to select one. /// - Parameters: /// - title: A title that captures what's being asked. @@ -18,12 +18,12 @@ public protocol Noorable { description: String?, collapseOnSelection: Bool ) -> T - + func yesOrNoChoicePrompt( title: String?, question: String ) -> Bool - + /// It shows a component to answer yes or no to a question. /// - Parameters: /// - title: A title that captures what's being asked. @@ -44,16 +44,16 @@ public protocol Noorable { public struct Noora: Noorable { let theme: Theme let terminal: Terminaling - + public init(theme: Theme = .default, terminal: Terminaling = Terminal()) { self.theme = theme self.terminal = terminal } - + public func singleChoicePrompt(question: String) -> T where T: CaseIterable, T: CustomStringConvertible, T: Equatable { singleChoicePrompt(title: nil, question: question, description: nil, collapseOnSelection: true) } - + public func singleChoicePrompt( title: String? = nil, question: String, @@ -74,11 +74,11 @@ public struct Noora: Noorable { ) return component.run() } - + public func yesOrNoChoicePrompt(title: String?, question: String) -> Bool { yesOrNoChoicePrompt(title: title, question: question, defaultAnswer: true, description: nil, collapseOnSelection: true) } - + public func yesOrNoChoicePrompt( title: String? = nil, question: String, @@ -99,4 +99,21 @@ public struct Noora: Noorable { defaultAnswer: defaultAnswer ).run() } + + public func progressStep(message: String, + successMessage: String? = nil, + errorMessage: String? = nil, + showSpinner: Bool = true, + action: @escaping ((String) -> Void) async throws -> Void) async throws { + var progressStep = ProgressStep(message: message, + successMessage: successMessage, + errorMessage: errorMessage, + showSpinner: showSpinner, + action: action, + theme: theme, + terminal: terminal, + renderer: Renderer(), + standardPipelines: StandardPipelines()) + try await progressStep.run() + } } diff --git a/Sources/Noora/Utilities/Spinner.swift b/Sources/Noora/Utilities/Spinner.swift index 56c5b78..7f55e74 100644 --- a/Sources/Noora/Utilities/Spinner.swift +++ b/Sources/Noora/Utilities/Spinner.swift @@ -1,18 +1,7 @@ -import CombineX -import CXFoundation import Foundation -enum Spinner { - typealias Cancellable = () -> Void - - actor Counter { - var count: Int = 0 - - func increase() { - count += 1 - } - } - +class Spinner { + private static let frames = [ "⠋", "⠙", @@ -25,21 +14,34 @@ enum Spinner { "⠇", "⠏", ] + private var isSpinning = true + private var timer: Timer? + + func spin(_ block: @escaping (String) -> Void) { + isSpinning = true + + DispatchQueue.global(qos: .userInitiated).async { + let runLoop = RunLoop.current + var index = 0 - static func spin(_ block: @escaping (String) async -> Void) async -> Cancellable { - let counter = Counter() - await block(Spinner.frames[0]) - - let cancellable = Timer.CX.TimerPublisher(interval: 0.1, runLoop: .main, mode: .common) - .autoconnect() - .sink { _ in - Task { - await block(Spinner.frames[await counter.count % Spinner.frames.count]) - await counter.increase() + // Schedule the timer in the current run loop + self.timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + if self.isSpinning { + block(Spinner.frames[index]) + index = (index + 1) % Spinner.frames.count + } else { + self.timer?.invalidate() } } - return { - cancellable.cancel() + + // Start the run loop to allow the timer to fire + while self.isSpinning && runLoop.run(mode: .default, before: .distantFuture) {} } } + + func stop() { + isSpinning = false + timer?.invalidate() + timer = nil + } } diff --git a/Sources/examples-cli/Commands/ProgressStepCommand.swift b/Sources/examples-cli/Commands/ProgressStepCommand.swift new file mode 100644 index 0000000..43e6816 --- /dev/null +++ b/Sources/examples-cli/Commands/ProgressStepCommand.swift @@ -0,0 +1,22 @@ +import ArgumentParser +import Foundation +import Noora + +struct ProgressStepCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "progress-step", + abstract: "A component to shows a progress step" + ) + + func run() async throws { + try await Noora().progressStep(message: "Loading manifests", successMessage: "Manifests loaded", errorMessage: "Failed to load manifests") { progress in + sleep(2) + } + try await Noora().progressStep(message: "Processing the graph", successMessage: "Project graph processed", errorMessage: "Failed to process the project graph") { progress in + sleep(2) + } + try await Noora().progressStep(message: "Generating Xcode projects and workspsace", successMessage: "Xcode projects and workspace generated", errorMessage: "Failed to generate Xcode workspace and projects") { progress in + sleep(2) + } + } +} diff --git a/Sources/examples-cli/ExamplesCLI.swift b/Sources/examples-cli/ExamplesCLI.swift index fc4cd79..a2492e8 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] + subcommands: [SingleChoicePromptCommand.self, YesOrNoChoicePromptCommand.self, ProgressStepCommand.self] ) }