Skip to content

Commit cd30fef

Browse files
authored
feat(cli): progress animation layer
* feat(progress-animation): progress animation layer Signed-off-by: melodicore <datafox@datafox.me> * feat(progress-animation): utilize flows better Signed-off-by: melodicore <datafox@datafox.me> * chore(progress-animation): api pins Signed-off-by: melodicore <datafox@datafox.me> * fix(progress-animation): code review requested changes Signed-off-by: melodicore <datafox@datafox.me> * chore(progress-animation): refactor into cli package Signed-off-by: melodicore <datafox@datafox.me> * fix(progress-animation): fix test race condition Signed-off-by: melodicore <datafox@datafox.me> * fix(progress-animation): fix test race condition again Signed-off-by: melodicore <datafox@datafox.me> * fix(progress-animation): rollback previous changes, do tests with coroutines properly Signed-off-by: melodicore <datafox@datafox.me> --------- Signed-off-by: melodicore <datafox@datafox.me>
1 parent 08f1ba5 commit cd30fef

File tree

16 files changed

+1402
-0
lines changed

16 files changed

+1402
-0
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright (c) 2024-2025 Elide Technologies, Inc.
3+
*
4+
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
5+
* with the License. You may obtain a copy of the License at
6+
*
7+
* https://opensource.org/license/mit/
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10+
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
* License for the specific language governing permissions and limitations under the License.
12+
*/
13+
package elide.tool.cli.progress
14+
15+
import com.github.ajalt.mordant.terminal.Terminal
16+
import kotlinx.coroutines.flow.StateFlow
17+
import kotlin.coroutines.CoroutineContext
18+
import elide.tool.cli.progress.impl.ProgressImpl
19+
import elide.tool.cli.progress.impl.ProgressManagerImpl
20+
21+
/**
22+
* A low-level interface for rendering a progress animation to the console.
23+
*
24+
* @property name Name of the main process.
25+
* @property tasks Current tasks of the progress animation.
26+
* @property running `true` if the progress animation is being rendered.
27+
* @author Lauri Heino <datafox>
28+
*/
29+
interface Progress {
30+
val name: String
31+
val tasks: List<TrackedTask>
32+
val running: Boolean
33+
34+
/** Starts rendering the animation. */
35+
suspend fun start()
36+
37+
/** Stops rendering the animation. */
38+
suspend fun stop()
39+
40+
/** Returns the state of a task at [index]. */
41+
suspend fun getTask(index: Int): TrackedTask
42+
43+
/** Returns the [StateFlow] of a task at [index]. */
44+
suspend fun getTaskFlow(index: Int): StateFlow<TrackedTask>
45+
46+
/** Adds a new task and returns its index. If [target] is `1`, the task is rendered as indeterminate. */
47+
suspend fun addTask(name: String, target: Int = 1, status: String = ""): Int
48+
49+
/** Updates the state of a task at [index]. */
50+
suspend fun updateTask(index: Int, block: TrackedTask.() -> TrackedTask)
51+
52+
companion object {
53+
/** Creates a new progress animation that renders to [terminal]. */
54+
fun create(name: String, terminal: Terminal, tasks: MutableList<TrackedTask>.() -> Unit): Progress =
55+
ProgressImpl(name, terminal, mutableListOf<TrackedTask>().apply(tasks))
56+
57+
/**
58+
* Creates a new [ProgressManager] for higher level management of a progress animation that renders to [terminal].
59+
*/
60+
fun managed(name: String, terminal: Terminal, context: CoroutineContext? = null): ProgressManager =
61+
ProgressManagerImpl(name, terminal, context)
62+
}
63+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright (c) 2024-2025 Elide Technologies, Inc.
3+
*
4+
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
5+
* with the License. You may obtain a copy of the License at
6+
*
7+
* https://opensource.org/license/mit/
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10+
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
* License for the specific language governing permissions and limitations under the License.
12+
*/
13+
package elide.tool.cli.progress
14+
15+
import kotlinx.coroutines.flow.Flow
16+
import kotlinx.coroutines.flow.FlowCollector
17+
import kotlinx.coroutines.flow.StateFlow
18+
import kotlinx.coroutines.flow.flow
19+
import kotlin.experimental.ExperimentalTypeInference
20+
21+
/**
22+
* A high-level interface for managing a [Progress] animation.
23+
*
24+
* @property progress [Progress] instance managed by this progress manager.
25+
* @author Lauri Heino <datafox>
26+
*/
27+
interface ProgressManager {
28+
val progress: Progress
29+
30+
/**
31+
* Adds a new task with [id] to the [progress] that listens to [events] and returns a [StateFlow] for the state of
32+
* that task. If [target] is `1`, the task is rendered as indeterminate.
33+
*/
34+
suspend fun register(
35+
id: String,
36+
name: String,
37+
target: Int = 1,
38+
status: String = "",
39+
events: Flow<TaskEvent>,
40+
): StateFlow<TrackedTask>
41+
42+
/** Returns a [StateFlow] for the state of the task with [id], or `null` if no task is registered. */
43+
suspend fun track(id: String): StateFlow<TrackedTask>?
44+
45+
/** Stops the task with [id] and stops rendering the animation if no tasks are running. */
46+
suspend fun stop(id: String)
47+
48+
/** Stops all tasks and rendering the animation. */
49+
suspend fun stopAll()
50+
}
51+
52+
/**
53+
* Adds a new task with [id] to the [Progress] that listens to [flow] { [block] } and returns a [StateFlow] for the
54+
* state of that task. If [target] is `1`, the task is rendered as indeterminate.
55+
*/
56+
@OptIn(ExperimentalTypeInference::class)
57+
suspend fun ProgressManager.register(
58+
id: String,
59+
name: String,
60+
target: Int = 1,
61+
status: String = "",
62+
@BuilderInference block: suspend FlowCollector<TaskEvent>.() -> Unit
63+
): StateFlow<TrackedTask> = register(id, name, target, status, flow(block))
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright (c) 2024-2025 Elide Technologies, Inc.
3+
*
4+
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
5+
* with the License. You may obtain a copy of the License at
6+
*
7+
* https://opensource.org/license/mit/
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10+
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
* License for the specific language governing permissions and limitations under the License.
12+
*/
13+
package elide.tool.cli.progress
14+
15+
import com.github.ajalt.mordant.rendering.BorderType
16+
import com.github.ajalt.mordant.rendering.TextColors
17+
import com.github.ajalt.mordant.rendering.TextStyle
18+
import com.github.ajalt.mordant.rendering.Widget
19+
import com.github.ajalt.mordant.table.*
20+
import com.github.ajalt.mordant.widgets.ProgressBar
21+
22+
/**
23+
* Tool for rendering a [ProgressState] into a [Widget].
24+
*
25+
* @author Lauri Heino <datafox>
26+
*/
27+
internal object ProgressRenderer {
28+
fun render(state: ProgressState): Widget {
29+
return table {
30+
column(0) { width = ColumnWidth.Expand(1) }
31+
column(1) { width = ColumnWidth.Expand(1) }
32+
borderType = BorderType.SQUARE
33+
header { header(state) }
34+
body {
35+
val notStarted = state.tasks.filter { !it.started }
36+
val running = state.tasks.filter { it.started && !it.finished }
37+
val completed = state.tasks.filter { it.finished }
38+
if (notStarted.isNotEmpty()) row { cell(tasks(notStarted)) { columnSpan = 2 } }
39+
running.forEach { task ->
40+
row {
41+
cell(task(task))
42+
cell(console(task))
43+
}
44+
}
45+
if (completed.isNotEmpty()) row { cell(tasks(completed)) { columnSpan = 2 } }
46+
}
47+
}
48+
}
49+
50+
private fun SectionBuilder.header(state: ProgressState) {
51+
if (state.tasks.all { !it.started } || state.tasks.all { it.finished }) {
52+
row { cell(state.name) { columnSpan = 2 } }
53+
return
54+
}
55+
row {
56+
cell(state.name) { columnSpan = 2 }
57+
cellBorders = Borders.LEFT_TOP_RIGHT
58+
}
59+
row {
60+
cellBorders = Borders.LEFT_RIGHT_BOTTOM
61+
val total = state.tasks.sumOf { task -> task.target }.toLong()
62+
val completed = state.tasks.sumOf { task -> task.position.coerceAtLeast(0) }.toLong()
63+
cell(progressBar(total, completed, state.tasks.any { it.failed })) { columnSpan = 2 }
64+
}
65+
}
66+
67+
private fun progressBar(total: Long, completed: Long, failed: Boolean): Any? {
68+
val style = if (failed) TextStyle(TextColors.red) else null
69+
return if (total == 0L && completed == 1L) ProgressBar(indeterminate = true, indeterminateStyle = style)
70+
else ProgressBar(total, completed, completeStyle = style, finishedStyle = style)
71+
}
72+
73+
private fun tasks(tasks: List<TrackedTask>): Table {
74+
return table {
75+
tableBorders = Borders.NONE
76+
cellBorders = Borders.NONE
77+
padding { all = 0 }
78+
body { tasks.forEach { task -> row { title(task) } } }
79+
}
80+
}
81+
82+
private fun RowBuilder.title(task: TrackedTask) {
83+
val sb = StringBuilder()
84+
sb.append(task.name).append(": ").append(task.state.displayName)
85+
if (task.status.isNotBlank()) sb.append(" (").append(task.status).append(")")
86+
cell(sb.toString()) { if (task.failed) style(TextColors.red) }
87+
}
88+
89+
private fun task(task: TrackedTask): Table {
90+
return table {
91+
tableBorders = Borders.NONE
92+
cellBorders = Borders.NONE
93+
padding { all = 0 }
94+
header { row { title(task) } }
95+
body {
96+
if (task.started && !task.finished) {
97+
row { cell(progressBar(task.target.toLong(), task.position.toLong(), task.failed)) }
98+
}
99+
}
100+
}
101+
}
102+
103+
private fun console(task: TrackedTask): Table {
104+
return table {
105+
tableBorders = Borders.NONE
106+
cellBorders = Borders.NONE
107+
padding { all = 0 }
108+
body { task.output.toSortedMap().values.lastElements(2).forEach { output -> row { cell(output) } } }
109+
}
110+
}
111+
112+
private fun <T> Collection<T>.lastElements(count: Int): List<T> {
113+
if (size <= count) return toList()
114+
return toList().subList(size - count, size)
115+
}
116+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Copyright (c) 2024-2025 Elide Technologies, Inc.
3+
*
4+
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
5+
* with the License. You may obtain a copy of the License at
6+
*
7+
* https://opensource.org/license/mit/
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10+
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
* License for the specific language governing permissions and limitations under the License.
12+
*/
13+
package elide.tool.cli.progress
14+
15+
/**
16+
* Immutable state of a progress animation.
17+
*
18+
* @author Lauri Heino <datafox>
19+
*/
20+
internal data class ProgressState(val name: String, val tasks: List<TrackedTask>)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright (c) 2024-2025 Elide Technologies, Inc.
3+
*
4+
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
5+
* with the License. You may obtain a copy of the License at
6+
*
7+
* https://opensource.org/license/mit/
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10+
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
* License for the specific language governing permissions and limitations under the License.
12+
*/
13+
package elide.tool.cli.progress
14+
15+
import kotlinx.coroutines.flow.FlowCollector
16+
17+
/**
18+
* An event used by [ProgressManager] to update the state of a task in a progress animation.
19+
*
20+
* @author Lauri Heino <datafox>
21+
*/
22+
sealed interface TaskEvent
23+
24+
/** Value class for an event that updates a task's status message. */
25+
@JvmInline value class StatusMessage(val status: String) : TaskEvent
26+
27+
/** Value class for an event that updates a task's progress bar. */
28+
@JvmInline value class ProgressPosition(val position: Int) : TaskEvent
29+
30+
/** Value class for an event that appends to a task's console. */
31+
@JvmInline value class AppendOutput(val output: String) : TaskEvent
32+
33+
/** Value class for an event that starts a task (sets position to `0` if it is `-1`). */
34+
@JvmInline value class TaskStarted(val started: Boolean = true) : TaskEvent
35+
36+
/** Value class for an event that fails a task. */
37+
@JvmInline value class TaskFailed(val failed: Boolean = true) : TaskEvent
38+
39+
/** Value class for an event that completes a task (sets position to target). */
40+
@JvmInline value class TaskCompleted(val completed: Boolean = true) : TaskEvent
41+
42+
/** Updates a task's status message. */
43+
suspend fun FlowCollector<TaskEvent>.emitStatus(status: String): Unit = emit(StatusMessage(status))
44+
45+
/** Updates a task's progress bar. */
46+
suspend fun FlowCollector<TaskEvent>.emitProgress(position: Int): Unit = emit(ProgressPosition(position))
47+
48+
/** Appends to a task's console. */
49+
suspend fun FlowCollector<TaskEvent>.emitOutput(output: String): Unit = emit(AppendOutput(output))
50+
51+
/** Starts a task (sets position to `0` if it is `-1`). */
52+
suspend fun FlowCollector<TaskEvent>.emitStarted(): Unit = emit(TaskStarted())
53+
54+
/** Fails a task. */
55+
suspend fun FlowCollector<TaskEvent>.emitFailed(): Unit = emit(TaskFailed())
56+
57+
/** Completes a task (sets position to target). */
58+
suspend fun FlowCollector<TaskEvent>.emitCompleted(): Unit = emit(TaskCompleted())
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright (c) 2024-2025 Elide Technologies, Inc.
3+
*
4+
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
5+
* with the License. You may obtain a copy of the License at
6+
*
7+
* https://opensource.org/license/mit/
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10+
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
* License for the specific language governing permissions and limitations under the License.
12+
*/
13+
package elide.tool.cli.progress
14+
15+
/**
16+
* Possible states of a [TrackedTask].
17+
*
18+
* @property displayName Text that should be displayed for the given state.
19+
* @author Lauri Heino <datafox>
20+
*/
21+
enum class TaskState(val displayName: String) {
22+
NOT_STARTED("not started"),
23+
RUNNING("running"),
24+
COMPLETED("completed"),
25+
FAILED("failed"),
26+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright (c) 2024-2025 Elide Technologies, Inc.
3+
*
4+
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
5+
* with the License. You may obtain a copy of the License at
6+
*
7+
* https://opensource.org/license/mit/
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10+
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
* License for the specific language governing permissions and limitations under the License.
12+
*/
13+
package elide.tool.cli.progress
14+
15+
/**
16+
* Immutable state of a task in a progress animation.
17+
*
18+
* @property name Name of the task.
19+
* @property status Current status of the task.
20+
* @property output Unix timestamps mapped to lines of console output of the task.
21+
* @property position Current position of the task, or `-1` if the task has not started.
22+
* @property target Target position of the task. If this is `1`, the task is rendered as indeterminate.
23+
* @property started `true` if the task has started ([position] is not negative).
24+
* @property finished `true` if the task has finished ([position] is equal to [target]).
25+
* @property state current state of this task.
26+
* @author Lauri Heino <datafox>
27+
*/
28+
data class TrackedTask(
29+
val name: String,
30+
val target: Int,
31+
val status: String = "",
32+
val position: Int = -1,
33+
val output: Map<Long, String> = mapOf(),
34+
val failed: Boolean = false,
35+
) {
36+
val started: Boolean get() = position >= 0
37+
val finished: Boolean get() = position == target
38+
val state: TaskState get() = when {
39+
failed -> TaskState.FAILED
40+
!started -> TaskState.NOT_STARTED
41+
!finished -> TaskState.RUNNING
42+
else -> TaskState.COMPLETED
43+
}
44+
}

0 commit comments

Comments
 (0)