-
Notifications
You must be signed in to change notification settings - Fork 4
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
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
Comment on lines
+56
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -111,6 +111,15 @@ public protocol Noorable { | |
/// - Parameters: | ||
/// - alerts: The warning messages. | ||
func warning(_ alerts: WarningAlert...) | ||
|
||
/// | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's add some documentation There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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() | ||
} | ||
} |
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 { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit:
Suggested change
|
||||||
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 | ||||||
} | ||||||
} |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)
}
} There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We prefer using structs whenever possible. Let's use classes only when you need in-place mutability.