Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@ jobs:
- name: Run integration tests
run: cargo test --verbose --test '*'

- name: Test standalone progress reporting
run: |
cargo test -p rb-progress --no-default-features
cargo build -p rb-progress --no-default-features --examples

- name: Test progress UI in a pseudo-terminal
if: matrix.os == 'ubuntu-latest'
run: |
cargo test -p rb-progress --features rb-task
cargo build -p rb-progress --features rb-task --example progress_ui_probe --example tasks
python3 crates/rb-progress/tests/pty_ui_test.py target/debug/examples/progress_ui_probe target/debug/examples/parallel

- name: Build release
run: cargo build --release

Expand Down
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions crates/rb-progress/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[package]
name = "rb-progress"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Terminal progress reporting for task execution"

[features]
default = []
rb-task = ["dep:rb-task"]

[dependencies]
terminal_size = "0.4.3"
unicode-width = "0.2.2"
rb-task = { path = "../rb-task", optional = true }

[[example]]
name = "progress_ui_probe"
required-features = ["rb-task"]

[[example]]
name = "tasks"
required-features = ["rb-task"]
39 changes: 39 additions & 0 deletions crates/rb-progress/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# rb-progress

Progress reporting with a live terminal tree and plain, append-only output when
stderr is redirected. Supports parallel workers, elapsed times, and errors.

```rust,no_run
use rb_progress::Reporter;

let reporter = Reporter::start("Polishing the silver").unwrap();
let progress = reporter.handle();
progress.event("polishing", 0, 3, None);
// Do the work, then report completion.
progress.event("polishing", 3, 3, None);
reporter.finish_with_summary("The silver is ready, sir");
```

Terminal output (timing varies):

```text
┌─ ✓ Polishing the silver
│ ├─ ✓ polishing 3/3 (0.0s)
└─ ✓ The silver is ready, sir (0.0s)
```

Clone a `ProgressHandle` to report from multiple threads. Use `worker_started`,
`worker_event`, and `worker_finished` or `worker_failed` for each worker.
Use `fail` to report an overall error.

`rb-task` is optional and disabled by default. Enable the `rb-task` feature to
connect `Reporter::task_sink()` to `TaskEvents`.

Run the examples:

```sh
cargo run -p rb-progress --example standalone
cargo run -p rb-progress --example parallel
cargo run -p rb-progress --example failure
cargo run -p rb-progress --features rb-task --example tasks
```
18 changes: 18 additions & 0 deletions crates/rb-progress/examples/failure.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use rb_progress::Reporter;
use std::{thread, time::Duration};

fn inspect_teacup() -> Result<(), String> {
thread::sleep(Duration::from_millis(800));
Err("Replace the teacup".into())
}

fn main() {
let reporter = Reporter::start("Inspecting the china").unwrap();
let progress = reporter.handle();
progress.event("inspecting", 0, 1, None);
match inspect_teacup() {
Ok(()) => progress.event("inspecting", 1, 1, None),
Err(error) => progress.fail(error),
}
reporter.finish();
}
34 changes: 34 additions & 0 deletions crates/rb-progress/examples/parallel.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
use rb_progress::Reporter;
use std::{thread, time::Duration};

fn main() {
let reporter = Reporter::start("Preparing the dining room").unwrap();
let progress = reporter.handle();
progress.event("preparing", 0, 3, None);

thread::scope(|scope| {
for (worker, job) in ["polishing silver", "folding napkins", "arranging flowers"]
.into_iter()
.enumerate()
{
let progress = progress.clone();
scope.spawn(move || {
progress.worker_started(worker, job);
for done in 1..=3 {
thread::sleep(Duration::from_millis(300));
progress.worker_event(
Some(worker),
job,
done,
3,
Some(format!("{done}/3 pieces")),
);
}
progress.worker_finished(worker);
});
}
});

progress.event("preparing", 3, 3, None);
reporter.finish_with_summary("The dining room is ready, sir");
}
138 changes: 138 additions & 0 deletions crates/rb-progress/examples/progress_ui_probe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
use rb_progress::Reporter;
use std::{thread, time::Duration};

fn main() {
if std::env::args().any(|arg| arg == "--aggregate") {
let reporter = Reporter::start("processing documents").unwrap();
reporter.handle().event("reading", 1, 1, None);
reporter.handle().event("writing", 1, 1, None);
reporter.finish_with_summary("documents ready");
return;
}
if std::env::args().any(|arg| matches!(arg.as_str(), "--tasks" | "--stress" | "--failure")) {
task_probe();
return;
}
let reporter = Reporter::start("preparing pty-probe bundle").unwrap();
let progress = reporter.handle();
thread::sleep(Duration::from_millis(350));
progress.event("resolved", 1, 1, None);
for worker in 0..12 {
progress.event("downloading", worker, 12, None);
progress.worker_event(
Some(worker),
"downloading",
worker,
12,
Some(format!("gem-{worker}")),
);
progress.event("unpacking", worker + 1, 12, None);
}
thread::sleep(Duration::from_millis(300));
progress.event("packing LMDB", 0, 1, Some("records".into()));
progress.worker_event(Some(9), "packing LMDB", 1, 1, Some("records".into()));
progress.event("packing LMDB", 1, 1, Some("records".into()));
progress.event("preparing native extensions", 0, 0, None);
if std::env::args().any(|arg| arg == "--handoff-output") {
let mut graph = rb_task::TaskGraph::new();
let task = graph.add("preparing content", []);
for line in ["preview one", "preview two", "preview three"] {
reporter.task_sink().event(rb_task::TaskEvent::Output {
task,
worker: 11,
line: line.into(),
elapsed: Duration::ZERO,
});
}
}
progress.suspend();
let native_lines = 10;
eprint!("\x1b[1B\r\x1b[{}L\x1b[1A\r", native_lines - 1);
let rows = [
"├─ ● installing pty-probe bundle | compiling native extensions",
"│ ├─ ● worker 0 (compiling) demo-1.0",
"│ checking for ruby.h... yes",
"│ └─ ● worker 1 (compiling) demo-1.0",
"│ compiling demo.c",
"└─ overall: 1/2 native tasks | pending 1 | elapsed 0.3s",
];
for index in 0..native_lines {
let line = rows.get(index).copied().unwrap_or("");
eprint!("\x1b[2K{line}");
if index + 1 < native_lines {
eprintln!();
}
}
eprint!(
"\x1b[{}A\r\x1b[0J\x1b[{}M",
native_lines - 1,
native_lines - 1
);
progress.event("preparing", 1, 1, None);
thread::sleep(Duration::from_millis(50));
reporter.finish_with_summary("your meticulously prepared bundle is ready, sir (.rb)");
println!("probe complete");
}

