Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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");
    }
}