facett-pop/src/lib.rs
//! **facett-pop** — a labelled-**progress** component. Generic over any
//! "N things, each `done/total`, under a phase" — bulk loads (knut-popviz's
//! per-label population load), job stages, batch pipelines. A non-graph [`Facet`]
//! to balance the model. The consumer feeds it numbers; it draws bars + exposes
//! the progress as `state_json` for headless robot-testing.
//!
//! ## FC contract (the canonical Elm split — see [`facett_core::Elm`])
//! - **[`Msg`] enum + [`ProgressView::update`]** (FC-2): the robot-/CLI-addressable
//! input surface. facett-pop is *fed* — it has no canvas gestures — so its writable
//! boundary is the data feed itself: set the phase, upsert/advance/remove a row,
//! replace or clear the set. The same channel a host or a headless
//! [`facett_core::harness`] drives.
//! - **Serializable [`ProgressView`] state** (FC-1 / FC-3): the view *is* its own
//! `Model` — phase + rows, round-trippable through serde. `state_json` additionally
//! publishes the derived `done`/`total`/`fraction`/`idle` roll-ups a matrix reads.
//! - **Pure [`view`](ProgressView::view)** (FC-9): paints bars + per-row a11y nodes,
//! mutates nothing, and returns the (empty — display-only) `Msg`s.
//! - **Uninhabited [`Effect`]** (FC-8): facett-pop does no I/O.
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
pub use facett_core::{Facet, FacetCaps};
use serde::{Deserialize, Serialize};
/// One labelled progress row.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProgressItem {
pub label: String,
pub done: u64,
pub total: u64,
}
impl ProgressItem {
pub fn fraction(&self) -> f32 {
if self.total == 0 { 0.0 } else { (self.done as f64 / self.total as f64) as f32 }
}
}
/// A robot-/CLI-addressable control message (**FC-2**) — the named boundary a host
/// or a headless driver feeds the view through. Applied by [`ProgressView::update`],
/// the sole mutation path.
///
/// facett-pop is a *fed* display: unlike a canvas facett there is no click/drag to
/// translate, so the `Msg` vocabulary IS the data-feed surface — the same set of
/// edits a consumer's `pop.items.push(..)` / `pop.phase = ..` used to do inline,
/// now expressed as inspectable messages a robot can replay.
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
/// Set the phase header label (`idle` / `loading` / …).
SetPhase(String),
/// Replace the entire row set.
SetItems(Vec<ProgressItem>),
/// Insert a row, or update `done`/`total` of the existing row with this `label`.
Upsert { label: String, done: u64, total: u64 },
/// Bump an existing row's `done` by `delta` (saturating, clamped to its `total`).
/// No-op if no row carries `label`.
Advance { label: String, delta: u64 },
/// Remove the row with this `label` (no-op if absent).
Remove(String),
/// Drop every row.
Clear,
}
/// Side work as data (**FC-8**). Uninhabited on purpose: facett-pop performs **no**
/// I/O — every [`Msg`] mutates only the in-memory rows — so it has no effect to emit.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
/// A set of progress rows under a phase label — **also its own serializable
/// [`Model`](facett_core::Elm::Model)** (FC-1 / FC-3). The `pub` fields are kept so a
/// consumer can still build one directly; the **only** in-place mutation path a robot
/// drives is [`update`](Self::update).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProgressView {
pub title: String,
pub phase: String,
pub items: Vec<ProgressItem>,
}
impl ProgressView {
pub fn new(title: impl Into<String>) -> Self {
Self { title: title.into(), phase: "idle".into(), items: Vec::new() }
}
pub fn done(&self) -> u64 {
self.items.iter().map(|i| i.done).sum()
}
pub fn total(&self) -> u64 {
self.items.iter().map(|i| i.total).sum()
}
pub fn fraction(&self) -> f32 {
let t = self.total();
if t == 0 { 0.0 } else { (self.done() as f64 / t as f64) as f32 }
}
/// **FC-8 quiescence.** `true` when every row is complete (`done >= total`)
/// or there is nothing loading — the view has settled. Readable without a
/// timing hack (also surfaced in `state_json` as `idle`). An empty set is idle.
pub fn is_idle(&self) -> bool {
self.items.iter().all(|i| i.done >= i.total)
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
/// (always empty) [`Effect`]s the host should run. Every field edit routes here,
/// so a headless `Vec<Msg>` reproduces the live feed exactly.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::SetPhase(p) => self.phase = p,
Msg::SetItems(items) => self.items = items,
Msg::Upsert { label, done, total } => {
if let Some(it) = self.items.iter_mut().find(|i| i.label == label) {
it.done = done;
it.total = total;
} else {
self.items.push(ProgressItem { label, done, total });
}
}
Msg::Advance { label, delta } => {
if let Some(it) = self.items.iter_mut().find(|i| i.label == label) {
it.done = it.done.saturating_add(delta).min(it.total);
}
}
Msg::Remove(label) => self.items.retain(|i| i.label != label),
Msg::Clear => self.items.clear(),
}
Vec::new()
}
/// The progress as the text block the pane renders (`phase` header + one
/// `label: done / total` line per row) — the copyable view.
pub fn as_text(&self) -> String {
let mut out = format!("phase: {} · {} / {}", self.phase, self.done(), self.total());
for it in &self.items {
out.push_str(&format!("\n{}: {} / {}", it.label, it.done, it.total));
}
out
}
/// **FC-9 pure view** — paints the phase header, the aggregate bar, and one
/// uniquely-identified a11y [`ProgressIndicator`](egui::WidgetType::ProgressIndicator)
/// per row (label + fraction as its numeric value, FC-4). It reads `&self` only
/// and **returns** the `Msg`s the frame produced — empty, because facett-pop is a
/// *fed* display with no canvas gestures. The [`impl_facet_via_elm!`] bridge feeds
/// those (none) back through [`update`](Self::update).
pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
let msgs: Vec<Msg> = Vec::new();
ui.label(format!("phase: {} · {} / {}", self.phase, self.done(), self.total()));
ui.add(egui::ProgressBar::new(self.fraction()).show_percentage());
ui.separator();
// Each row's bar gets a stable, unique Id keyed on its label (FC-5) and an
// explicit AccessKit node carrying that label + its fraction as the numeric
// value (FC-4) — so a robot driver finds a specific row by label and reads
// its progress, not just "a progress bar".
let base_id = ui.id().with("pop-rows");
egui::ScrollArea::vertical().show(ui, |ui| {
for it in &self.items {
let resp = ui
.push_id(facett_core::stable_id(base_id, &it.label), |ui| {
ui.add(egui::ProgressBar::new(it.fraction()).text(format!("{}: {} / {}", it.label, it.done, it.total)))
})
.inner;
let frac = it.fraction();
let label = it.label.clone();
resp.widget_info(move || {
facett_core::Semantics::new(egui::WidgetType::ProgressIndicator, label.clone())
.value(frac as f64)
.widget_info()
});
}
});
// ── render-lane emit: this pure-view path RAN ─────────────────────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-pop::ProgressView::view",
"ui_render",
self.total() > 0 || self.items.is_empty(),
&format!("rows={} done={} total={}", self.items.len(), self.done(), self.total()),
);
msgs
}
}
// ── typed copy (§16) — read-only progress dump as Text ────────────────────────
impl CopySource for ProgressView {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
if self.items.is_empty() {
return None;
}
Some(ClipPayload::Text(self.as_text()))
}
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
//
// facett-pop *is* its own `Model`: the view struct is serializable + `PartialEq`, so
// `state()` hands back `&self` and the harness snapshots a clone of it directly.
impl facett_core::Elm for ProgressView {
type Model = ProgressView;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &ProgressView {
self
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
ProgressView::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
ProgressView::view(self, ui)
}
}
// The bridge macro writes `impl Facet for ProgressView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extras below.
// **Form 3 (`custom_state_json`)** because facett-pop publishes a RICHER `state_json`
// than plain `serde(state())` — the derived `done`/`total`/`fraction`/`idle` roll-ups
// a deck/matrix reads — so the default serde `state_json` is suppressed and the exact
// historical key set is preserved verbatim here.
facett_core::impl_facet_via_elm!(ProgressView, custom_state_json, {
fn copy(&mut self) -> Option<String> {
self.copy_payload().map(|p| p.as_text())
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"phase": self.phase,
"done": self.done(),
"total": self.total(),
"fraction": self.fraction(),
// FC-8: a single typed quiescence flag a caller reads instead of
// parsing the free-text `phase`.
"idle": self.is_idle(),
"items": self.items.iter().map(|i| serde_json::json!({
"label": i.label, "done": i.done, "total": i.total
})).collect::<Vec<_>>(),
})
}
/// Bars + labels are standard egui widgets, so they follow the active
/// [`Theme`](facett_core::Theme)'s `Visuals` (set by [`facett_core::set_theme`]);
/// the bars and the vertical scroll area flex to the host's width.
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable().copyable()
}
});
#[cfg(test)]
mod tests {
use super::*;
use facett_core::{Elm, Facet, harness};
fn sample() -> ProgressView {
let mut v = ProgressView::new("Ingest");
v.phase = "loading".into();
v.items = vec![
ProgressItem { label: "Person".into(), done: 50, total: 100 },
ProgressItem { label: "Company".into(), done: 10, total: 10 },
];
v
}
#[test]
fn totals_and_state_json() {
let v = sample();
assert_eq!(v.done(), 60);
assert_eq!(v.total(), 110);
let j = Facet::state_json(&v);
assert_eq!(j["done"], 60);
assert_eq!(j["total"], 110);
assert_eq!(j["items"].as_array().unwrap().len(), 2);
assert_eq!(j["phase"], "loading");
}
#[test]
fn copy_payload_is_the_progress_dump_as_text() {
let mut v = sample();
let p = v.copy_payload().expect("rows copy");
assert_eq!(p.kind(), facett_core::clip::ClipKind::Text);
assert_eq!(p.as_text(), "phase: loading \u{b7} 60 / 110\nPerson: 50 / 100\nCompany: 10 / 10");
assert_eq!(<ProgressView as Facet>::copy(&mut v), Some(p.as_text()));
assert!(ProgressView::new("empty").copy_payload().is_none());
}
// ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
// harness and assert the resulting `ProgressView` Model — no egui, no GPU. ──
#[test]
fn harness_drives_phase_and_row_transitions() {
let mut v = ProgressView::new("Ingest");
// Upsert two rows + set the phase, then advance and complete one.
let snap = harness::snapshot(
&mut v,
[
Msg::SetPhase("loading".into()),
Msg::Upsert { label: "Person".into(), done: 20, total: 100 },
Msg::Upsert { label: "Company".into(), done: 0, total: 10 },
Msg::Advance { label: "Person".into(), delta: 30 }, // 20 -> 50
Msg::Upsert { label: "Company".into(), done: 10, total: 10 }, // updates existing row
],
);
assert_eq!(snap.phase, "loading");
assert_eq!(snap.items.len(), 2, "Upsert updates in place, never duplicates a label");
assert_eq!(snap.items[0], ProgressItem { label: "Person".into(), done: 50, total: 100 });
assert_eq!(snap.done(), 60);
assert_eq!(snap.total(), 110);
assert!(!snap.is_idle(), "Person 50/100 is still loading");
// The snapshot is a clone of the live state — same transition, headlessly.
assert_eq!(&snap, v.state());
}
#[test]
fn harness_advance_clamps_remove_and_clear() {
let mut v = ProgressView::new("Ingest");
// Advance past total clamps to total (idle); Remove + Clear prune rows.
let snap = harness::snapshot(
&mut v,
[
Msg::Upsert { label: "a".into(), done: 5, total: 10 },
Msg::Upsert { label: "b".into(), done: 0, total: 4 },
Msg::Advance { label: "a".into(), delta: 999 }, // clamps to 10
Msg::Advance { label: "missing".into(), delta: 1 }, // no-op
Msg::Remove("b".into()),
],
);
assert_eq!(snap.items, vec![ProgressItem { label: "a".into(), done: 10, total: 10 }]);
assert!(snap.is_idle(), "the one remaining row is complete");
assert_eq!(Facet::state_json(&snap)["idle"], true);
// Clear empties the set (still idle — nothing loading).
let snap = harness::snapshot(&mut v, [Msg::Clear]);
assert!(snap.items.is_empty() && snap.is_idle());
}
#[test]
fn drive_reports_no_effects_fc8() {
let mut v = sample();
let effects = harness::drive(
&mut v,
[Msg::Advance { label: "Person".into(), delta: 10 }, Msg::SetPhase("done".into()), Msg::Clear],
);
assert!(effects.is_empty(), "FC-8: facett-pop performs no I/O — `Effect` is uninhabited");
}
#[test]
fn model_round_trips_through_serde_fc3() {
let mut v = ProgressView::new("Ingest");
let model = harness::snapshot(
&mut v,
[Msg::SetPhase("loading".into()), Msg::Upsert { label: "Person".into(), done: 50, total: 100 }],
);
let json = serde_json::to_string(&model).unwrap();
let back: ProgressView = serde_json::from_str(&json).unwrap();
assert_eq!(back, model, "the observable Model round-trips through serde");
}
}