facett-jobview/src/lib.rs
//! **facett-jobview** — a job/task-list viewer, in the spirit of a desktop task
//! manager: one row per long-running job (kind · target · status · elapsed ·
//! detail), themeable, sortable, with a kind filter and a live counts header.
//! It renders a **hierarchical, streaming job tree** — parents fold/unfold, any
//! row selects, and children stream in over successive refreshes.
//!
//! **Host-agnostic by construction.** This crate knows nothing about any
//! particular backend (no nornir, no warehouse, no gRPC). The host hands it a
//! `Vec<`[`JobEntry`]`>` each refresh (push, via [`JobList::set_jobs`]) or
//! implements [`JobSource`] for the list to pull (via [`JobList::set_source`]).
//! [`JobEntry`] is the one simple datatype the list renders, carrying free-form
//! `meta` key/values so a host can attach whatever extra info it has.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! - **[`JobState`]** — the complete observable, serializable, round-trippable
//! state: the job rows plus the view controls (sort key/direction, kind filter,
//! the selected row, and the folded-parent set). Expand/collapse (`folded`) and
//! selection (`selected`) key on **stable job ids** (FC-5), never tree-position
//! indices — the tree streams and reorders, so a positional key would drift.
//! - **[`Msg`] + [`JobList::update`]** — the single mutation path (FC-2): fold a
//! parent, select a row, click a sort header, set the kind filter, refresh the
//! rows. The same gesture the canvas drives, so live == headless.
//! - **[`JobList::view`]** — a **pure** paint (FC-9): it reads `&self`, paints the
//! tree, and *returns* the [`Msg`]s the interactions produced; the
//! [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them.
//! The only `&self`-interior mutation is the ephemeral scroll/fade animation
//! cache (FC-7), deliberately kept OUT of the observable Model.
//! - **Rich `state_json`** — the fold-aware display *tree* (parents then children,
//! `depth` + ASCII branch guides), the hierarchy summary, and the motion state a
//! headless driver reads are *derived* from the Model, so the macro is invoked in
//! **form 3** (`custom_state_json`) to publish those keys (which facett-demo's
//! mega_matrix reads for the `"jobs"` cell) instead of a plain `serde(state())`.
use std::cell::{Cell, RefCell};
use facett_core::effects::FadeTrack;
use facett_core::scroll_engine::{SmoothScroll, smooth_scroll_area};
use facett_core::{Elm, FacetCaps, Theme, theme};
use serde::{Deserialize, Serialize};
/// Hover / selection cross-fade length (seconds) for a row highlight.
const ROW_FADE_SECS: f32 = 0.12;
/// Fold expand/collapse tween length (seconds) — the subtree-height animation.
const FOLD_SECS: f32 = 0.16;
// Re-export the canonical Facet trait so a host can drive [`JobList`] (call
// `.ui(..)` / `.state_json()`) without taking its own facett-core dependency.
pub use facett_core::Facet;
/// Lifecycle status of a job. The list colours each row by this.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
/// Accepted, not started yet.
Queued,
/// In flight.
Running,
/// Finished successfully.
Done,
/// Finished with an error (or abandoned).
Failed,
}
impl JobStatus {
/// Stable lowercase tag (matches a host's `status` string and `state_json`).
pub fn as_str(self) -> &'static str {
match self {
JobStatus::Queued => "queued",
JobStatus::Running => "running",
JobStatus::Done => "done",
JobStatus::Failed => "failed",
}
}
/// Parse a host status string; unknown ⇒ [`JobStatus::Queued`].
pub fn parse(s: &str) -> Self {
match s {
"running" => JobStatus::Running,
"done" => JobStatus::Done,
"failed" => JobStatus::Failed,
_ => JobStatus::Queued,
}
}
/// `true` while the job may still change (queued or running).
pub fn is_active(self) -> bool {
matches!(self, JobStatus::Queued | JobStatus::Running)
}
/// A short status glyph for the row.
pub fn glyph(self) -> &'static str {
match self {
JobStatus::Queued => "…",
JobStatus::Running => "▶",
JobStatus::Done => "✓",
JobStatus::Failed => "✗",
}
}
/// Row colour — semantic ongoing/done/failed traffic-light, with queued dim:
/// running = 🟡 amber-yellow (ongoing), done = 🟢 the theme's `point` (green-ish),
/// failed = 🔴 a fixed warm red, queued = dim. Running is a fixed yellow rather
/// than the theme accent so "in progress" reads unambiguously against any palette.
pub fn color(self, th: &Theme) -> egui::Color32 {
match self {
JobStatus::Queued => th.text_dim,
JobStatus::Running => egui::Color32::from_rgb(232, 193, 42),
JobStatus::Done => th.point,
JobStatus::Failed => facett_core::look::RED,
}
}
}
/// One job row — the simple datatype the list renders. Owned + cheap to clone so
/// a host can rebuild the list each refresh without lifetime juggling.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct JobEntry {
/// Stable id (used as the row key / selection identity).
pub id: String,
/// Op family, e.g. `"docs_book_svg"`, `"bench_run"` — the host's vocabulary.
pub kind: String,
/// What the job acts on (repo / workspace / artifact).
pub target: String,
pub status: JobStatus,
/// Start time, microseconds since the unix epoch (drives newest-first sort).
pub started_micros: i64,
/// Wall-clock duration once terminal; `None` while running/queued.
pub elapsed_ms: Option<i64>,
/// One-line detail (args / counts / error), shown in the last column.
pub detail: String,
/// Arbitrary extra key/values the host wants to surface (shown on the
/// selected row's expanded line). Empty is fine.
pub meta: Vec<(String, String)>,
/// **Hierarchy** — the id of this job's PARENT job, when it is a sub-job of a
/// larger op (`None` = a top-level job). A host emitting a multi-phase /
/// multi-member op (e.g. a workspace *populate*: one parent job + one child
/// per member clone + per build stage) sets this to the parent's [`id`]; the
/// list then renders the parent as a FOLDABLE row whose children appear
/// indented underneath when unfolded. Host-agnostic: korp/njord/nornir all
/// reuse it — a child is simply any entry whose `parent_id` matches another
/// entry's `id`. Children stream in over successive [`set_jobs`](JobList::set_jobs)
/// refreshes as the op progresses (running → done), so the tree fills live.
pub parent_id: Option<String>,
}
impl JobEntry {
/// Minimal constructor; fill the rest with struct-update syntax.
pub fn new(id: impl Into<String>, kind: impl Into<String>, status: JobStatus) -> Self {
Self {
id: id.into(),
kind: kind.into(),
target: String::new(),
status,
started_micros: 0,
elapsed_ms: None,
detail: String::new(),
meta: Vec::new(),
parent_id: None,
}
}
/// `true` when this entry is a sub-job (has a parent). Convenience over
/// `parent_id.is_some()`.
pub fn is_child(&self) -> bool {
self.parent_id.is_some()
}
/// Stamp a **determinate** progress fraction (clamped to `[0,1]`) so the row
/// draws a filled progress meter to that fill. Carried in [`meta`](Self::meta)
/// under [`PROGRESS_META_KEY`] rather than a dedicated struct field: this keeps
/// every `JobEntry { .. }` construction site in every host valid (adding a
/// non-defaulted field would break full-struct-literal callers), and `meta` is
/// exactly the "arbitrary extra key/values the host wants to surface" channel.
/// Re-stamping replaces any prior progress marker (idempotent).
pub fn with_progress(mut self, fraction: f32) -> Self {
self.set_progress_meta(format!("{:.4}", fraction.clamp(0.0, 1.0)));
self
}
/// Stamp an **indeterminate** progress marker (running, no measurable total) so
/// the row draws an animated sweeping meter — the honest "in flight, unmeasured"
/// state, never a fake full bar. See [`with_progress`](Self::with_progress).
pub fn with_indeterminate_progress(mut self) -> Self {
self.set_progress_meta(PROGRESS_INDETERMINATE.to_string());
self
}
/// Stamp progress from a `current`/`total` count (a "3/10 tasks" / bytes form).
/// `total == 0` ⇒ indeterminate. See [`with_progress`](Self::with_progress).
pub fn with_progress_ratio(self, current: u64, total: u64) -> Self {
if total == 0 {
self.with_indeterminate_progress()
} else {
self.with_progress(current as f32 / total as f32)
}
}
/// Stamp the **phase** a running job reports — `cloning`, `indexing`, `embedding`:
/// *what* it is doing, next to how far along it is. Carried under
/// [`PHASE_META_KEY`]; see that constant for why this writer is shared.
///
/// `None`, empty and whitespace-only all stamp NOTHING and clear any prior phase.
/// That is the load-bearing half: a key present with a blank value paints an empty
/// cell where "no phase reported" is the fact, and a host mapping
/// `Option<String> → String` with `unwrap_or_default()` would produce exactly that.
/// Idempotent — a producer republishing on every phase change never accumulates
/// entries.
pub fn with_phase(mut self, phase: Option<&str>) -> Self {
self.meta.retain(|(k, _)| k != PHASE_META_KEY);
if let Some(p) = phase.map(str::trim).filter(|p| !p.is_empty()) {
self.meta.push((PHASE_META_KEY.to_string(), p.to_string()));
}
self
}
/// The phase this row reports, if any — the read side of [`with_phase`](Self::with_phase).
/// `None` when no phase was stamped (never `Some("")`).
pub fn phase(&self) -> Option<&str> {
self.meta
.iter()
.find(|(k, _)| k == PHASE_META_KEY)
.map(|(_, v)| v.as_str())
.filter(|v| !v.is_empty())
}
/// Replace the [`PROGRESS_META_KEY`] meta entry with `value` (private helper for
/// the `with_progress*` builders — keeps the meta channel single-slotted).
fn set_progress_meta(&mut self, value: String) {
self.meta.retain(|(k, _)| k != PROGRESS_META_KEY);
self.meta.push((PROGRESS_META_KEY.to_string(), value));
}
/// Stamp a **step meter** ("step 3 of 4", one segment per step) onto this row, under
/// [`STEPS_META_KEY`]. Re-stamping replaces any prior meter (idempotent), so a
/// producer republishing on every step change never accumulates entries.
///
/// Independent of [`with_progress`](Self::with_progress): a row may carry a step
/// meter, a fraction meter, both, or neither.
pub fn with_steps(mut self, meter: &StepMeter) -> Self {
let encoded = serde_json::to_string(meter).unwrap_or_default();
self.meta.retain(|(k, _)| k != STEPS_META_KEY);
self.meta.push((STEPS_META_KEY.to_string(), encoded));
self
}
/// The **step meter** this row should draw, decoded from [`meta`](Self::meta)'s
/// [`STEPS_META_KEY`] entry.
///
/// `None` when there is no entry, when it does not parse, or when it parses to zero
/// steps — an unparseable meter draws nothing rather than an empty bar that would
/// read as "0 of 0 done".
pub fn steps_meter(&self) -> Option<StepMeter> {
let (_, v) = self.meta.iter().find(|(k, _)| k == STEPS_META_KEY)?;
let m: StepMeter = serde_json::from_str(v).ok()?;
(!m.steps.is_empty()).then_some(m)
}
/// The **honest** progress meter this row should draw, decoded from
/// [`meta`](Self::meta)'s [`PROGRESS_META_KEY`] entry:
///
/// - a parseable fraction (`"0.4"`) or ratio (`"3/10"`) ⇒
/// [`ProgressMeter::Determinate`] (filled to that fraction, clamped);
/// - the literal `"indeterminate"` (or a `"n/0"` ratio) ⇒
/// [`ProgressMeter::Indeterminate`] (an animated sweep);
/// - **no** progress marker ⇒ [`ProgressMeter::Indeterminate`] while the job is
/// [`JobStatus::Running`] (it IS in flight, we simply have no measure), else
/// [`ProgressMeter::None`] (no bar at all — never a fake full one).
pub fn progress_meter(&self) -> ProgressMeter {
if let Some((_, v)) = self.meta.iter().find(|(k, _)| k == PROGRESS_META_KEY) {
let v = v.trim();
if v.eq_ignore_ascii_case(PROGRESS_INDETERMINATE) {
return ProgressMeter::Indeterminate;
}
if let Ok(f) = v.parse::<f32>() {
return ProgressMeter::Determinate(f.clamp(0.0, 1.0));
}
if let Some((a, b)) = v.split_once('/')
&& let (Ok(a), Ok(b)) = (a.trim().parse::<f64>(), b.trim().parse::<f64>())
{
return if b > 0.0 {
ProgressMeter::Determinate((a / b).clamp(0.0, 1.0) as f32)
} else {
ProgressMeter::Indeterminate
};
}
}
// No explicit progress marker: a running job honestly reads as indeterminate,
// a queued/terminal job draws nothing.
if self.status == JobStatus::Running {
ProgressMeter::Indeterminate
} else {
ProgressMeter::None
}
}
}
/// The [`meta`](JobEntry::meta) key a job's progress fraction/marker rides under, so
/// a per-row progress meter can be plumbed WITHOUT a new [`JobEntry`] struct field
/// (which would break every full-struct-literal construction site in the hosts).
/// The value is either a fraction in `[0,1]` (`"0.4"`), a `"current/total"` ratio
/// (`"3/10"`), or the literal [`PROGRESS_INDETERMINATE`]. A jera host maps its
/// `JobReports::progress_meta()` straight into this slot.
pub const PROGRESS_META_KEY: &str = "progress";
/// The [`PROGRESS_META_KEY`] value that marks an **indeterminate** meter (running,
/// no measurable total) — an animated sweep, never a fake full bar.
pub const PROGRESS_INDETERMINATE: &str = "indeterminate";
/// The [`JobEntry::meta`] key a job's **phase** rides under — *what* a running job is
/// doing right now (`cloning`, `indexing`, `embedding`), as opposed to how far along it
/// is ([`PROGRESS_META_KEY`]) or which of its named steps it is on ([`STEPS_META_KEY`]).
/// Same no-new-struct-field channel, same reason: a non-defaulted field breaks every
/// full-struct-literal construction site in every host.
///
/// **Declared here because it had TWO independent droppers.** A jera `JobRecord` has
/// carried `phase` durably for a while — the daemon's report mirror writes it, and
/// jera's `upsert` grew a preserve limb so a `finish()` cannot erase it — and *both*
/// host mappers that turn a `JobRecord` into a [`JobEntry`]
/// (`dwarves_embed::jobsfleet::entry_from_record` and `nornir::viz::nornir_tab::job_entry`)
/// simply did not copy the field. Two hosts, one field, zero surfacing: the write side
/// was fixed twice while the read side could never show the result. The key and the
/// [`JobEntry::with_phase`] writer live here so there is one spelling and one
/// empty-value rule for every host, instead of two `meta.push(("phase", …))` twins that
/// agree until one of them does not.
pub const PHASE_META_KEY: &str = "phase";
/// How a job row's progress meter should render — the honest, decoded outcome of
/// [`JobEntry::progress_meter`]. `Determinate` fills a bar to its fraction;
/// `Indeterminate` sweeps a highlight (running, no measure); `None` draws nothing.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ProgressMeter {
/// A filled bar at this completion fraction (`∈[0,1]`, already clamped).
Determinate(f32),
/// An animated sweeping bar — in flight, no measurable fraction.
Indeterminate,
/// No meter at all (queued/terminal with no reported progress).
None,
}
/// The filled width (px) of the **determinate** portion of a progress meter
/// `track_w` px wide — the exact value the paint path uses for the fill rect, and a
/// pure, value-assertable proxy for "the bar is drawn to N%". `Indeterminate`/`None`
/// have no determinate fill (`0.0`).
pub fn meter_fill_width(meter: ProgressMeter, track_w: f32) -> f32 {
match meter {
ProgressMeter::Determinate(f) => track_w * f.clamp(0.0, 1.0),
ProgressMeter::Indeterminate | ProgressMeter::None => 0.0,
}
}
// ───────────────────────────────────────────────────────────────────────────────
// The STEP meter — "step 3 of 4", one segment per step
// ───────────────────────────────────────────────────────────────────────────────
/// The [`JobEntry::meta`] key a job's **step meter** rides under — the same
/// no-new-struct-field channel [`PROGRESS_META_KEY`] uses, for the same reason (a new
/// non-defaulted field breaks every full-struct-literal construction site in every host).
///
/// The value is a [`StepMeter`] as compact JSON. A producer that runs a job in named
/// steps (a collector's convert → tables → relations → tiles chain) stamps it with
/// [`JobEntry::with_steps`]; a producer that only knows a fraction keeps using
/// [`PROGRESS_META_KEY`]. The two are independent: a row may carry either, both or
/// neither.
pub const STEPS_META_KEY: &str = "steps";
/// Where one step of a job's **current run** stands.
///
/// Deliberately not [`JobStatus`]: that is the status of the *job*, and a four-step job
/// is `Running` for the whole of steps 1–4. This says which of those four is happening,
/// which is the only thing a *"step 3 of 4"* bar can be drawn from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StepPhase {
/// Not reached yet in this run.
#[default]
Pending,
/// Being worked on right now. **At most one per run.**
Active,
/// Finished in this run.
Done,
/// The run died here.
Failed,
}
impl StepPhase {
/// Stable lowercase key — the `state_json` encoding a robot asserts on.
pub fn key(self) -> &'static str {
match self {
StepPhase::Pending => "pending",
StepPhase::Active => "active",
StepPhase::Done => "done",
StepPhase::Failed => "failed",
}
}
/// The upper-case tag a hover line shows — the shouting half of [`key`](Self::key),
/// and the only place that spelling is produced.
pub fn tag(self) -> &'static str {
match self {
StepPhase::Pending => "PENDING",
StepPhase::Active => "ACTIVE",
StepPhase::Done => "DONE",
StepPhase::Failed => "FAILED",
}
}
/// Parse a [`key`](Self::key) back; unknown text ⇒ [`StepPhase::Pending`], never a
/// panic and never an optimistic `Done`.
pub fn parse(s: &str) -> StepPhase {
match s.trim().to_ascii_lowercase().as_str() {
"active" => StepPhase::Active,
"done" => StepPhase::Done,
"failed" => StepPhase::Failed,
_ => StepPhase::Pending,
}
}
/// The segment colour. `Done` reads with the row's done-green, `Active` with the
/// same amber [`JobStatus::Running`] uses, `Failed` with facett's warm red, and
/// `Pending` is a dim track — so a half-finished chain is legible at a glance
/// without a legend.
pub fn color(self, th: &Theme) -> egui::Color32 {
match self {
StepPhase::Pending => th.edge.linear_multiply(0.55),
StepPhase::Active => JobStatus::Running.color(th),
StepPhase::Done => th.point,
StepPhase::Failed => facett_core::look::RED,
}
}
}
/// One step of a job's run — everything a segment and its tooltip need, and nothing a
/// consumer has to re-derive.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StepCell {
/// Stable short key (`"a"`, `"b"`, `"tile"`) — the hover prefix and the
/// `state_json` id.
pub key: String,
/// The human label (`"relation clip + geometry assembly"`).
pub label: String,
/// Where this step stands.
pub phase: StepPhase,
/// **The tooltip line for this step, written by the producer** — carried rather than
/// composed here so the words a human reads have exactly one author. See
/// [`StepMeter`]'s docs.
#[serde(default)]
pub line: String,
/// **A GENUINE `(current, total)` count for this step, or `None`.**
///
/// `None` is the honest default and must stay cheap to choose: a producer that has
/// no counter for a step says nothing, and [`meter`](Self::meter) then renders the
/// step [`ProgressMeter::Indeterminate`] while it is active. **Never synthesise a
/// fraction to fill this in** — an invented percentage is indistinguishable from a
/// measured one on screen, which makes every measured one worthless.
///
/// Where a real number exists it belongs here and the step reads
/// [`ProgressMeter::Determinate`]. Two worked cases:
///
/// - **dwarves populate, step "clone"** — the warehouse's `clone_events` writes one
/// row per member attempt with ok/failed, so `Some((n_ok, 19))` is measured, not
/// estimated.
/// - **korp's region convert** — osm-katana logs phase transitions and no counter
/// korp reads, so every step there is `None` today. The step bar still says which
/// of the four is running, which was the whole ask; it does not pretend to know
/// how far into one it is.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub count: Option<(u64, u64)>,
}
impl StepCell {
/// Build a cell, deriving [`line`](Self::line) from the parts when the producer has
/// no better sentence of its own.
pub fn new(key: impl Into<String>, label: impl Into<String>, phase: StepPhase) -> StepCell {
let key = key.into();
let label = label.into();
let line = format!("{key} {label} {}", phase.key().to_ascii_uppercase());
StepCell { key, label, phase, line, count: None }
}
/// Attach a **measured** `(current, total)` to this step. Only call this with a
/// number the producer actually counted — see [`count`](Self::count).
pub fn with_count(mut self, current: u64, total: u64) -> Self {
self.count = Some((current, total));
self
}
/// **How much of THIS step is known to be done** — expressed in the crate's existing
/// [`ProgressMeter`] vocabulary rather than a second one (LAW 5).
///
/// - a measured [`count`](Self::count) ⇒ [`ProgressMeter::Determinate`];
/// - active with no count ⇒ [`ProgressMeter::Indeterminate`] — in flight, unmeasured,
/// and saying so;
/// - pending / done / failed with no count ⇒ [`ProgressMeter::None`]: the segment's
/// own [`StepPhase`] colour already carries the whole answer, and a bar on top of it
/// would be a second, weaker rendering of the same fact.
///
/// `total == 0` is treated as unmeasured, not as a division by zero.
pub fn meter(&self) -> ProgressMeter {
match self.count {
Some((_, 0)) => ProgressMeter::Indeterminate,
Some((c, t)) => ProgressMeter::Determinate((c as f32 / t as f32).clamp(0.0, 1.0)),
None if self.phase == StepPhase::Active => ProgressMeter::Indeterminate,
None => ProgressMeter::None,
}
}
}
/// **A job's step meter** — an ordered list of steps, the run it belongs to, and the two
/// sentences a human reads.
///
/// # Why `caption`/`hover` are carried, not computed
///
/// The producer owns the words. A collector says *"run #3 · step 3 of 4 · relation clip +
/// geometry assembly"*; this crate paints that string and republishes that string. If the
/// widget composed its own sentence from `steps`, there would be two authors of the same
/// text and `state_json` could report a sentence that was never drawn — `seam-1`, where a
/// published theme switch outlived the paint that never happened.
///
/// [`caption_fallback`](Self::caption_fallback) exists only for a producer that stamps
/// steps without a sentence; it is used when `caption` is empty and never overrides it.
///
/// # Run identity
///
/// [`generation`](Self::generation) is the producer's run number. It is what makes a
/// restart legible: a bar that read *"step 3 of 4"* and now reads *"step 1 of 4"* has
/// either lost ground or begun again, and only the run number tells a viewer which.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct StepMeter {
/// The producer's monotone run number (`0` ⇒ the producer published none).
#[serde(default)]
pub generation: u64,
/// The one-line sentence drawn next to the bar, **verbatim from the producer**.
#[serde(default)]
pub caption: String,
/// The multi-line tooltip block, **verbatim from the producer** (typically the
/// why-this-run-started line followed by one line per step).
#[serde(default)]
pub hover: String,
/// The steps, in dependency order.
#[serde(default)]
pub steps: Vec<StepCell>,
}
impl StepMeter {
/// Build a meter from steps alone, deriving both sentences. A producer with its own
/// words should set [`caption`](Self::caption)/[`hover`](Self::hover) instead.
pub fn from_steps(generation: u64, steps: Vec<StepCell>) -> StepMeter {
let mut m = StepMeter { generation, caption: String::new(), hover: String::new(), steps };
m.caption = m.caption_fallback();
m.hover = m.steps.iter().map(|s| s.line.clone()).collect::<Vec<_>>().join("\n");
m
}
/// How many steps — the *of N*.
pub fn total(&self) -> usize {
self.steps.len()
}
/// The index of the step being worked on, if any.
pub fn active(&self) -> Option<usize> {
self.steps.iter().position(|s| s.phase == StepPhase::Active)
}
/// The index of the step that failed, if any.
pub fn failed(&self) -> Option<usize> {
self.steps.iter().position(|s| s.phase == StepPhase::Failed)
}
/// How many steps are finished.
pub fn done(&self) -> usize {
self.steps.iter().filter(|s| s.phase == StepPhase::Done).count()
}
/// Every step finished. **An empty meter is not complete** — a zero-step bar reading
/// "complete" is the hollow green a step bar exists to prevent.
pub fn is_complete(&self) -> bool {
!self.steps.is_empty() && self.steps.iter().all(|s| s.phase == StepPhase::Done)
}
/// The **1-based** step number a human reads: the active step, else the failed one,
/// else how many are done. Never exceeds [`total`](Self::total).
pub fn human_index(&self) -> usize {
self.active().or_else(|| self.failed()).map_or_else(|| self.done(), |i| i + 1)
}
/// The sentence used when the producer supplied none. Finished steps only — this
/// never guesses how far *into* a step the work has got.
pub fn caption_fallback(&self) -> String {
if self.steps.is_empty() {
return String::new();
}
let run =
if self.generation > 0 { format!("run #{} · ", self.generation) } else { String::new() };
match (self.failed(), self.active()) {
(Some(i), _) => format!("{run}step {} of {} FAILED", i + 1, self.total()),
(None, Some(i)) => format!("{run}step {} of {}", i + 1, self.total()),
(None, None) => format!("{run}{} of {} steps complete", self.done(), self.total()),
}
}
/// The caption actually drawn: the producer's, or the fallback when it is blank.
/// **The one place the drawn sentence is chosen** — the render path and
/// `state_json` both call this, so they cannot disagree.
pub fn caption_text(&self) -> String {
if self.caption.trim().is_empty() { self.caption_fallback() } else { self.caption.clone() }
}
/// The tooltip actually shown: the producer's `hover`, or the per-step lines joined.
/// Same one-writer argument as [`caption_text`](Self::caption_text).
pub fn hover_text(&self) -> String {
if self.hover.trim().is_empty() {
self.steps.iter().map(|s| s.line.clone()).collect::<Vec<_>>().join("\n")
} else {
self.hover.clone()
}
}
/// The meter as `state_json` data — every human-visible sentence carried **verbatim**
/// (`caption`, `hover`, `steps[].line`), so a robot asserts the drawn text and not a
/// second rendering of the same model.
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"generation": self.generation,
"caption": self.caption_text(),
"hover": self.hover_text(),
"index": self.human_index(),
"total": self.total(),
"done": self.done(),
"active": self.active(),
"failed": self.failed(),
"complete": self.is_complete(),
"steps": self.steps.iter().map(|s| serde_json::json!({
"key": s.key,
"label": s.label,
"phase": s.phase.key(),
"line": s.line,
// The step's own fill, in the crate's existing ProgressMeter vocabulary.
// `indeterminate` here is a POSITIVE statement - this step is running and
// its producer has no counter - not a missing value.
"meter": match s.meter() {
ProgressMeter::Determinate(f) => serde_json::json!({
"kind": "determinate",
"fraction": f,
"current": s.count.map(|(c, _)| c),
"total": s.count.map(|(_, t)| t),
}),
ProgressMeter::Indeterminate => serde_json::json!({ "kind": "indeterminate" }),
ProgressMeter::None => serde_json::Value::Null,
},
})).collect::<Vec<_>>(),
})
}
/// **Read a published [`state_json`](Self::state_json) BACK into a meter** — the
/// inverse of the projection directly above, deliberately kept beside it so the two
/// cannot drift apart unnoticed (`state_json_round_trips_through_from_state_json`
/// asserts they agree, and fails if either side gains a field the other ignores).
///
/// # Why this exists
///
/// `state_json` is a HAND-WRITTEN projection, not the serde derive. MEASURED, not
/// assumed: the derive survives more of it than it looks — `StepPhase` carries
/// `#[serde(rename_all = "lowercase")]`, so the `phase` strings parse — but it loses
/// **`count`**, which the projection folds into the nested `meter` object and the
/// derive has no field for. A consumer using the derive therefore shows a measured
/// step as an indeterminate shimmer: a bar that renders, is plausible, and is wrong
/// about the one number the producer actually knew. That single field is what this
/// reader is for, and the test below asserts exactly that difference.
///
/// The consumer this was written for is a THIN client. korp's step board lives in the
/// PRODUCER's process: the emitters are in `korp/src/regions.rs` and the board is
/// published into the SERVER's `$KORP_STATE`. A `korp-ui --server` reads its own
/// state file, so it drew an empty local board while the server was three steps into
/// a planet convert — and the map pane's own help text promised it "shows its
/// progress while it runs". The progress existed, on the wire, in a field nothing
/// read back. The owner, 2026-08-04: *"collectorns status ska jag kunna se i korp-ui."*
///
/// Returns `None` for a value that is not an object. Missing keys take their
/// defaults — a producer on an older build is read as far as it published, never
/// rejected wholesale.
pub fn from_state_json(v: &serde_json::Value) -> Option<StepMeter> {
let o = v.as_object()?;
let steps = o
.get("steps")
.and_then(|s| s.as_array())
.map(|arr| arr.iter().filter_map(StepCell::from_state_json).collect())
.unwrap_or_default();
Some(StepMeter {
generation: o.get("generation").and_then(|g| g.as_u64()).unwrap_or(0),
caption: o.get("caption").and_then(|c| c.as_str()).unwrap_or_default().to_string(),
hover: o.get("hover").and_then(|h| h.as_str()).unwrap_or_default().to_string(),
steps,
})
}
}
impl StepCell {
/// Read one published step object back into a cell — the inverse of the `steps[]`
/// entry [`StepMeter::state_json`] writes. `count` comes out of the nested `meter`
/// object's `current`/`total`, which is where the projection put it; a step whose
/// meter is `indeterminate` or `null` correctly reads back as `count: None`, so an
/// unmeasured step stays unmeasured rather than acquiring an invented fraction.
pub fn from_state_json(v: &serde_json::Value) -> Option<StepCell> {
let o = v.as_object()?;
let phase = match o.get("phase").and_then(|p| p.as_str()).unwrap_or("pending") {
"active" => StepPhase::Active,
"done" => StepPhase::Done,
"failed" => StepPhase::Failed,
_ => StepPhase::Pending,
};
let count = o.get("meter").and_then(|m| m.as_object()).and_then(|m| {
match (m.get("current").and_then(|c| c.as_u64()), m.get("total").and_then(|t| t.as_u64())) {
(Some(c), Some(t)) => Some((c, t)),
_ => None,
}
});
Some(StepCell {
key: o.get("key").and_then(|k| k.as_str()).unwrap_or_default().to_string(),
label: o.get("label").and_then(|l| l.as_str()).unwrap_or_default().to_string(),
phase,
line: o.get("line").and_then(|l| l.as_str()).unwrap_or_default().to_string(),
count,
})
}
}
/// **The segment rectangles of a step meter** — pure geometry, so a test asserts *"four
/// segments, the third one starts at x=…"* with no GPU and no window.
///
/// Segments split `rect` evenly with a `gap` px hairline between them. A meter too narrow
/// for its gaps degrades to touching segments rather than to negative widths.
pub fn step_segments(rect: egui::Rect, n: usize, gap: f32) -> Vec<egui::Rect> {
if n == 0 {
return Vec::new();
}
let n_f = n as f32;
let total_gap = gap * (n_f - 1.0);
let roomy = rect.width() > total_gap;
let seg_w = if roomy { (rect.width() - total_gap) / n_f } else { rect.width() / n_f };
let step = seg_w + if roomy { gap } else { 0.0 };
(0..n)
.map(|i| {
egui::Rect::from_min_size(
egui::pos2(rect.min.x + i as f32 * step, rect.min.y),
egui::vec2(seg_w, rect.height()),
)
})
.collect()
}
/// Width (px) of the segmented step meter — wider than [`METER_W`] because it must hold
/// N distinguishable segments rather than one fill.
const STEP_METER_W: f32 = 76.0;
/// Height (px) of the segmented step meter — matches the plain meter so a row holding
/// either keeps the same rhythm.
const STEP_METER_H: f32 = 6.0;
/// The hairline between segments. Without it a run of `Done` segments reads as one solid
/// bar and *"3 of 4"* becomes unreadable at a glance — the gap IS the step count.
const STEP_METER_GAP: f32 = 2.0;
/// Paint a job row's **segmented step meter**: one rounded segment per step, coloured by
/// its [`StepPhase`], with the producer's caption on hover of the meter and the
/// producer's per-step line on hover of a segment.
///
/// FC-4/FC-5: each segment gets an AccessKit node keyed on `(row_id, step.key)` — a
/// stable domain key, never the loop index — so a robot can find *"step c"* by label
/// instead of by pixel. The meter as a whole is one more node carrying the caption, so
/// `LabelVisible("run #3 · step 3 of 4 · …")` is assertable headlessly.
/// **Paint a step meter on its own, outside a [`JobList`] row.**
///
/// The same painter the job tree uses, with the current theme resolved for you — so a
/// host that has one step bar to show (dwarves' populate pane) draws the identical
/// widget, with the identical AccessKit nodes a robot asserts on, instead of
/// hand-rolling a second segmented bar that would drift from this one.
///
/// `row_id` namespaces the per-segment nodes; pass the run's stable id.
pub fn step_meter_ui(ui: &mut egui::Ui, row_id: &str, meter: &StepMeter) {
let th = theme(ui);
paint_step_meter(ui, row_id, meter, &th);
}
fn paint_step_meter(ui: &mut egui::Ui, row_id: &str, meter: &StepMeter, th: &Theme) {
if meter.steps.is_empty() {
return;
}
let (rect, _) =
ui.allocate_exact_size(egui::vec2(STEP_METER_W, STEP_METER_H), egui::Sense::hover());
let rounding = 1.5;
// The track under the segments — visible through the gaps, so the bar reads as one
// object with divisions rather than as N floating chips.
ui.painter().rect_filled(rect, rounding, th.edge.linear_multiply(0.25));
let segs = step_segments(rect, meter.total(), STEP_METER_GAP);
let base = ui.id();
for (i, (seg, cell)) in segs.iter().zip(meter.steps.iter()).enumerate() {
// A step with a MEASURED count fills its own segment to that fraction over a
// dimmed bed, so "clone: 12 of 19 members" is visible inside step 1 without a
// second widget. A step with no count paints flat: the phase colour is the whole
// answer, and a partial fill there would be an invented number.
match cell.meter() {
ProgressMeter::Determinate(f) => {
ui.painter().rect_filled(*seg, rounding, cell.phase.color(th).linear_multiply(0.35));
let w = meter_fill_width(ProgressMeter::Determinate(f), seg.width());
if w > 0.5 {
let filled = egui::Rect::from_min_size(seg.min, egui::vec2(w, seg.height()));
ui.painter().rect_filled(filled, rounding, cell.phase.color(th));
}
}
_ => {
ui.painter().rect_filled(*seg, rounding, cell.phase.color(th));
}
}
let sem = facett_core::a11y::Semantics::new(
egui::WidgetType::ProgressIndicator,
cell.line.clone(),
)
.value(i as f64 + 1.0);
let resp = facett_core::a11y::node(
ui,
base,
// FC-5: the stable domain key is (row, step key) — never the loop index.
(row_id, cell.key.as_str()),
egui::Sense::hover(),
*seg,
sem,
);
// The producer's own sentence for this step, verbatim.
resp.on_hover_text(cell.line.clone());
}
// One node for the meter as a whole, carrying the caption a human reads.
let caption = meter.caption_text();
let whole = facett_core::a11y::node(
ui,
base,
(row_id, "step-meter"),
egui::Sense::hover(),
rect,
facett_core::a11y::Semantics::new(egui::WidgetType::ProgressIndicator, caption.clone())
.value(meter.human_index() as f64),
);
let hover = meter.hover_text();
whole.on_hover_ui(|ui| {
ui.label(caption);
if !hover.is_empty() {
ui.separator();
ui.label(hover);
}
});
}
/// Something the [`JobList`] can pull a fresh snapshot from each frame. A host
/// that prefers to push uses [`JobList::set_jobs`] instead and never implements
/// this. When a source is set, the [`Facet::ui`] bridge refreshes the rows from it
/// (through the FC-2 [`Msg::SetJobs`] mutation path) before the pure view paints.
pub trait JobSource {
/// The current jobs, in any order (the list sorts them itself).
fn jobs(&self) -> Vec<JobEntry>;
}
/// Which column the list is sorted by.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortKey {
/// By start time (the default; newest first when descending).
Started,
Kind,
Target,
Status,
Elapsed,
}
/// One row of the display tree, enriched with everything an ASCII branch-guide
/// renderer needs. Internal: the public surface is [`JobList::tree`]
/// (`(entry, depth)`) plus the `guide`/`is_last` keys in `state_json`.
struct GuideRow {
entry: JobEntry,
/// Nesting depth (0 = top-level root, 1 = child, 2 = grandchild, …).
depth: usize,
/// `true` when this is the LAST of its siblings (`└─`), else `├─`.
is_last: bool,
/// For each ANCESTOR level (index 0 = root … index `depth-1` = parent),
/// whether that ancestor still has a following sibling — i.e. whether a
/// continuing vertical `│` is drawn at that level's column for this row.
/// Length is always `depth`.
ancestors_continue: Vec<bool>,
/// Collapse progress `∈[0,1]` inherited from a folding ancestor: `0` = full
/// height, `1` = fully collapsed (row about to vanish). Drives the fold-height
/// tween in the render path; always `0` in the logical [`JobList::tree_rows`].
collapse: f32,
}
/// The literal ASCII branch-guide prefix for a tree row: blank for a root, else
/// one 3-char cell per drawn level — `│ `/` ` for each ANCESTOR column
/// (continuing vs. cleared) and `├─ `/`└─ ` for the node's own connector. The
/// root level has no column, so its mask entry is skipped.
fn guide_prefix(row: &GuideRow) -> String {
if row.depth == 0 {
return String::new();
}
let mut s = String::with_capacity(row.depth * 3);
// Skip index 0 (the root level draws no column); each remaining ancestor
// contributes a continuing `│` or a cleared blank.
for &cont in &row.ancestors_continue[1..] {
s.push_str(if cont { "│ " } else { " " });
}
s.push_str(if row.is_last { "└─ " } else { "├─ " });
s
}
/// **The complete observable state (FC-1 / FC-3)** of a [`JobList`], in one
/// serializable, round-trippable struct: the job rows plus the view controls. The
/// **only** thing that mutates it is [`JobList::update`]. Expand/collapse and
/// selection key on **stable job ids** (FC-5) — never tree-position indices, since
/// the tree streams in and reorders under sort.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct JobState {
/// The job rows the tree is built from (parents + children, any order).
pub jobs: Vec<JobEntry>,
/// The active sort column.
pub sort: SortKey,
/// `true` = descending (the default: newest / largest first).
pub descending: bool,
/// Active kind filter (`None` = all kinds).
pub kind_filter: Option<String>,
/// Selected row id (for the expanded meta line) — a **stable job id** (FC-5).
pub selected: Option<String>,
/// Parent ids the user has FOLDED (children hidden) — **stable job ids** (FC-5).
/// A parent is unfolded by default, so the tree shows live progress the moment
/// children stream in; folding a parent adds its id here.
pub folded: Vec<String>,
}
impl Default for JobState {
fn default() -> Self {
Self {
jobs: Vec::new(),
sort: SortKey::Started,
descending: true,
kind_filter: None,
selected: None,
folded: Vec::new(),
}
}
}
/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the tree through, the same effect a canvas gesture
/// produces. Applied by [`JobList::update`]. Serde-tagged (`snake_case`) so the
/// robot `update_json` seam parses one message straight off the wire.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
/// Expand/collapse a parent node by its **stable job id** (FC-5) — the parent
/// row's ▸/▾ toggle. Unknown ids are inserted (a not-yet-streamed parent can be
/// pre-folded); toggling again removes it.
ToggleFold(String),
/// Select a row by its **stable job id** (FC-5). A no-op-safe re-select is fine.
Select(String),
/// Clear the row selection.
ClearSelection,
/// Click a sort-column header: the SAME key flips direction; a NEW key sorts
/// descending.
SetSort(SortKey),
/// Set the active kind filter (`None` = all kinds).
SetKindFilter(Option<String>),
/// Replace the job rows — the host push ([`JobList::set_jobs`]) and the
/// per-frame [`JobSource`] pull both route through this one mutation path.
SetJobs(Vec<JobEntry>),
}
/// Side work as data (FC-8). The job view does no I/O — every [`Msg`] mutates only
/// the in-memory [`JobState`] — so this is uninhabited on purpose: the type-checked
/// statement that [`JobList::update`] never asks the host to do anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
/// A task-manager-style hierarchical job tree: a sortable, kind-filterable tree of
/// [`JobEntry`] rows with foldable parents and a counts header. A [`Facet`].
pub struct JobList {
title: String,
/// All observable state (FC-3).
state: JobState,
/// Optional pull source; when set, the [`Facet::ui`] bridge refreshes `jobs`
/// from it (via [`Msg::SetJobs`]) each frame. **Ephemeral input wiring, not
/// observable state** — deliberately kept OUT of [`JobState`] (a `dyn` source is
/// not serializable), like the motion caches below.
source: Option<Box<dyn JobSource>>,
/// Smooth vertical scroll for the row list (SCRL-3): eased, injected-clock
/// (FC-7). **Ephemeral render cache, not observable state** — interior-mutable so
/// the FC-9 pure [`view`](Self::view) can advance it through a `&self` borrow.
scroll: Cell<SmoothScroll>,
/// Per-row hover + selection fades (injected-clock, FC-7). Same story as the
/// scroll: ephemeral animation caches, interior-mutable, outside the Model.
hover_fades: RefCell<FadeTrack>,
sel_fades: RefCell<FadeTrack>,
/// Per-parent fold tween (`1` = folded/collapsed): drives the subtree
/// fold-height animation. Injected-clock, interior-mutable, outside the Model.
fold_fades: RefCell<FadeTrack>,
}
impl JobList {
/// An empty list with a title.
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
state: JobState::default(),
source: None,
scroll: Cell::new(SmoothScroll::default()),
hover_fades: RefCell::new(FadeTrack::default()),
sel_fades: RefCell::new(FadeTrack::default()),
fold_fades: RefCell::new(FadeTrack::default()),
}
}
/// **FC-3** — read the complete observable state at any frame boundary.
pub fn state(&self) -> &JobState {
&self.state
}
/// Replace the rows (the push model — call each refresh). A thin wrapper over the
/// FC-2 [`update`](Self::update) mutation path.
pub fn set_jobs(&mut self, jobs: Vec<JobEntry>) {
let _ = self.update(Msg::SetJobs(jobs));
}
/// Install a pull [`JobSource`] refreshed every frame (alternative to push).
pub fn set_source(&mut self, source: Box<dyn JobSource>) {
self.source = Some(source);
}
/// The current rows (post-set, pre-filter).
pub fn jobs(&self) -> &[JobEntry] {
&self.state.jobs
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
/// (always empty) [`Effect`]s the host should run. Every canvas gesture routes
/// its [`Msg`] here too, so live == headless.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::ToggleFold(id) => self.toggle_fold(&id),
Msg::Select(id) => self.state.selected = Some(id),
Msg::ClearSelection => self.state.selected = None,
Msg::SetSort(key) => self.set_sort(key),
Msg::SetKindFilter(k) => self.state.kind_filter = k,
Msg::SetJobs(jobs) => self.state.jobs = jobs,
}
Vec::new()
}
/// Count of rows in a given status.
pub fn count(&self, status: JobStatus) -> usize {
self.state.jobs.iter().filter(|j| j.status == status).count()
}
/// The distinct kinds present, sorted — drives the filter combo.
pub fn kinds(&self) -> Vec<String> {
let mut k: Vec<String> = self.state.jobs.iter().map(|j| j.kind.clone()).collect();
k.sort();
k.dedup();
k
}
/// The rows after the active kind filter + sort — what the table draws (and
/// what `state_json` reports), so headless tests see the visible order.
pub fn visible(&self) -> Vec<JobEntry> {
let mut rows: Vec<JobEntry> = self
.state
.jobs
.iter()
.filter(|j| self.state.kind_filter.as_deref().is_none_or(|k| j.kind == k))
.cloned()
.collect();
self.sort_rows(&mut rows);
rows
}
/// The direct children of `parent_id`, in the active sort order. A child is
/// any entry whose `parent_id` equals `parent_id` (host-agnostic hierarchy).
pub fn children_of(&self, parent_id: &str) -> Vec<JobEntry> {
let mut kids: Vec<JobEntry> = self
.state
.jobs
.iter()
.filter(|j| j.parent_id.as_deref() == Some(parent_id))
.cloned()
.collect();
self.sort_rows(&mut kids);
kids
}
/// A one-line roll-up of `parent_id`'s children for the FOLDED parent row,
/// e.g. `"3/5 ✓ · 1 ▶ · 1 ✗"` (done/total, running, failed). Empty string
/// when the parent has no children. This is what a folded parent shows in
/// place of its hidden child rows.
pub fn child_summary(&self, parent_id: &str) -> String {
let kids = self.children_of(parent_id);
if kids.is_empty() {
return String::new();
}
let total = kids.len();
let done = kids.iter().filter(|k| k.status == JobStatus::Done).count();
let running = kids.iter().filter(|k| k.status == JobStatus::Running).count();
let failed = kids.iter().filter(|k| k.status == JobStatus::Failed).count();
let mut parts = vec![format!("{done}/{total} ✓")];
if running > 0 {
parts.push(format!("{running} ▶"));
}
if failed > 0 {
parts.push(format!("{failed} ✗"));
}
parts.join(" · ")
}
/// `true` when `parent_id` is currently folded (its children hidden).
pub fn is_folded(&self, parent_id: &str) -> bool {
self.state.folded.iter().any(|f| f == parent_id)
}
/// Fold/unfold a parent row (the FC-2 [`Msg::ToggleFold`] handler).
fn toggle_fold(&mut self, parent_id: &str) {
if let Some(i) = self.state.folded.iter().position(|f| f == parent_id) {
self.state.folded.remove(i);
} else {
self.state.folded.push(parent_id.to_string());
}
}
/// Select a row by id (the label-addressable counterpart of clicking it). A thin
/// wrapper over the FC-2 [`update`](Self::update) mutation path; `None` clears it.
pub fn select(&mut self, id: Option<String>) {
let _ = match id {
Some(i) => self.update(Msg::Select(i)),
None => self.update(Msg::ClearSelection),
};
}
/// The currently selected row id, if any.
pub fn selected(&self) -> Option<&str> {
self.state.selected.as_deref()
}
/// The display tree: top-level rows in sort order, each immediately followed
/// by its (recursively nested) children in sort order UNLESS the parent is
/// folded. An "orphan" child whose parent is not in the current (filtered)
/// set is promoted to a top-level row so a kind filter can never hide it
/// entirely. Each returned entry is tagged with its nesting `depth`
/// (0 = top-level, 1 = child, 2 = grandchild, …) so a renderer (or
/// `state_json`) can indent without re-deriving the hierarchy. Recurses to
/// arbitrary depth, so a populate → phase → member hierarchy renders all
/// three levels.
pub fn tree(&self) -> Vec<(JobEntry, usize)> {
self.tree_rows()
.into_iter()
.map(|r| (r.entry, r.depth))
.collect()
}
/// The display tree, enriched with the per-row info an ASCII branch-guide
/// renderer needs (see [`GuideRow`]): each row's `depth`, whether it is the
/// LAST of its siblings (`└─` vs `├─`), and an ancestor "still-has-siblings"
/// mask (which ancestor levels draw a continuing `│`). The plain
/// [`tree`](JobList::tree) is this projected down to `(entry, depth)`.
fn tree_rows(&self) -> Vec<GuideRow> {
self.build_tree_rows(false)
}
/// Render-path tree: like [`tree_rows`](Self::tree_rows) but driven by the fold
/// *animation* — a folding parent keeps emitting its children (each carrying a
/// `collapse` progress) until the fold tween fully closes, so the subtree
/// collapses smoothly instead of vanishing. Used only by `view`.
fn tree_rows_animated(&self) -> Vec<GuideRow> {
self.build_tree_rows(true)
}
fn build_tree_rows(&self, animated: bool) -> Vec<GuideRow> {
let visible = self.visible(); // filtered + sorted
let present: std::collections::HashSet<&str> =
visible.iter().map(|j| j.id.as_str()).collect();
// Roots = entries with no parent in the visible set (true top-level rows
// plus promoted orphans), already in sort order.
let roots: Vec<JobEntry> = visible
.iter()
.filter(|j| match &j.parent_id {
Some(p) => !present.contains(p.as_str()),
None => true,
})
.cloned()
.collect();
let mut out: Vec<GuideRow> = Vec::new();
let n = roots.len();
let mut ancestors: Vec<bool> = Vec::new();
for (i, r) in roots.iter().enumerate() {
self.push_subtree(r, 0, i + 1 == n, 0.0, animated, &mut ancestors, &mut out);
}
out
}
/// Depth-first emit of `node` then its children, recording branch-guide info.
/// `is_last` = `node` is the last of its siblings. `ancestors` carries, for
/// each ancestor level above `node`, whether that ancestor still has a
/// following sibling (so its vertical `│` continues down through this row).
#[allow(clippy::too_many_arguments)]
fn push_subtree(
&self,
node: &JobEntry,
depth: usize,
is_last: bool,
parent_collapse: f32,
animated: bool,
ancestors: &mut Vec<bool>,
out: &mut Vec<GuideRow>,
) {
out.push(GuideRow {
entry: node.clone(),
depth,
is_last,
ancestors_continue: ancestors.clone(),
collapse: parent_collapse,
});
// This node's own fold progress: the animated render uses the eased fade,
// the logical tree uses the binary fold set. Skip the children once fully
// folded; while a fold is mid-tween, keep emitting them so they collapse.
let fold = if animated {
self.fold_fades.borrow().factor(FadeTrack::key(node.id.as_str()))
} else if self.is_folded(node.id.as_str()) {
1.0
} else {
0.0
};
if fold >= 0.999 {
return;
}
// Children inherit the strongest collapsing ancestor's progress.
let child_collapse = parent_collapse.max(fold);
let kids: Vec<JobEntry> = self
.children_of(&node.id)
.into_iter()
// Honour the kind filter for children too.
.filter(|k| self.state.kind_filter.as_deref().is_none_or(|kf| k.kind == kf))
.collect();
let k = kids.len();
// Descending past this node, its own vertical bar continues iff it has a
// following sibling.
ancestors.push(!is_last);
for (i, kid) in kids.iter().enumerate() {
self.push_subtree(kid, depth + 1, i + 1 == k, child_collapse, animated, ancestors, out);
}
ancestors.pop();
}
/// Sort `rows` in place by the active key/direction (shared by `visible` and
/// `children_of`).
fn sort_rows(&self, rows: &mut [JobEntry]) {
let key = self.state.sort;
rows.sort_by(|a, b| {
let o = match key {
SortKey::Started => a.started_micros.cmp(&b.started_micros),
SortKey::Kind => a.kind.cmp(&b.kind),
SortKey::Target => a.target.cmp(&b.target),
SortKey::Status => a.status.as_str().cmp(b.status.as_str()),
SortKey::Elapsed => a.elapsed_ms.unwrap_or(0).cmp(&b.elapsed_ms.unwrap_or(0)),
};
if self.state.descending { o.reverse() } else { o }
});
}
/// Toggle sort: clicking the active column flips direction; a new column
/// sorts descending. (The FC-2 [`Msg::SetSort`] handler.)
fn set_sort(&mut self, key: SortKey) {
if self.state.sort == key {
self.state.descending = !self.state.descending;
} else {
self.state.sort = key;
self.state.descending = true;
}
}
/// One sortable column header button (shows a ▲/▼ on the active column). **Pure**
/// (`&self`): returns `true` when clicked, so the caller emits [`Msg::SetSort`].
fn header(&self, ui: &mut egui::Ui, label: &str, key: SortKey, th: &Theme) -> bool {
let mark = if self.state.sort == key {
if self.state.descending { " ▼" } else { " ▲" }
} else {
""
};
let text = egui::RichText::new(format!("{label}{mark}"))
.color(if self.state.sort == key { th.accent } else { th.text })
.strong();
ui.add(egui::Label::new(text).sense(egui::Sense::click())).clicked()
}
}
/// Human elapsed: `—` (none), `…` (running), `340ms`, `1.2s`, `2m 03s`.
fn fmt_elapsed(status: JobStatus, elapsed_ms: Option<i64>) -> String {
match elapsed_ms {
None => {
if status == JobStatus::Running {
"…".to_string()
} else {
"—".to_string()
}
}
Some(ms) if ms < 1000 => format!("{ms}ms"),
Some(ms) if ms < 60_000 => format!("{:.1}s", ms as f64 / 1000.0),
Some(ms) => {
let s = ms / 1000;
format!("{}m {:02}s", s / 60, s % 60)
}
}
}
/// Compact per-row **progress meter** width/height (px) — a slim rounded bar sized
/// to sit in the row without stealing height.
const METER_W: f32 = 54.0;
const METER_H: f32 = 6.0;
/// Paint a compact per-row progress meter matching the facett palette:
///
/// - a dim `edge` **track** underneath;
/// - **determinate** ⇒ a `fill` rect to [`meter_fill_width`], coloured by the row's
/// [`JobStatus`] (green when done, amber while running) so the meter reads with
/// the row's traffic-light;
/// - **indeterminate** ⇒ an accent highlight segment that ping-pongs across the
/// track (an honest "in flight, unmeasured" sweep), requesting repaint so it
/// animates.
///
/// [`ProgressMeter::None`] allocates nothing (early return). Injected-clock via
/// `time` (seconds) so the sweep is deterministic and testable, matching the FC-7
/// injected-clock convention the row fades already use.
fn paint_meter(ui: &mut egui::Ui, meter: ProgressMeter, status: JobStatus, th: &Theme, time: f64) {
if meter == ProgressMeter::None {
return;
}
let ctx = ui.ctx().clone();
let (rect, _resp) =
ui.allocate_exact_size(egui::vec2(METER_W, METER_H), egui::Sense::hover());
let rounding = 2.0;
let painter = ui.painter();
// Track — a dim rounded band the fill/sweep rides over.
painter.rect_filled(rect, rounding, th.edge.linear_multiply(0.35));
match meter {
ProgressMeter::Determinate(_) => {
let fill_w = meter_fill_width(meter, rect.width());
if fill_w > 0.5 {
let fill = egui::Rect::from_min_size(rect.min, egui::vec2(fill_w, rect.height()));
painter.rect_filled(fill, rounding, status.color(th));
}
}
ProgressMeter::Indeterminate => {
// A ~30%-wide highlight segment ping-ponging across the track.
let seg = rect.width() * 0.3;
let travel = (rect.width() - seg).max(0.0);
let phase = (time * 0.8).rem_euclid(1.0) as f32; // 0..1 loop
let x = if phase < 0.5 { phase * 2.0 } else { (1.0 - phase) * 2.0 }; // ping-pong 0..1
let seg_rect = egui::Rect::from_min_size(
egui::pos2(rect.min.x + x * travel, rect.min.y),
egui::vec2(seg, rect.height()),
);
painter.rect_filled(seg_rect, rounding, th.accent.linear_multiply(0.85));
ctx.request_repaint(); // keep the sweep moving
}
ProgressMeter::None => {}
}
}
impl JobList {
/// **FC-9 render** — a **pure** function of `&self`: it paints the counts header,
/// the sort bar, and the hierarchical job tree, and *returns* the [`Msg`]s the
/// interactions produced. It MUST NOT mutate the [`JobState`]; the
/// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
/// returned messages through [`update`](Self::update). The only `&self`-interior
/// mutation is the ephemeral scroll/fade animation cache (FC-7), deliberately
/// outside the observable Model. This is the same body the old `&mut self` `ui()`
/// ran, with every inline `self.*` mutation replaced by a `msgs.push(..)`.
pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
let th = theme(ui);
// Pull from a source if one is installed (push hosts have no source and skip
// this): refresh the input rows through the FC-2 [`Msg::SetJobs`] mutation
// path — reading host input and returning it as a `Msg` keeps the view pure
// (no model mutation here); the bridge applies it via `update`.
if let Some(src) = &self.source {
msgs.push(Msg::SetJobs(src.jobs()));
}
// ── header: counts + kind filter ───────────────────────────────────
ui.horizontal_wrapped(|ui| {
let (run, done, fail) = (
self.count(JobStatus::Running),
self.count(JobStatus::Done),
self.count(JobStatus::Failed),
);
ui.label(egui::RichText::new(format!("{} job(s)", self.state.jobs.len())).color(th.text).strong());
ui.separator();
ui.label(egui::RichText::new(format!("▶ {run}")).color(JobStatus::Running.color(&th)));
ui.label(egui::RichText::new(format!("✓ {done}")).color(JobStatus::Done.color(&th)));
ui.label(egui::RichText::new(format!("✗ {fail}")).color(JobStatus::Failed.color(&th)));
ui.separator();
// Kind filter combo.
let current = self.state.kind_filter.clone().unwrap_or_else(|| "all kinds".to_string());
egui::ComboBox::from_id_salt("jobview-kind-filter")
.selected_text(current)
.show_ui(ui, |ui| {
if ui.selectable_label(self.state.kind_filter.is_none(), "all kinds").clicked() {
msgs.push(Msg::SetKindFilter(None));
}
for k in self.kinds() {
let sel = self.state.kind_filter.as_deref() == Some(k.as_str());
if ui.selectable_label(sel, &k).clicked() {
msgs.push(Msg::SetKindFilter(Some(k.clone())));
}
}
});
});
ui.separator();
if self.state.jobs.is_empty() {
ui.label(egui::RichText::new("no jobs").color(th.text_dim));
// ── render-lane emit: empty-data early return drew the hint ────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-jobview::JobList::view",
"ui_render",
true,
&format!("jobs={} drew=empty_hint", self.state.jobs.len()),
);
return msgs;
}
// ── sort bar (the row columns are tree-shaped, so sorting lives in a
// compact button strip rather than a faux-aligned column header) ────
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new("sort").color(th.text_dim).small());
for (label, key) in [
("status", SortKey::Status),
("kind", SortKey::Kind),
("target", SortKey::Target),
("elapsed", SortKey::Elapsed),
("started", SortKey::Started),
] {
if self.header(ui, label, key, &th) {
msgs.push(Msg::SetSort(key));
}
}
});
ui.add_space(4.0);
// ── rows (hierarchical: ASCII branch guides + foldable parents) ───────
// Render path uses the fold-animated tree so a collapsing subtree eases
// shut (each row carries a `collapse` progress) instead of vanishing.
let rows = self.tree_rows_animated();
// Collect which rows are hovered / selected this frame; the fade trackers
// (interior-mutable on `self`) are advanced AFTER the closure so the row loop
// keeps its `&self` borrow. Reading the *current* factor mid-loop is fine.
let mut lit_hover: Vec<u64> = Vec::new();
let mut lit_sel: Vec<u64> = Vec::new();
// Dim, low-contrast guide colour so the labels pop; the theme's `edge`
// is exactly the "connector between things" token.
let guide_col = th.edge;
let row_h = ui.text_style_height(&egui::TextStyle::Body) + 6.0;
// Smooth-scroll the row list (SCRL-3): drive the eased offset from this
// frame's `stable_dt` (FC-7). `SmoothScroll` is `Copy`, so we work on a copy
// pulled from the interior-mutable cache and write it back afterward.
let dt = ui.input(|i| i.stable_dt);
let mut scroll = self.scroll.get();
smooth_scroll_area(
ui,
egui::ScrollArea::vertical().id_salt("jobview-rows"),
dt,
&mut scroll,
None,
|ui| {
ui.spacing_mut().item_spacing = egui::vec2(2.0, 0.0);
// Injected clock for the indeterminate meter sweep (FC-7, like the fades).
let time = ui.input(|i| i.time);
for (i, row) in rows.iter().enumerate() {
let j = &row.entry;
let c = j.status.color(&th);
let is_sel = self.state.selected.as_deref() == Some(j.id.as_str());
let has_children = !self.children_of(&j.id).is_empty();
let folded = self.is_folded(j.id.as_str());
let guide = guide_prefix(row);
// Fold-height tween: a collapsing subtree's rows ease their height
// to zero. Reserve EXACTLY the (collapsing) height so the stack pulls
// shut with no overlap, then render the row content into a clipped,
// fading child whose layout box stays full-height (labels keep their
// natural size) but is clipped to the collapsing band.
let eff_h = (row_h * (1.0 - row.collapse)).max(0.0);
if eff_h < 0.75 {
continue; // fully collapsed this frame — nothing to draw
}
let avail_w = ui.available_width();
// Reserve a paint slot UNDER the row content for its background,
// filled in once we know the row rect (hover / select / stripe).
let bg_idx = ui.painter().add(egui::Shape::Noop);
let (row_rect, resp) =
ui.allocate_exact_size(egui::vec2(avail_w, eff_h), egui::Sense::click());
let content_rect =
egui::Rect::from_min_size(row_rect.min, egui::vec2(avail_w, row_h));
let mut cui = ui.new_child(
egui::UiBuilder::new()
.max_rect(content_rect)
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
cui.set_clip_rect(ui.clip_rect().intersect(row_rect));
if row.collapse > 0.0 {
cui.set_opacity(1.0 - row.collapse);
}
{
let ui = &mut cui;
ui.add_space(2.0);
// [guides] — monospaced so the vertical lines align.
if !guide.is_empty() {
ui.add(
egui::Label::new(
egui::RichText::new(&guide).monospace().color(guide_col),
)
.selectable(false),
);
}
// [toggle] — a parent's ▾/▸ sits where the node glyph
// is (guides to its left); leaves have no toggle.
if has_children {
let t = if folded { "▸" } else { "▾" };
ui.add(
egui::Label::new(
egui::RichText::new(t).monospace().color(th.text_dim),
)
.selectable(false),
);
}
// [status glyph] — colour-coded traffic light.
ui.add(
egui::Label::new(
egui::RichText::new(j.status.glyph()).color(c).strong(),
)
.selectable(false),
);
ui.add_space(2.0);
// [label] — kind bright (accent when selected), target dim.
ui.add(
egui::Label::new(
egui::RichText::new(&j.kind)
.color(if is_sel { th.accent } else { th.text }),
)
.selectable(false),
);
if !j.target.is_empty() {
ui.add(
egui::Label::new(
egui::RichText::new(&j.target).color(th.text_dim),
)
.selectable(false),
);
}
// [elapsed] right-aligned, with the folded roll-up /
// detail muted just to its left.
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.add_space(2.0);
ui.add(
egui::Label::new(
egui::RichText::new(fmt_elapsed(j.status, j.elapsed_ms))
.monospace()
.color(th.text_dim),
)
.selectable(false),
);
// [progress meter] — a per-row filled/animated bar
// to the left of elapsed. Honest: determinate fills
// to its fraction, a running-but-unmeasured job
// sweeps indeterminate, and a job with nothing to
// report draws no bar (never a fake full one).
let meter = j.progress_meter();
if meter != ProgressMeter::None {
ui.add_space(8.0);
paint_meter(ui, meter, j.status, &th, time);
}
// [step meter] — "step 3 of 4" as N segments, one
// per step, each with its own phase colour and the
// producer's own tooltip line. Sits left of the
// fraction meter: the coarse "which step" reads
// first, the fine fill (when any) second.
if let Some(steps) = j.steps_meter() {
ui.add_space(8.0);
paint_step_meter(ui, &j.id, &steps, &th);
ui.add_space(6.0);
ui.add(
egui::Label::new(
egui::RichText::new(steps.caption_text())
.monospace()
.color(th.text_dim),
)
.selectable(false),
);
}
let note = if has_children && folded {
self.child_summary(&j.id)
} else {
j.detail.clone()
};
if !note.is_empty() {
ui.add_space(10.0);
ui.add(
egui::Label::new(
egui::RichText::new(note)
.color(th.text_dim)
.italics(),
)
.selectable(false),
);
}
},
);
}
// Row background: an injected-clock cross-fade of hover + selection
// over the zebra stripe (generalises the old flat hover > selection
// > stripe). Factors are last frame's eased state; we note this
// frame's hovered/selected rows to advance after the loop.
let key = FadeTrack::key(j.id.as_str());
if resp.hovered() {
lit_hover.push(key);
}
if is_sel {
lit_sel.push(key);
}
let hover_f = self.hover_fades.borrow().factor(key);
let sel_f = self.sel_fades.borrow().factor(key);
// Faded accent fill (hover stronger than selection); falls back to
// the static zebra stripe when neither is lit.
let accent_a = 0.12 * hover_f + 0.06 * sel_f;
let fill = if accent_a > 0.001 {
th.accent.linear_multiply(accent_a.min(0.20))
} else if i % 2 == 1 {
th.text_dim.linear_multiply(0.04)
} else {
egui::Color32::TRANSPARENT
};
if fill != egui::Color32::TRANSPARENT {
ui.painter().set(
bg_idx,
egui::Shape::rect_filled(resp.rect, 3.0, fill),
);
}
// Selection glow: a faded accent outline that eases in on select —
// painted over the content so it never dims the row text.
if sel_f > 0.01 {
ui.painter().rect_stroke(
resp.rect,
3.0,
egui::Stroke::new(1.0_f32, th.accent.linear_multiply(0.55 * sel_f)),
egui::StrokeKind::Inside,
);
}
if resp.clicked() {
// Clicking a PARENT toggles its fold; any row also (de)selects.
// FC-9: emit the Msgs instead of mutating `self`.
if has_children {
msgs.push(Msg::ToggleFold(j.id.clone()));
}
msgs.push(if is_sel {
Msg::ClearSelection
} else {
Msg::Select(j.id.clone())
});
}
// Expanded meta line for the selected row, indented past its guides.
if is_sel && !j.meta.is_empty() {
let meta = j
.meta
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(" ");
ui.horizontal(|ui| {
if !guide.is_empty() {
ui.add(
egui::Label::new(
egui::RichText::new(format!("{guide} "))
.monospace()
.color(guide_col),
)
.selectable(false),
);
}
ui.add(
egui::Label::new(
egui::RichText::new(meta).color(th.text_dim).italics().small(),
)
.selectable(false),
);
});
}
}
},
);
self.scroll.set(scroll);
// Advance the row fades from this frame's hovered/selected set (FC-7), and
// keep repainting while any highlight is still easing in/out. Interior-
// mutable, ephemeral — the observable Model is untouched.
{
let mut hover_fades = self.hover_fades.borrow_mut();
hover_fades.begin();
for k in lit_hover {
hover_fades.lit(k);
}
}
{
let mut sel_fades = self.sel_fades.borrow_mut();
sel_fades.begin();
for k in lit_sel {
sel_fades.lit(k);
}
}
let a = self.hover_fades.borrow_mut().advance(dt, ROW_FADE_SECS);
let b = self.sel_fades.borrow_mut().advance(dt, ROW_FADE_SECS);
// Advance the fold tween: every currently-folded parent eases toward
// collapsed (factor 1); unfolded ones ease back open. Keep repainting while
// any highlight or fold is still moving.
{
let mut fold_fades = self.fold_fades.borrow_mut();
fold_fades.begin();
for id in self.state.folded.iter() {
fold_fades.lit(FadeTrack::key(id.as_str()));
}
}
let f = self.fold_fades.borrow_mut().advance(dt, FOLD_SECS);
if a || b || f {
ui.ctx().request_repaint();
}
// ── render-lane emit: this view path RAN ─────────────────────────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-jobview::JobList::view",
"ui_render",
!self.state.jobs.is_empty(),
&format!("jobs={}", self.state.jobs.len()),
);
msgs
}
/// The rich, fold-aware `state_json` (LAW 6) — the hierarchical display tree +
/// hierarchy summary + motion state a headless driver reads. Kept as an inherent
/// method so both the [`Facet::state_json`] bridge and tests share one source of
/// truth. Publishes the exact keys facett-demo's mega_matrix reads for `"jobs"`.
fn state_json_impl(&self) -> serde_json::Value {
// The hierarchical display order (parents then their children, fold-aware)
// — exactly the row sequence the table draws, so a headless test reads the
// tree without re-deriving it. Each row carries its `depth` + `parent_id`.
let tree = self.tree_rows();
let parents: usize = tree.iter().filter(|r| !self.children_of(&r.entry.id).is_empty()).count();
let children: usize = self.state.jobs.iter().filter(|j| j.parent_id.is_some()).count();
serde_json::json!({
"title": self.title,
"count": self.state.jobs.len(),
"running": self.count(JobStatus::Running),
"done": self.count(JobStatus::Done),
"failed": self.count(JobStatus::Failed),
"queued": self.count(JobStatus::Queued),
"sort": format!("{:?}", self.state.sort),
"descending": self.state.descending,
"kind_filter": self.state.kind_filter,
"selected": self.state.selected,
"kinds": self.kinds(),
// Hierarchy summary: how many rows are parents (have children) and how
// many entries are children (have a parent_id).
"parents": parents,
"children": children,
"folded": self.state.folded.clone(),
// MOTION STATE (T1.3 SCRL-3 + fade) as data: the live SmoothScroll
// offset/target + whether it's easing, and how many hover/selection/fold
// cross-fades are currently tracked (and animating). A headless robot
// proof reads this to assert the row list's smooth-scroll + fade motion
// machinery is actually exercised, not eyeballed.
"motion": {
"scroll_offset": self.scroll.get().offset,
"scroll_target": self.scroll.get().target,
"scroll_animating": self.scroll.get().animating(),
"smooth": self.scroll.get().smooth,
"hover_fades": self.hover_fades.borrow().active(),
"sel_fades": self.sel_fades.borrow().active(),
"fold_fades": self.fold_fades.borrow().active(),
"fades_animating": self.hover_fades.borrow().is_animating()
|| self.sel_fades.borrow().is_animating()
|| self.fold_fades.borrow().is_animating(),
},
// The visible (filtered + sorted, fold-aware) display order, so a
// headless test sees exactly what the table draws — including the
// parent→child nesting via `depth` + `parent_id`, the folded
// parent's `child_summary` roll-up, and the ASCII branch-guide info
// (`is_last` + the rendered `guide` prefix) the table draws to its left.
"jobs": tree.iter().map(|r| serde_json::json!({
"id": r.entry.id,
"kind": r.entry.kind,
"target": r.entry.target,
"status": r.entry.status.as_str(),
"elapsed_ms": r.entry.elapsed_ms,
"detail": r.entry.detail,
"parent_id": r.entry.parent_id,
"depth": r.depth,
"is_last": r.is_last,
"guide": guide_prefix(r),
"child_summary": self.child_summary(&r.entry.id),
// The honest per-row progress meter a headless driver reads (the
// exact state the drawn bar renders): a determinate fraction, an
// indeterminate sweep, or null (no bar).
"progress": match r.entry.progress_meter() {
ProgressMeter::Determinate(f) => serde_json::json!({ "kind": "determinate", "fraction": f }),
ProgressMeter::Indeterminate => serde_json::json!({ "kind": "indeterminate" }),
ProgressMeter::None => serde_json::Value::Null,
},
// The segmented step meter, or null when the row has none.
// `caption` and `steps[].line` here are the IDENTICAL Strings the row
// paints and its tooltips show — `StepMeter::caption_text`/`hover_text`
// are the one writer both paths call. A robot asserting on this text is
// asserting on what a human sees, which is the whole point.
"steps": r.entry.steps_meter().map(|m| m.state_json())
.unwrap_or(serde_json::Value::Null),
})).collect::<Vec<_>>(),
})
}
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl Elm for JobList {
type Model = JobState;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &JobState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
JobList::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
JobList::view(self, ui)
}
}
// The bridge macro writes `impl Facet for JobList` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra overrides
// below. **Form 3** (`custom_state_json`) because the job view publishes a RICHER
// `state_json` than a plain `serde(state())`: the fold-aware display TREE (parents
// then children, `depth` + ASCII branch guides), the hierarchy summary
// (`parents`/`children`), and the motion state — the keys facett-demo's mega_matrix
// reads for the `"jobs"` cell (`count`/`parents`/`children`/`running`, and the
// `jobs[]` rows) come from the derived tree, not the raw Model — so the default is
// suppressed and this one supplied. The generated `ui` is the FC-9 loop verbatim
// (`for m in view(ui) { update(m) }`); a [`JobSource`] pull rides in as a
// [`Msg::SetJobs`] emitted from `view`, so no `ui` override is needed.
facett_core::impl_facet_via_elm!(JobList, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
self.state_json_impl()
}
/// The robot / headless-drive seam (FC-2): parse one serde-tagged [`Msg`] and
/// apply it through the single [`Elm::update`](facett_core::Elm::update)
/// mutation path. The `impl_facet_via_elm!` bridge does NOT generate an
/// `update_json`, so without this override the JSON drive seam is a dead no-op
/// (a headless robot's messages mutate nothing). Malformed / unknown JSON is
/// ignored (never a panic).
fn update_json(&mut self, msg_json: &str) {
if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
let _ = <Self as Elm>::update(self, msg);
}
}
/// Reads the active [`Theme`] (status colours follow the palette) and refits
/// to any host size.
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable()
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
fn kind(&self) -> &'static str {
"jobview"
}
/// The portable view config: sort key + direction, the active kind filter, the
/// selected row, and the folded-parent set. (The job rows themselves stream from
/// each list's own source; this clones how they're SORTED / FILTERED / FOLDED, so
/// a sibling shows the same arrangement.)
fn portable_state(&self) -> Option<serde_json::Value> {
// Sorted so the portable state is stable regardless of insertion order.
let mut folded = self.state.folded.clone();
folded.sort();
Some(serde_json::json!({
"sort": sort_key_str(self.state.sort),
"descending": self.state.descending,
"kind_filter": self.state.kind_filter,
"selected": self.state.selected,
"folded": folded,
}))
}
fn load_state(&mut self, state: &serde_json::Value) -> bool {
let obj = match state.as_object() {
Some(o) => o,
None => return false,
};
if let Some(s) = obj.get("sort").and_then(|v| v.as_str())
&& let Some(k) = sort_key_from_str(s)
{
self.state.sort = k;
}
if let Some(d) = obj.get("descending").and_then(|v| v.as_bool()) {
self.state.descending = d;
}
// `kind_filter` / `selected`: a string sets it, null clears it.
match obj.get("kind_filter") {
Some(serde_json::Value::String(s)) => self.state.kind_filter = Some(s.clone()),
Some(serde_json::Value::Null) => self.state.kind_filter = None,
_ => {}
}
match obj.get("selected") {
Some(serde_json::Value::String(s)) => self.state.selected = Some(s.clone()),
Some(serde_json::Value::Null) => self.state.selected = None,
_ => {}
}
if let Some(folded) = obj.get("folded").and_then(|v| v.as_array()) {
self.state.folded = folded.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
}
true
}
});
/// Stable lowercase tag for a [`SortKey`] (the portable-state encoding).
fn sort_key_str(k: SortKey) -> &'static str {
match k {
SortKey::Started => "started",
SortKey::Kind => "kind",
SortKey::Target => "target",
SortKey::Status => "status",
SortKey::Elapsed => "elapsed",
}
}
/// Parse a [`sort_key_str`] tag back into a [`SortKey`] (`None` = unknown).
fn sort_key_from_str(s: &str) -> Option<SortKey> {
Some(match s {
"started" => SortKey::Started,
"kind" => SortKey::Kind,
"target" => SortKey::Target,
"status" => SortKey::Status,
"elapsed" => SortKey::Elapsed,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: &str, kind: &str, status: JobStatus, started: i64, elapsed: Option<i64>) -> JobEntry {
JobEntry {
id: id.into(),
kind: kind.into(),
target: "repo".into(),
status,
started_micros: started,
elapsed_ms: elapsed,
detail: String::new(),
meta: Vec::new(),
parent_id: None,
}
}
fn three() -> JobList {
let mut l = JobList::new("Jobs");
l.set_jobs(vec![
entry("a", "bench_run", JobStatus::Done, 100, Some(1200)),
entry("b", "docs_book_svg", JobStatus::Running, 300, None),
entry("c", "bench_run", JobStatus::Failed, 200, Some(50)),
]);
l
}
#[test]
fn status_parse_roundtrip_and_active() {
for s in [JobStatus::Queued, JobStatus::Running, JobStatus::Done, JobStatus::Failed] {
assert_eq!(JobStatus::parse(s.as_str()), s);
}
assert!(JobStatus::Running.is_active() && JobStatus::Queued.is_active());
assert!(!JobStatus::Done.is_active() && !JobStatus::Failed.is_active());
assert_eq!(JobStatus::parse("bogus"), JobStatus::Queued);
}
#[test]
fn default_sort_is_newest_first_by_start() {
let l = three();
let v = l.visible();
// started: b=300, c=200, a=100 → descending.
assert_eq!(v.iter().map(|j| j.id.as_str()).collect::<Vec<_>>(), ["b", "c", "a"]);
}
#[test]
fn sort_toggle_and_by_elapsed() {
let mut l = three();
l.update(Msg::SetSort(SortKey::Elapsed)); // descending: 1200(a), 50(c), running→0(b)
assert_eq!(l.visible().iter().map(|j| j.id.as_str()).collect::<Vec<_>>(), ["a", "c", "b"]);
l.update(Msg::SetSort(SortKey::Elapsed)); // same key → ascending
assert_eq!(l.visible().iter().map(|j| j.id.as_str()).collect::<Vec<_>>(), ["b", "c", "a"]);
}
#[test]
fn portable_state_round_trips_sort_filter_fold_between_instances() {
use facett_core::clipboard;
use facett_core::Facet;
// A: sorted by elapsed ascending, filtered to bench_run, a row selected,
// a parent folded.
let mut a = three();
a.update(Msg::SetSort(SortKey::Elapsed));
a.update(Msg::SetSort(SortKey::Elapsed)); // toggle → ascending
a.update(Msg::SetKindFilter(Some("bench_run".into())));
a.update(Msg::Select("a".into()));
a.update(Msg::ToggleFold("a".into()));
let env = clipboard::encode_component(Facet::kind(&a), &Facet::portable_state(&a).unwrap());
let (kind, state) = clipboard::decode_component(&env).unwrap();
assert_eq!(kind, "jobview");
// B is a fresh list with the SAME rows but default arrangement.
let mut b = three();
assert_ne!(Facet::portable_state(&b), Facet::portable_state(&a), "differ before paste");
assert!(Facet::load_state(&mut b, &state));
assert_eq!(Facet::portable_state(&b), Facet::portable_state(&a), "B mirrors A's sort/filter/fold/selection");
assert_eq!(b.state().sort, SortKey::Elapsed);
assert!(!b.state().descending, "ascending direction adopted");
assert_eq!(b.state().kind_filter.as_deref(), Some("bench_run"));
assert!(b.is_folded("a"));
// And the arrangement actually drives the visible rows the same way.
assert_eq!(
b.visible().iter().map(|j| j.id.as_str()).collect::<Vec<_>>(),
a.visible().iter().map(|j| j.id.as_str()).collect::<Vec<_>>(),
);
}
#[test]
fn kind_filter_scopes_rows() {
let mut l = three();
l.update(Msg::SetKindFilter(Some("bench_run".into())));
let v = l.visible();
assert_eq!(v.len(), 2, "only the two bench_run rows");
assert!(v.iter().all(|j| j.kind == "bench_run"));
assert_eq!(l.kinds(), vec!["bench_run".to_string(), "docs_book_svg".to_string()]);
}
#[test]
fn counts_and_state_json() {
use facett_core::Facet;
let l = three();
assert_eq!(l.count(JobStatus::Running), 1);
assert_eq!(l.count(JobStatus::Done), 1);
assert_eq!(l.count(JobStatus::Failed), 1);
let j = Facet::state_json(&l);
assert_eq!(j["count"], 3);
assert_eq!(j["running"], 1);
assert_eq!(j["failed"], 1);
// state_json reports the VISIBLE (sorted) order.
let ids: Vec<&str> = j["jobs"].as_array().unwrap().iter().map(|r| r["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["b", "c", "a"]);
}
/// Build a parent populate job with two member children (one done, one
/// running) — the streaming-populate shape nornir emits.
fn parent_with_kids() -> JobList {
let mut l = JobList::new("Jobs");
let mut p = entry("pop", "workspace_populate", JobStatus::Running, 100, None);
p.target = "nordisk".into();
let mut c1 = entry("m1", "workspace_clone", JobStatus::Done, 110, Some(200));
c1.parent_id = Some("pop".into());
let mut c2 = entry("m2", "workspace_clone", JobStatus::Running, 120, None);
c2.parent_id = Some("pop".into());
l.set_jobs(vec![p, c1, c2]);
l
}
#[test]
fn hierarchy_unfolded_shows_children_under_parent_with_depth() {
use facett_core::Facet;
let l = parent_with_kids();
// tree(): parent first (depth 0), then its children (depth 1), newest-first.
let t = l.tree();
let ids: Vec<(&str, usize)> = t.iter().map(|(j, d)| (j.id.as_str(), *d)).collect();
// parent pop (start 100) is top-level; children m2(120) then m1(110) under it.
assert_eq!(ids, [("pop", 0), ("m2", 1), ("m1", 1)], "parent then its children indented");
// child_summary on the parent rolls up its kids: 1/2 ✓ · 1 ▶.
assert_eq!(l.child_summary("pop"), "1/2 ✓ · 1 ▶");
// state_json exposes the hierarchy.
let s = Facet::state_json(&l);
assert_eq!(s["parents"], 1, "one row has children");
assert_eq!(s["children"], 2, "two entries are children");
let rows = s["jobs"].as_array().unwrap();
assert_eq!(rows[0]["id"], "pop");
assert_eq!(rows[0]["depth"], 0);
assert_eq!(rows[1]["depth"], 1);
assert_eq!(rows[1]["parent_id"], "pop");
}
#[test]
fn folding_a_parent_hides_children_but_keeps_rollup() {
let mut l = parent_with_kids();
l.update(Msg::ToggleFold("pop".into()));
assert!(l.is_folded("pop"));
let t = l.tree();
let ids: Vec<&str> = t.iter().map(|(j, _)| j.id.as_str()).collect();
assert_eq!(ids, ["pop"], "folded: only the parent row, children hidden");
// The roll-up still reports both kids even though they're hidden.
assert_eq!(l.child_summary("pop"), "1/2 ✓ · 1 ▶");
// Unfold restores the children.
l.update(Msg::ToggleFold("pop".into()));
assert!(!l.is_folded("pop"));
assert_eq!(l.tree().len(), 3, "unfolded again: parent + 2 children");
}
#[test]
fn child_streams_in_progressively_via_set_jobs() {
// Streaming: children land one-at-a-time across refreshes; the tree grows.
let mut l = JobList::new("Jobs");
let p = {
let mut p = entry("pop", "workspace_populate", JobStatus::Running, 100, None);
p.target = "ws".into();
p
};
l.set_jobs(vec![p.clone()]);
assert_eq!(l.tree().len(), 1, "just the parent at first");
assert_eq!(l.child_summary("pop"), "", "no children yet");
// First member clone lands done.
let mut c1 = entry("m1", "workspace_clone", JobStatus::Done, 110, Some(50));
c1.parent_id = Some("pop".into());
l.set_jobs(vec![p.clone(), c1.clone()]);
assert_eq!(l.tree().len(), 2, "parent + first child streamed in");
assert_eq!(l.child_summary("pop"), "1/1 ✓");
// Second member clone lands done; the parent flips done too.
let mut c2 = entry("m2", "workspace_clone", JobStatus::Done, 120, Some(60));
c2.parent_id = Some("pop".into());
let mut p_done = p.clone();
p_done.status = JobStatus::Done;
l.set_jobs(vec![p_done, c1, c2]);
assert_eq!(l.tree().len(), 3, "both children present");
assert_eq!(l.child_summary("pop"), "2/2 ✓");
}
/// The 3-level populate → phase → members hierarchy nornir feeds: one
/// populate parent, one phase under it, three member jobs under the phase.
fn populate_phase_members() -> JobList {
let mut l = JobList::new("Jobs");
let mut pop = entry("pop", "WORKSPACE_POPULATE", JobStatus::Running, 100, None);
pop.target = String::new();
let mut phase = entry("scip_ingest", "scip_ingest", JobStatus::Running, 110, None);
phase.parent_id = Some("pop".into());
phase.target = String::new();
// Three members, in newest-first start order m3,m2,m1 so sort is stable.
let mut m1 = entry("m1", "scip", JobStatus::Done, 121, Some(12_300));
m1.parent_id = Some("scip_ingest".into());
m1.target = "nornir".into();
let mut m2 = entry("m2", "scip", JobStatus::Running, 122, None);
m2.parent_id = Some("scip_ingest".into());
m2.target = "holger".into();
let mut m3 = entry("m3", "scip", JobStatus::Queued, 123, None);
m3.parent_id = Some("scip_ingest".into());
m3.target = "skade".into();
l.set_jobs(vec![pop, phase, m1, m2, m3]);
l.update(Msg::SetSort(SortKey::Started)); // flip newest-first → ascending start order
l
}
#[test]
fn branch_guides_draw_box_drawing_connectors_for_three_levels() {
let l = populate_phase_members();
// The whole tree renders (recursion reaches the grandchild members).
let t = l.tree();
let ids: Vec<(&str, usize)> = t.iter().map(|(j, d)| (j.id.as_str(), *d)).collect();
assert_eq!(
ids,
[
("pop", 0),
("scip_ingest", 1),
("m1", 2),
("m2", 2),
("m3", 2)
],
"populate → phase → three members, recursed to depth 2",
);
// The rendered ASCII guide prefix per row (root has none; the phase is the
// last/only child of populate, so its members carry a CLEARED ` ` at the
// phase column, then their own ├─/├─/└─ connector).
let guides: Vec<String> = l.tree_rows().iter().map(guide_prefix).collect();
assert_eq!(
guides,
vec![
"".to_string(), // pop (root)
"└─ ".to_string(), // scip_ingest (last child of pop)
" ├─ ".to_string(), // m1 (phase cleared → blank, own ├─)
" ├─ ".to_string(), // m2
" └─ ".to_string(), // m3 (last member)
],
);
}
#[test]
fn branch_guides_continue_vertical_bar_under_a_non_last_parent() {
use facett_core::Facet;
// Two phases under one populate: the FIRST phase is non-last, so its
// members carry a CONTINUING `│ ` at the phase column. A last member of
// the first phase thus shows `│ └─ ` (the task's worked example).
let mut l = JobList::new("Jobs");
let pop = entry("pop", "POP", JobStatus::Running, 100, None);
let mut p1 = entry("p1", "phase", JobStatus::Running, 110, None);
p1.parent_id = Some("pop".into());
let mut p2 = entry("p2", "phase", JobStatus::Running, 120, None);
p2.parent_id = Some("pop".into());
let mut g1 = entry("g1", "leaf", JobStatus::Done, 111, Some(5));
g1.parent_id = Some("p1".into());
let mut g2 = entry("g2", "leaf", JobStatus::Done, 112, Some(5));
g2.parent_id = Some("p1".into());
l.set_jobs(vec![pop, p1, p2, g1, g2]);
l.update(Msg::SetSort(SortKey::Started)); // ascending → p1 before p2, g1 before g2
let rows = l.tree_rows();
let by: std::collections::HashMap<&str, String> =
rows.iter().map(|r| (r.entry.id.as_str(), guide_prefix(r))).collect();
assert_eq!(by["pop"], "");
assert_eq!(by["p1"], "├─ ", "p1 is a non-last child of pop");
assert_eq!(by["p2"], "└─ ", "p2 is the last child of pop");
// p1 continues, so its members keep a vertical bar at the phase column.
assert_eq!(by["g1"], "│ ├─ ", "non-last grandchild under a continued parent");
assert_eq!(by["g2"], "│ └─ ", "last grandchild keeps the leading │");
// state_json surfaces the same guide + is_last per row (additive keys).
let s = Facet::state_json(&l);
let row = s["jobs"].as_array().unwrap().iter().find(|r| r["id"] == "g2").unwrap();
assert_eq!(row["guide"], "│ └─ ");
assert_eq!(row["is_last"], true);
assert_eq!(row["depth"], 2);
}
#[test]
fn three_siblings_yield_branch_then_branch_then_last() {
// A parent with three children yields ├─, ├─, └─ on the children.
let mut l = parent_with_kids(); // pop + m1(done) + m2(running)
// Add a third child so we get the ├─,├─,└─ pattern.
let mut jobs = l.jobs().to_vec();
let mut c3 = entry("m3", "workspace_clone", JobStatus::Queued, 130, None);
c3.parent_id = Some("pop".into());
jobs.push(c3);
l.set_jobs(jobs);
l.update(Msg::SetSort(SortKey::Started)); // ascending start → m1, m2, m3
let rows = l.tree_rows();
let kid_guides: Vec<String> = rows
.iter()
.filter(|r| r.depth == 1)
.map(guide_prefix)
.collect();
assert_eq!(kid_guides, vec!["├─ ", "├─ ", "└─ "], "three children: ├─, ├─, └─");
}
#[test]
fn orphan_child_is_promoted_to_top_level() {
// A child whose parent isn't in the set must NOT be hidden.
let mut l = JobList::new("Jobs");
let mut c = entry("m1", "workspace_clone", JobStatus::Done, 110, Some(50));
c.parent_id = Some("missing-parent".into());
l.set_jobs(vec![c]);
let t = l.tree();
assert_eq!(t.len(), 1, "orphan child still shows");
assert_eq!(t[0].0.id, "m1");
assert_eq!(t[0].1, 0, "promoted to top-level depth");
}
#[test]
fn fmt_elapsed_buckets() {
assert_eq!(fmt_elapsed(JobStatus::Running, None), "…");
assert_eq!(fmt_elapsed(JobStatus::Done, None), "—");
assert_eq!(fmt_elapsed(JobStatus::Done, Some(340)), "340ms");
assert_eq!(fmt_elapsed(JobStatus::Done, Some(1200)), "1.2s");
assert_eq!(fmt_elapsed(JobStatus::Done, Some(123_000)), "2m 03s");
}
#[test]
fn pull_source_feeds_via_ui_bridge() {
struct Src;
impl JobSource for Src {
fn jobs(&self) -> Vec<JobEntry> {
vec![entry("x", "k", JobStatus::Done, 1, Some(1))]
}
}
let mut l = JobList::new("Jobs");
l.set_source(Box::new(Src));
// The source feeds on the next ui pull (through Msg::SetJobs); pre-render the
// push rows are still empty.
assert!(l.jobs().is_empty());
}
#[test]
fn headless_render_draws_rows_and_reports_state() {
use facett_core::harness;
let mut l = three();
let r = harness::headless_render(&mut l);
assert_eq!(r.title, "Jobs");
assert!(r.drew(), "the table tessellated to vertices");
assert_eq!(r.state["count"], 3);
assert_eq!(r.state["running"], 1);
}
// ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
// harness (no egui, no GPU) and assert the resulting `JobState`. ───────────
#[test]
fn harness_drives_fold_and_select_across_a_streamed_reorder() {
use facett_core::harness;
// Start with a parent + one child. Fold the parent and select the child by
// its STABLE id (FC-5).
let mut l = parent_with_kids(); // pop + m1(done,110) + m2(running,120)
let snap = harness::snapshot(&mut l, [Msg::ToggleFold("pop".into()), Msg::Select("m1".into())]);
assert_eq!(snap.folded, vec!["pop".to_string()], "the parent id is folded");
assert_eq!(snap.selected.as_deref(), Some("m1"), "the child is selected by stable id");
// Now STREAM a reorder: the same jobs arrive with different start times (m1
// now newest) AND a brand-new child m3 — the tree reshuffles under sort. The
// fold + selection MUST survive because they key on ids, not tree position.
let mut pop = entry("pop", "workspace_populate", JobStatus::Running, 100, None);
pop.target = "nordisk".into();
let mut m1 = entry("m1", "workspace_clone", JobStatus::Done, 900, Some(200)); // now newest
m1.parent_id = Some("pop".into());
let mut m2 = entry("m2", "workspace_clone", JobStatus::Running, 500, None);
m2.parent_id = Some("pop".into());
let mut m3 = entry("m3", "workspace_clone", JobStatus::Queued, 700, None); // new arrival
m3.parent_id = Some("pop".into());
let snap = harness::snapshot(&mut l, [Msg::SetJobs(vec![pop, m1, m2, m3])]);
assert_eq!(snap.selected.as_deref(), Some("m1"), "selection survives the reorder (id-keyed, FC-5)");
assert_eq!(snap.folded, vec!["pop".to_string()], "fold survives the reorder (id-keyed, FC-5)");
// Folded still hides the (now 3) children; unfolding shows all three.
assert_eq!(l.tree().len(), 1, "folded parent hides its children after the reorder");
l.update(Msg::ToggleFold("pop".into()));
assert_eq!(l.tree().len(), 4, "unfolded: parent + three children (m3 streamed in)");
}
#[test]
fn harness_select_and_clear_transitions() {
use facett_core::harness;
let mut l = three();
let snap = harness::snapshot(&mut l, [Msg::Select("b".into())]);
assert_eq!(snap.selected.as_deref(), Some("b"), "selection is observable headlessly");
assert_eq!(&snap, l.state(), "the snapshot is a clone of the live state");
let snap = harness::snapshot(&mut l, [Msg::Select("a".into()), Msg::ClearSelection]);
assert!(snap.selected.is_none(), "ClearSelection empties the selection");
}
#[test]
fn harness_kind_filter_and_sort_transitions() {
use facett_core::harness;
let mut l = three();
// Filter to bench_run + sort ascending by elapsed in one Msg sequence.
let snap = harness::snapshot(
&mut l,
[Msg::SetKindFilter(Some("bench_run".into())), Msg::SetSort(SortKey::Elapsed), Msg::SetSort(SortKey::Elapsed)],
);
assert_eq!(snap.kind_filter.as_deref(), Some("bench_run"));
assert_eq!(snap.sort, SortKey::Elapsed);
assert!(!snap.descending, "second SetSort on the same key flipped to ascending");
// Clearing the filter restores all kinds.
let snap = harness::snapshot(&mut l, [Msg::SetKindFilter(None)]);
assert!(snap.kind_filter.is_none());
}
// ── progress meter (per-row progress bar) ─────────────────────────────────
#[test]
fn progress_fraction_renders_a_determinate_bar_at_that_fill() {
// A job carrying a 0.4 progress fraction reads as a DETERMINATE meter at
// 0.4, and the drawn fill width is 40% of the track — a value-assert on the
// exact geometry the paint path uses, not an eyeballed pixel.
let j = JobEntry::new("dl", "download", JobStatus::Running).with_progress(0.4);
assert_eq!(j.progress_meter(), ProgressMeter::Determinate(0.4));
assert_eq!(meter_fill_width(j.progress_meter(), 100.0), 40.0, "40%-wide fill");
// Clamped: an over-range fraction never yields a >full bar.
let over = JobEntry::new("x", "k", JobStatus::Running).with_progress(1.9);
assert_eq!(over.progress_meter(), ProgressMeter::Determinate(1.0));
assert_eq!(meter_fill_width(over.progress_meter(), 100.0), 100.0);
// A tasks-done/total ratio maps to the same determinate fill (~30%).
let ratio = JobEntry::new("y", "k", JobStatus::Running).with_progress_ratio(3, 10);
assert!((meter_fill_width(ratio.progress_meter(), 100.0) - 30.0).abs() < 0.01, "3/10 → 30%");
}
#[test]
fn no_progress_job_renders_the_honest_empty_or_indeterminate_state() {
// A job with NO progress marker never fakes a full bar:
// • terminal/queued → NO meter at all (honest empty)…
let done = JobEntry::new("a", "k", JobStatus::Done);
assert_eq!(done.progress_meter(), ProgressMeter::None);
assert_eq!(meter_fill_width(done.progress_meter(), 100.0), 0.0, "no fake fill");
let queued = JobEntry::new("q", "k", JobStatus::Queued);
assert_eq!(queued.progress_meter(), ProgressMeter::None);
// • …a RUNNING but unmeasured job → an honest INDETERMINATE sweep (not full).
let running = JobEntry::new("r", "k", JobStatus::Running);
assert_eq!(running.progress_meter(), ProgressMeter::Indeterminate);
assert_eq!(meter_fill_width(running.progress_meter(), 100.0), 0.0, "indeterminate has no determinate fill");
// An explicit indeterminate marker forces the sweep even off a running row.
let indet = JobEntry::new("i", "k", JobStatus::Queued).with_indeterminate_progress();
assert_eq!(indet.progress_meter(), ProgressMeter::Indeterminate);
}
#[test]
fn state_json_surfaces_the_progress_meter_per_row() {
use facett_core::Facet;
let mut l = JobList::new("Jobs");
l.set_jobs(vec![
JobEntry::new("d", "download", JobStatus::Running).with_progress(0.4),
JobEntry::new("t", "task", JobStatus::Done), // no progress → null
]);
let s = Facet::state_json(&l);
let rows = s["jobs"].as_array().unwrap();
let d = rows.iter().find(|r| r["id"] == "d").unwrap();
assert_eq!(d["progress"]["kind"], "determinate");
assert!((d["progress"]["fraction"].as_f64().unwrap() - 0.4).abs() < 1e-4, "40% surfaced");
let t = rows.iter().find(|r| r["id"] == "t").unwrap();
assert_eq!(t["progress"], serde_json::Value::Null, "no-progress terminal job has no meter");
}
#[test]
fn progress_stamp_is_single_slotted_and_survives_meta() {
// Re-stamping progress replaces (never accumulates) the meta marker, and a
// pre-existing meta key rides alongside untouched.
let mut j = JobEntry::new("j", "k", JobStatus::Running);
j.meta.push(("node".into(), "loki".into()));
let j = j.with_progress(0.2).with_progress(0.7);
let progs: Vec<&String> =
j.meta.iter().filter(|(k, _)| k == PROGRESS_META_KEY).map(|(_, v)| v).collect();
assert_eq!(progs.len(), 1, "one progress marker, the latest");
assert_eq!(j.progress_meter(), ProgressMeter::Determinate(0.7));
assert!(j.meta.iter().any(|(k, v)| k == "node" && v == "loki"), "other meta preserved");
}
#[test]
fn drive_reports_no_effects_and_state_round_trips() {
use facett_core::harness;
let mut l = parent_with_kids();
// FC-8: the job view does no I/O, so the effect stream is always empty.
let effects = harness::drive(
&mut l,
[Msg::ToggleFold("pop".into()), Msg::Select("m1".into()), Msg::SetSort(SortKey::Kind)],
);
assert!(effects.is_empty(), "FC-8: jobview emits no Effects — `Effect` is uninhabited");
// FC-3: the observable Model round-trips through serde.
let json = serde_json::to_value(l.state()).unwrap();
let back: JobState = serde_json::from_value(json).unwrap();
assert_eq!(&back, l.state(), "serde(state) -> state round-trips");
}
}
#[cfg(test)]
mod state_json_inverse_tests {
use super::*;
/// **The published projection and its reader agree, field for field.**
///
/// `state_json` is hand-written, so `serde_json::from_value` does NOT round-trip it:
/// `phase` is a [`StepPhase::key`] string and `count` is folded into a nested `meter`
/// object. A consumer that used the derive would get every step `Pending` with no
/// counts — a bar that renders and lies.
///
/// RED when reverted: change either side (drop `line` from the projection, or read
/// `phase` with the derive) and the re-projection stops matching.
#[test]
fn state_json_round_trips_through_from_state_json() {
let meter = StepMeter {
generation: 7,
caption: "sweden · step 2 of 4 — convert pass 2 (nodes/ways/relations)".into(),
hover: "the previous cut failed - retrying\na DONE convert pass 1".into(),
steps: vec![
StepCell { key: "a".into(), label: "convert pass 1 (node coord store)".into(), phase: StepPhase::Done, line: "a DONE convert pass 1 (node coord store)".into(), count: None },
StepCell { key: "b".into(), label: "convert pass 2 (nodes/ways/relations)".into(), phase: StepPhase::Active, line: "b ACTIVE convert pass 2".into(), count: Some((3, 19)) },
StepCell { key: "c".into(), label: "relation clip + geometry assembly".into(), phase: StepPhase::Pending, line: "c PENDING relation clip".into(), count: None },
StepCell { key: "d".into(), label: "tile pyramid".into(), phase: StepPhase::Failed, line: "d FAILED tile pyramid".into(), count: None },
],
};
let published = meter.state_json();
let read_back = StepMeter::from_state_json(&published).expect("an object parses");
// Every field a viewer sees, not a state round-trip: the sentence, the run
// identity, the per-step phases and the ONE genuine count.
assert_eq!(read_back.caption, meter.caption_text(), "the producer's sentence survives verbatim");
assert_eq!(read_back.hover, meter.hover_text());
assert_eq!(read_back.generation, 7, "run identity survives — a restart must stay legible");
assert_eq!(read_back.steps.len(), 4);
let phases: Vec<StepPhase> = read_back.steps.iter().map(|s| s.phase).collect();
assert_eq!(phases, vec![StepPhase::Done, StepPhase::Active, StepPhase::Pending, StepPhase::Failed]);
assert_eq!(read_back.steps[1].count, Some((3, 19)), "the measured count comes back out of `meter`");
assert_eq!(read_back.steps[0].count, None, "an unmeasured step stays unmeasured");
assert_eq!(read_back.steps[2].label, "relation clip + geometry assembly");
assert_eq!(read_back.steps[3].line, "d FAILED tile pyramid");
// And the whole projection is stable: re-publishing the read-back meter gives
// the identical document, so nothing was dropped on the way through.
assert_eq!(read_back.state_json(), published, "re-projecting must reproduce the document");
// ── The negative leg: what the DERIVE loses, measured rather than assumed ──
// This reader only earns its keep if the derive cannot do the job. It nearly
// can: `StepPhase` is `rename_all = "lowercase"`, so the phases parse. What it
// drops is `count`, which the projection folds into the nested `meter` object.
// A thin consumer on the derive would draw dwarves' measured `(3, 19)` clone
// step as an indeterminate shimmer — plausible, and wrong about the one number
// the producer knew.
let via_derive: StepMeter =
serde_json::from_value(published.clone()).expect("the derive parses the document");
assert_eq!(
via_derive.steps.iter().map(|s| s.phase).collect::<Vec<_>>(),
phases,
"the derive DOES get the phases (rename_all = lowercase) — do not claim otherwise"
);
assert_eq!(
via_derive.steps[1].count, None,
"and it LOSES the measured count — this is the whole reason from_state_json exists"
);
assert_eq!(
read_back.steps[1].count,
Some((3, 19)),
"which this reader keeps"
);
}
/// A non-object is `None`, and a sparse object reads as far as it published — a
/// producer on an older build is never rejected wholesale.
#[test]
fn a_partial_or_wrong_shaped_document_degrades_instead_of_lying() {
assert!(StepMeter::from_state_json(&serde_json::json!("nope")).is_none());
assert!(StepMeter::from_state_json(&serde_json::json!(null)).is_none());
let sparse = StepMeter::from_state_json(&serde_json::json!({ "caption": "only a sentence" }))
.expect("an object parses");
assert_eq!(sparse.caption, "only a sentence");
assert_eq!(sparse.generation, 0);
assert!(sparse.steps.is_empty(), "no steps published ⇒ no steps invented");
}
}