Skip to content

Commit

Permalink
Add progress step
Browse files Browse the repository at this point in the history
  • Loading branch information
pepicrft committed Dec 11, 2024
1 parent 151e2a4 commit ac1936b
Show file tree
Hide file tree
Showing 7 changed files with 151 additions and 65 deletions.
27 changes: 0 additions & 27 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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
Expand Down
4 changes: 0 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -32,8 +30,6 @@ let package = Package(
name: "Noora",
dependencies: [
"Rainbow",
"CombineX",
"Asynchrone",
],
swiftSettings: [
.define("MOCKING", .when(configuration: .debug)),
Expand Down
76 changes: 76 additions & 0 deletions Sources/Noora/Components/ProgressStep.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
33 changes: 25 additions & 8 deletions Sources/Noora/Noora.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ public protocol Noorable {
func singleChoicePrompt<T: CaseIterable & CustomStringConvertible & Equatable>(
question: String
) -> T

/// It shows multiple options to the user to select one.
/// - Parameters:
/// - title: A title that captures what's being asked.
Expand All @@ -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.
Expand All @@ -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<T>(question: String) -> T where T: CaseIterable, T: CustomStringConvertible, T: Equatable {
singleChoicePrompt(title: nil, question: question, description: nil, collapseOnSelection: true)
}

public func singleChoicePrompt<T: CaseIterable & CustomStringConvertible & Equatable>(
title: String? = nil,
question: String,
Expand All @@ -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,
Expand All @@ -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()
}
}
52 changes: 27 additions & 25 deletions Sources/Noora/Utilities/Spinner.swift
Original file line number Diff line number Diff line change
@@ -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 = [
"",
"",
Expand All @@ -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
}
}
22 changes: 22 additions & 0 deletions Sources/examples-cli/Commands/ProgressStepCommand.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 1 addition & 1 deletion Sources/examples-cli/ExamplesCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)
}

0 comments on commit ac1936b

Please sign in to comment.