fn task_probe() {
use rb_task::{Executor, TaskAction, TaskEvents, TaskGraph};
use std::{collections::BTreeMap, sync::Arc};

let stress = std::env::args().any(|arg| arg == "--stress");
let fail = std::env::args().any(|arg| arg == "--failure");
let reporter = Reporter::start("processing documents").unwrap();
let events = TaskEvents::default();
events.subscribe(reporter.task_sink());
let mut graph = TaskGraph::new();
let prepare = graph.add("preparing content", []);
let publish = graph.add("exporting documents", [prepare]);
let mut actions = BTreeMap::from([
(
prepare,
Arc::new(|context: rb_task::TaskContext| {
context.progress("preparing content", 0, 1, None);
context.output("reading document");
thread::sleep(Duration::from_millis(300));
Ok(())
}) as TaskAction,
),
(
publish,
Arc::new(|context: rb_task::TaskContext| {
context.progress("exporting documents", 0, 1, None);
context.output("writing document");
thread::sleep(Duration::from_millis(300));
Ok(())
}) as TaskAction,
),
]);
if stress || fail {
for index in 0..16 {
let task = graph.add(format!("document {index}"), []);
actions.insert(
task,
Arc::new(move |context: rb_task::TaskContext| {
context.progress("failed", 0, 1, None);
context.output(format!(
"\x1b[2J\x1b[31m{}\x1b[0m\nworking on document {index}\npreview e\u{301}",
"資料".repeat(100)
));
thread::sleep(Duration::from_millis(30));
if fail && index == 0 {
Err("document failure".into())
} else {
Ok(())
}
}) as TaskAction,
);
}
}
let result = Executor::new(if stress || fail { 6 } else { 2 }, events).run(graph, actions);
assert_eq!(result.is_err(), fail);
reporter.finish_with_summary(if fail {
"processing failed"
} else {
"documents ready"
});
println!("probe complete");
}
12 changes: 12 additions & 0 deletions crates/rb-progress/examples/standalone.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
use rb_progress::Reporter;
use std::{thread, time::Duration};

fn main() {
let reporter = Reporter::start("Polishing the silver").unwrap();
let handle = reporter.handle();
for done in 0..=3 {
handle.event("polishing", done, 3, None);
thread::sleep(Duration::from_millis(350));
}
reporter.finish_with_summary("The silver is ready, sir");
}
48 changes: 48 additions & 0 deletions crates/rb-progress/examples/tasks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use rb_progress::Reporter;
use rb_task::{Executor, TaskAction, TaskContext, TaskEvents, TaskGraph};
use std::{collections::BTreeMap, sync::Arc, thread, time::Duration};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let reporter = Reporter::start("Preparing afternoon tea").unwrap();
let events = TaskEvents::default();
events.subscribe(reporter.task_sink());
let mut graph = TaskGraph::new();
let kettle = graph.add("Boil water", []);
let table = graph.add("Lay the table", []);
let serve = graph.add("Serve tea", [kettle, table]);
let actions = BTreeMap::from([
(
kettle,
Arc::new(|ctx: TaskContext| {
ctx.output("The kettle is warming.");
thread::sleep(Duration::from_millis(1200));
Ok(())
}) as TaskAction,
),
(
table,
Arc::new(|ctx: TaskContext| {
ctx.progress("arranging", 0, 1, Some("cups and saucers".into()));
ctx.output("Two places, neatly arranged.");
thread::sleep(Duration::from_millis(600));
Ok(())
}) as TaskAction,
),
(
serve,
Arc::new(|ctx: TaskContext| {
ctx.output("Your tea awaits, sir.");
thread::sleep(Duration::from_millis(400));
Ok(())
}) as TaskAction,
),
]);
let result = Executor::new(2, events).run(graph, actions);
reporter.finish_with_summary(if result.is_ok() {
"Tea is served"
} else {
"Service interrupted"
});
result?;
Ok(())
}
11 changes: 11 additions & 0 deletions crates/rb-progress/src/events.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
use std::time::Duration;

#[derive(Clone, Debug)]
pub struct ProgressEvent {
pub worker: Option<usize>,
pub phase: &'static str,
pub done: usize,
pub total: usize,
pub detail: Option<String>,
pub elapsed: Option<Duration>,
}
14 changes: 14 additions & 0 deletions crates/rb-progress/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#![doc = include_str!("../README.md")]

mod events;
mod plain;
mod render;
mod reporter;
mod state;
#[cfg(feature = "rb-task")]
mod task;
mod terminal;

pub use events::ProgressEvent;
pub use reporter::{ProgressHandle, Reporter};
pub use terminal::format_duration;
Loading
Loading