Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add progress bar #163

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
106 changes: 106 additions & 0 deletions Sources/Noora/Components/CLIProgressBar.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import Foundation
import Rainbow


class CLIProgressBar {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
class CLIProgressBar {
struct CLIProgressBar {

We prefer using structs whenever possible. Let's use classes only when you need in-place mutability.


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)
}
Comment on lines +56 to +60
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in another comment, it shouldn't be the controller the one that controls the progress, but the caller that uses the component to run an asynchronous action.


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
)
}
}
29 changes: 29 additions & 0 deletions Sources/Noora/Noora.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ public protocol Noorable {
/// - Parameters:
/// - alerts: The warning messages.
func warning(_ alerts: WarningAlert...)

///
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add some documentation

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let’s leave the docs and tests for the end, after finishing the tasks
What do you think?

func progressBar(
message: String,
successMessage: String?,
errorMessage: String?,
total: Int,
action: @escaping ((String) -> Void) async throws -> Void
) async throws
}

public struct Noora: Noorable {
Expand Down Expand Up @@ -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()
}
}
53 changes: 53 additions & 0 deletions Sources/Noora/Utilities/ProgressBar.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Foundation

protocol ProgressBar {
func startProgress(total: Int, interval: TimeInterval?, block: @escaping (String, Int) -> Void)
func stop()
}
class DefaultProgressBar: ProgressBar {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
class DefaultProgressBar: ProgressBar {
final 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
}
}
28 changes: 28 additions & 0 deletions Sources/examples-cli/Commands/ProgressBarCommand.swift
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd drop this argument. What are the scenarios where you don't want the progress bar to reach 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task should have an API to control the progress. Something along the lines of:

progressBar(....) { progress in
  try await doSomethingThatTakesTime { percentage in // e.g. downloading a file
     progress(percentage)
  }
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to separate the calculation of the progress bar task, can you explain more? i found this method in the docs

try await Task.sleep(nanoseconds: 10_000_000_000)
}
}
}
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, AlertCommand.self]
subcommands: [SingleChoicePromptCommand.self, YesOrNoChoicePromptCommand.self, AlertCommand.self, ProgressBarCommand.self]
)
}