Progress-reporting futures¶
ProgressFuture<T> is a Deferred<T> that also exposes live progress as a StateFlow. Start one with asyncWithProgress, observe progress, and await() the result as usual. The intended granularity is one future per user-visible operation, such as opening a document or downloading a file, not one per inner step.
Start a job that reports progress¶
asyncWithProgress launches a coroutine like async (its context and start parameters behave the same) and runs the block with a ProgressScope receiver:
import io.github.yuroyami.kitecore.ProgressFuture
import io.github.yuroyami.kitecore.asyncWithProgress
val opening: ProgressFuture<Document> = scope.asyncWithProgress {
report(null, "connecting") // indeterminate, labeled
val bytes = fetchBytes()
report(0.4f) // 40%, keeps the "connecting" label
report(0.4f, "parsing") // same fraction, new label
parse(bytes)
}
val doc = opening.await()
report(fraction, phase) takes a fraction in 0..1, or null for indeterminate. It never throws: an out-of-range fraction is clamped, and NaN becomes indeterminate. It may be called from any thread. Prefer coarse units such as per page or per percent over per byte, since each distinct report allocates and wakes collectors.
Phase labels are sticky. A non-null phase updates the scope's label, and later reports without one keep it, so a stage's label does not need re-passing on every fraction tick. Update phases from the job's own flow, not from concurrent children.
Divide the bar into slices¶
slice(weight, phase) allocates the next portion of the current window to a sub-scope. The child maps its own 0..1 reports into that window, and its slices nest further:
val opening = scope.asyncWithProgress {
val header = slice(0.05f, "header")
val xref = slice(0.25f, "xref")
val pages = slice(0.70f, "pages")
header.report(1f) // overall bar reaches 5%
val table = parseXref(xref) // pass the sub-scope down
loadPages(table) { done, total ->
pages.report(done, total) // fills the remaining 70%
}
}
Slice rules:
- Call
slicesequentially from the job's own flow. Slices are windows allocated left to right, not a concurrent tree. - Weights are fractions of the current window and should sum to at most 1. A weight beyond the remaining window is clamped.
- A named slice reports
0fimmediately, so the bar shows the new phase at its start. An unnamed slice inherits the current label.
Count parallel work¶
For fan-out work, do not slice per task. Route completions through the report(done, total) overload instead:
import io.github.yuroyami.kitecore.ioDispatcher
val downloads = scope.asyncWithProgress {
val urls = pendingUrls()
val total = urls.size.toLong()
report(0, total, "downloading")
val done = MutableStateFlow(0L)
urls.map { url ->
async(ioDispatcher) {
download(url)
report(done.updateAndGet { it + 1 }, total)
}
}.awaitAll()
}
report(done, total) reports done / total, or indeterminate when total <= 0 (useful while the item count is still unknown).
Collect the progress¶
progress is a conflated StateFlow<Progress>: it always holds the latest value, and a slow collector skips intermediate ones. Collect it anywhere a flow works:
opening.progress.collect { p ->
statusBar.fraction = p.fraction // null while indeterminate
statusBar.label = p.phase
}
In Compose, observe it with collectAsState():
@Composable
fun OpeningIndicator(opening: ProgressFuture<Document>) {
val p by opening.progress.collectAsState()
val fraction = p.fraction
if (fraction == null) LinearProgressIndicator()
else LinearProgressIndicator(progress = { fraction })
Text(p.phase.orEmpty())
}
Apply progress policy with ordinary Flow operators, for example progress.sample(100.milliseconds) to throttle UI updates.
Success, failure, and cancellation¶
- On success, progress completes to
fraction = 1fautomatically, keeping the last phase. A finalreport(1f)is not needed. - On failure or cancellation, progress freezes at the last reported value. Read the outcome from the
Deferredside (await()rethrows,isCancelled), not from the bar. - Every
Deferredmember works:await(),cancel(),awaitAll(...), andonAwaitinselect.
Adapt an existing Deferred¶
withProgress glues a Deferred and an external StateFlow<Progress> into a ProgressFuture, for APIs that already expose both halves. A Ktor download is the typical case:
import io.github.yuroyami.kitecore.Progress
import io.github.yuroyami.kitecore.withProgress
val state = MutableStateFlow(Progress(null, "downloading"))
val download = scope.async {
client.get(url) {
onDownload { sent, total ->
state.value = Progress(
fraction = if (total != null && total > 0) sent.toFloat() / total else null,
phase = "downloading",
)
}
}.body<ByteArray>()
}.withProgress(state)
The adapter passes your flow through untouched: unlike asyncWithProgress, it does not complete progress to 1f on success. Emit the final value from your own callback if the consumer expects one.
Progress value semantics¶
Progress is an immutable snapshot of a fraction and an optional phase, so a collector always renders a fraction and a label that belong together.
- The constructor never throws: an out-of-range fraction is clamped into
0..1, andNaNbecomesnull. fractionisnullwhile the total is unknown (indeterminate).- Equality is by value over
(fraction, phase).StateFlowconflates onequals, so reporting an equal progress twice wakes no collectors. toString()renders asProgress(42%),Progress(42%, pages), orProgress(indeterminate).