facett-timeline/src/lib.rs
//! **facett-timeline** — a **swimlane Gantt**: a wall-clock time axis with stacked,
//! named lanes of bars, each bar optionally split into phase **segments**. The
//! single Spark-defining visual facett lacked — it covers the **Jobs event
//! timeline** (executor lifecycle + job/stage bars), the **per-stage task event
//! timeline** (segmented phase bars), and the **Lakeflow run Gantt**, all from one
//! engine-agnostic [`TimelineModel`](model::TimelineModel) (a host maps Spark's
//! event log / REST API onto it; this crate never learns what a "stage" is).
//!
//! Two halves, exactly like the rest of facett:
//! - [`model`] — the pure, egui-free, serializable data shape (lanes / bars /
//! segments + the `[t0,t1]` window) with its own offset/`state_json` tests.
//! - [`TimelineView`] — the [`Facet`](facett_core::Facet) that paints it (time axis, lane gutter,
//! status-coloured + phase-segmented bars, click-select, scroll-zoom) and folds
//! the laid-out bar offsets into `state_json` so a headless driver asserts the
//! pixels as DATA.
//!
//! ## FC contract
//! - **Serializable state** (`state_json`, `portable_state`/`load_state`): the
//! active window + per-bar `start_frac`/`width_frac` + the selection.
//! - **[`Msg`] enum + [`TimelineView::update`]**: a robot-/CLI-addressable control
//! surface (select a bar by its stable id, zoom the window, toggle the legend) —
//! the same gesture the canvas click drives, so headless == live.
//! - **Render-proof**: the painter, the headless [`layout`] helper, and
//! `state_json` all read bar offsets from **one** formula
//! ([`TimelineModel::bar_fraction_in`]), so what a test asserts IS what paints.
pub mod model;
pub use model::{AxisFormat, Bar, BarStatus, Lane, Segment, TASK_PHASES, TimelineModel};
use egui::{Align2, Color32, CornerRadius, FontId, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2};
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::{FacetCaps, a11y, theme};
use serde::{Deserialize, Serialize};
/// Left gutter width (lane labels).
const GUTTER_W: f32 = 120.0;
/// Top time-axis strip height.
const AXIS_H: f32 = 22.0;
/// Per-lane row height.
const LANE_H: f32 = 34.0;
/// Vertical padding inside a lane row (bar sits in the middle).
const BAR_VPAD: f32 = 6.0;
/// Minimum painted width for a non-zero-duration bar (so a hairline bar is clickable).
const MIN_BAR_W: f32 = 2.0;
/// A laid-out bar — the exact rect the painter fills for one [`Bar`], plus the keys
/// a test/robot needs (lane + bar index, the stable id, status). The headless
/// [`layout`] helper returns these; the painter draws them; `state_json` reports
/// their fractions — one source of truth.
#[derive(Clone, Debug, PartialEq)]
pub struct BarLayout {
/// Lane index (top to bottom).
pub lane: usize,
/// Bar index within its lane.
pub bar: usize,
/// The bar's stable id (selection key).
pub id: String,
/// The pixel rect the bar fills inside `content`.
pub rect: Rect,
/// The bar's status (→ colour).
pub status: BarStatus,
}
/// **Pure layout** — given the plotting `content` rect (the area right of the lane
/// gutter, below the time axis) and the active window `[t0,t1]`, return one
/// [`BarLayout`] per bar. No painter, no `Ui`: a test calls this directly and
/// asserts the bar rects (offsets + widths). The painter calls the SAME fn, so the
/// asserted rects ARE the pixels.
pub fn layout(model: &TimelineModel, t0: f64, t1: f64, content: Rect) -> Vec<BarLayout> {
let mut out = Vec::with_capacity(model.bar_count());
for (li, lane) in model.lanes.iter().enumerate() {
let row_top = content.top() + li as f32 * LANE_H;
for (bi, b) in lane.bars.iter().enumerate() {
let (sf, wf) = model.bar_fraction_in(b, t0, t1);
let x = content.left() + sf * content.width();
let w = (wf * content.width()).max(if b.duration() > 0.0 { MIN_BAR_W } else { 0.0 });
let rect = Rect::from_min_size(
Pos2::new(x, row_top + BAR_VPAD),
Vec2::new(w, LANE_H - 2.0 * BAR_VPAD),
);
out.push(BarLayout { lane: li, bar: bi, id: b.id.clone(), rect, status: b.status });
}
}
out
}
/// A robot-/CLI-addressable control message — the named boundary a headless driver
/// (or a host toolbar) drives the view through, the same effect the canvas gestures
/// produce. Applied by [`TimelineView::update`].
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
/// Select the bar with this stable id (no-op if absent).
Select(String),
/// Clear any selection.
ClearSelection,
/// Zoom the visible window to `[t0,t1]` (clamped to a non-empty span).
SetWindow(f64, f64),
/// Reset the visible window to the model's full `[t0,t1]`.
ResetWindow,
/// Toggle the phase-segment legend strip.
ToggleLegend,
}
/// Side work as data (FC-8). The timeline does no I/O — every [`Msg`] mutates only
/// the in-memory [`TimelineState`] — so this is uninhabited on purpose: the
/// type-checked statement that [`TimelineView::update`] never asks the host to do
/// anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
/// **The complete observable state (FC-1 / FC-3)** of a [`TimelineView`], in one
/// serializable, round-trippable struct: the pure [`TimelineModel`], the selection
/// (keyed on the bar's stable **id**, FC-5), an optional zoom **window**, and the
/// legend toggle. [`TimelineView::state`] hands back a `&TimelineState`; a headless
/// driver ([`facett_core::harness`]) snapshots it after feeding a `Vec<Msg>`.
///
/// The `Serialize` impl is hand-written so `state_json` carries three **derived**
/// convenience keys (`lane_count`, `bar_count`, `has_segments`) alongside the model
/// — the same observability signals the render matrix reads. `Deserialize` is
/// derived and ignores those three (they are recomputed), so the base state still
/// round-trips.
#[derive(Clone, Debug, PartialEq, Deserialize)]
pub struct TimelineState {
/// The data.
pub model: TimelineModel,
/// Selected bar id (stable key, not an index).
pub selected: Option<String>,
/// Zoom window `(t0,t1)`; `None` = the model's full `[t0,t1]`.
pub window: Option<(f64, f64)>,
/// Whether the phase legend is shown (only meaningful when bars have segments).
pub show_legend: bool,
}
impl Serialize for TimelineState {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let has_segments = self.model.lanes.iter().flat_map(|l| &l.bars).any(|b| !b.segments.is_empty());
let mut st = serializer.serialize_struct("TimelineState", 7)?;
st.serialize_field("model", &self.model)?;
st.serialize_field("selected", &self.selected)?;
st.serialize_field("window", &self.window)?;
st.serialize_field("show_legend", &self.show_legend)?;
// Derived observability (FC-3), ignored on the way back in:
st.serialize_field("lane_count", &self.model.lanes.len())?;
st.serialize_field("bar_count", &self.model.bar_count())?;
st.serialize_field("has_segments", &has_segments)?;
st.end()
}
}
/// The **swimlane Gantt** [`Facet`](facett_core::Facet). A tab title plus the complete observable
/// [`TimelineState`]; the FC-9 pure [`view`](Self::view) reads it and the FC-2
/// [`update`](Self::update) is the sole path that mutates it.
pub struct TimelineView {
/// Tab/panel title.
pub title: String,
/// All observable state (FC-3).
pub state: TimelineState,
}
impl TimelineView {
/// A view over `model`, titled `title`.
pub fn new(title: impl Into<String>, model: TimelineModel) -> Self {
Self {
title: title.into(),
state: TimelineState { model, selected: None, window: None, show_legend: true },
}
}
/// The live [`TimelineModel`] (the data half).
pub fn model(&self) -> &TimelineModel {
&self.state.model
}
/// The active window `(t0,t1)` — the zoom override if set, else the model's span.
pub fn window(&self) -> (f64, f64) {
self.state.window.unwrap_or((self.state.model.t0, self.state.model.t1))
}
/// The currently-selected bar (resolved from its stable id), if any.
pub fn selected_bar(&self) -> Option<&Bar> {
let id = self.state.selected.as_deref()?;
self.state.model.lanes.iter().flat_map(|l| &l.bars).find(|b| b.id == id)
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
/// (always empty) [`Effect`]s the host should run. The canvas gestures route
/// their [`Msg`]s here too, so live == headless.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::Select(id) => {
let exists = self.state.model.lanes.iter().flat_map(|l| &l.bars).any(|b| b.id == id);
if exists {
self.state.selected = Some(id);
}
}
Msg::ClearSelection => self.state.selected = None,
Msg::SetWindow(a, b) => {
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
let hi = hi.max(lo + f64::EPSILON);
self.state.window = Some((lo, hi));
}
Msg::ResetWindow => self.state.window = None,
Msg::ToggleLegend => self.state.show_legend = !self.state.show_legend,
}
Vec::new()
}
/// `true` if any bar on any lane carries phase segments (drives the legend).
fn has_segments(&self) -> bool {
self.state.model.lanes.iter().flat_map(|l| &l.bars).any(|b| !b.segments.is_empty())
}
/// Status → theme colour.
fn status_color(status: BarStatus, th: &facett_core::Theme) -> Color32 {
match status {
BarStatus::Idle => th.text_dim,
BarStatus::Running => th.accent,
BarStatus::Done => th.point,
BarStatus::Failed => facett_core::look::RED,
}
}
/// Paint the time axis (gutter corner + a few tick labels across the window).
fn paint_axis(&self, painter: &egui::Painter, content: Rect, axis: Rect, th: &facett_core::Theme) {
let (t0, t1) = self.window();
painter.rect_filled(axis, CornerRadius::ZERO, th.panel_bg);
const TICKS: usize = 5;
for i in 0..=TICKS {
let f = i as f32 / TICKS as f32;
let x = content.left() + f * content.width();
let t = t0 + (t1 - t0) * f as f64;
painter.line_segment(
[Pos2::new(x, axis.bottom() - 4.0), Pos2::new(x, content.bottom())],
Stroke::new(1.0_f32, th.panel_stroke.linear_multiply(0.35)),
);
painter.text(
Pos2::new(x + 2.0, axis.top() + 2.0),
Align2::LEFT_TOP,
self.state.model.axis.label(t),
FontId::monospace(10.0),
th.text_dim,
);
}
self.paint_now_marker(painter, content, axis, th, t0, t1);
}
/// Draw the host-supplied **now marker** as a vertical rule across the lanes.
/// Nothing is drawn when the model carries no `now`, or when it falls outside
/// the visible window (so zooming away from it doesn't pin a rule to an edge).
fn paint_now_marker(
&self,
painter: &egui::Painter,
content: Rect,
axis: Rect,
th: &facett_core::Theme,
t0: f64,
t1: f64,
) {
let Some(now) = self.state.model.now else { return };
if now < t0 || now > t1 {
return;
}
let span = (t1 - t0).max(f64::EPSILON);
let x = content.left() + ((now - t0) / span) as f32 * content.width();
painter.line_segment(
[Pos2::new(x, axis.bottom() - 4.0), Pos2::new(x, content.bottom())],
Stroke::new(1.5_f32, th.accent),
);
painter.text(
Pos2::new(x + 2.0, content.bottom() - 12.0),
Align2::LEFT_TOP,
"now",
FontId::monospace(10.0),
th.accent,
);
}
}
/// Stable per-phase colour: the canonical [`TASK_PHASES`] get a fixed ramp slot;
/// any other phase string hashes to a deterministic hue, so a host's free phase
/// names still colour consistently across frames.
fn phase_color(kind: &str) -> Color32 {
// Fixed ramp for the canonical Spark task phases (scheduler-delay → getting-result).
const RAMP: &[Color32] = &[
Color32::from_rgb(120, 120, 140), // scheduler_delay — grey
Color32::from_rgb(150, 110, 200), // deserialize — violet
Color32::from_rgb(90, 160, 230), // shuffle_read — blue
Color32::from_rgb(90, 200, 140), // compute — green (the dominant phase)
Color32::from_rgb(230, 170, 70), // shuffle_write — amber
Color32::from_rgb(200, 130, 90), // serialize — terracotta
Color32::from_rgb(160, 90, 110), // getting_result — maroon
];
if let Some(i) = TASK_PHASES.iter().position(|p| *p == kind) {
RAMP[i % RAMP.len()]
} else {
// Deterministic hash → a stable, readable colour.
let mut h: u32 = 2166136261;
for b in kind.bytes() {
h = (h ^ b as u32).wrapping_mul(16777619);
}
Color32::from_rgb(80 + (h & 0x7f) as u8, 80 + ((h >> 8) & 0x7f) as u8, 80 + ((h >> 16) & 0x7f) as u8)
}
}
impl TimelineView {
/// A self-contained **demo timeline** with no host: an executor-lifecycle lane,
/// two job bars, and a stage's task lane whose bars are split into the canonical
/// Spark task phases — exercises both timeline kinds (plain + segmented) in one
/// model. Used by the gallery + the render tests.
pub fn local() -> Self {
let exec = Lane::new("executor-1").with_bars(vec![
Bar::new("exec-1 up", 0.0, 1000.0, BarStatus::Done).with_id("exec1"),
]);
let exec2 = Lane::new("executor-2").with_bars(vec![
Bar::new("exec-2 up", 120.0, 1000.0, BarStatus::Running).with_id("exec2"),
]);
let jobs = Lane::new("jobs").with_bars(vec![
Bar::new("job 0", 20.0, 420.0, BarStatus::Done).with_id("job0"),
Bar::new("job 1", 430.0, 980.0, BarStatus::Running).with_id("job1"),
]);
// A stage's tasks, each a phase-segmented bar (the per-stage task timeline).
let seg = |compute: f32, shuffle: f32| {
vec![
Segment::new("scheduler_delay", 0.4),
Segment::new("deserialize", 0.3),
Segment::new("shuffle_read", shuffle),
Segment::new("compute", compute),
Segment::new("shuffle_write", 0.6),
Segment::new("serialize", 0.2),
]
};
let tasks = Lane::new("stage 7 · tasks").with_bars(vec![
Bar::new("task 0", 440.0, 700.0, BarStatus::Done).with_id("t0").with_segments(seg(4.0, 1.0)),
Bar::new("task 1", 445.0, 620.0, BarStatus::Done).with_id("t1").with_segments(seg(2.0, 0.5)),
// a straggler — much longer compute (the skew signal)
Bar::new("task 2", 450.0, 960.0, BarStatus::Failed).with_id("t2").with_segments(seg(9.0, 3.0)),
]);
let mut model = TimelineModel::new(0.0, 1000.0).with_lane(exec).with_lane(exec2).with_lane(jobs).with_lane(tasks);
model.auto_window();
Self::new("timeline", model)
}
}
impl TimelineView {
/// The copyable text: the selected bar's `label status start end` line when a
/// bar is selected, else every bar as a TSV rectangle
/// (`lane \t bar \t status \t start \t end`).
pub fn copy_text(&self) -> Option<String> {
if self.state.model.lanes.iter().all(|l| l.bars.is_empty()) {
return None;
}
if let Some(b) = self.selected_bar() {
return Some(format!("{}\t{}\t{}\t{}", b.label, b.status.label(), b.start, b.end));
}
let mut out = String::from("lane\tbar\tstatus\tstart\tend");
for l in &self.state.model.lanes {
for b in &l.bars {
out.push('\n');
out.push_str(&format!("{}\t{}\t{}\t{}\t{}", l.label, b.label, b.status.label(), b.start, b.end));
}
}
Some(out)
}
}
// ── typed copy (§16) — read-only: selected bar or the whole swimlane as rows ───
impl CopySource for TimelineView {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Rows, ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
self.copy_text().map(ClipPayload::Rows)
}
}
impl TimelineView {
/// **FC-9 pure view** — paints the swimlane Gantt (header, time axis, lane
/// gutter, status-coloured + phase-segmented bars, selected-bar detail) and
/// **returns** the [`Msg`]s this frame's interactions produced (Fit button,
/// phases checkbox, canvas bar-click → select/clear, scroll → zoom). It does
/// **not** mutate `self`; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
/// returned messages through [`update`](Self::update), so a headless driver
/// feeding the same [`Msg`]s reproduces the live behaviour exactly.
pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
let mut msgs: Vec<Msg> = Vec::new();
let th = theme(ui);
// ── header: window + zoom controls + the phase legend ────────────────────
let (t0, t1) = self.window();
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new(format!("⟦ {t0:.0} – {t1:.0} ⟧")).monospace().color(th.text_dim));
ui.label(egui::RichText::new(format!("{} lanes · {} bars", self.state.model.lanes.len(), self.state.model.bar_count())).small().color(th.text_dim));
if self.state.window.is_some() && ui.button("⤢ Fit").on_hover_text("Reset the time window").clicked() {
msgs.push(Msg::ResetWindow);
}
if self.has_segments() {
let mut leg = self.state.show_legend;
if ui.checkbox(&mut leg, "phases").changed() {
msgs.push(Msg::ToggleLegend);
}
}
});
if self.has_segments() && self.state.show_legend {
ui.horizontal_wrapped(|ui| {
for p in TASK_PHASES {
ui.colored_label(phase_color(p), format!("▆ {p}"));
}
});
}
ui.separator();
// ── canvas: lane gutter + time axis + bars ───────────────────────────────
let nlanes = self.state.model.lanes.len().max(1);
let needed_h = AXIS_H + nlanes as f32 * LANE_H + 8.0;
let avail = ui.available_size();
let canvas = Vec2::new(avail.x.max(GUTTER_W + 240.0), avail.y.max(needed_h.max(180.0)));
let (rect, _resp) = ui.allocate_exact_size(canvas, Sense::hover());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::ZERO, th.bg);
let content = Rect::from_min_max(
Pos2::new(rect.left() + GUTTER_W, rect.top() + AXIS_H),
Pos2::new(rect.right() - 6.0, rect.top() + AXIS_H + nlanes as f32 * LANE_H),
);
let axis = Rect::from_min_max(Pos2::new(rect.left() + GUTTER_W, rect.top()), Pos2::new(rect.right() - 6.0, rect.top() + AXIS_H));
self.paint_axis(&painter, content, axis, &th);
// Lane gutter labels + row separators.
for (li, lane) in self.state.model.lanes.iter().enumerate() {
let y = content.top() + li as f32 * LANE_H;
painter.text(Pos2::new(rect.left() + 6.0, y + LANE_H / 2.0), Align2::LEFT_CENTER, &lane.label, FontId::proportional(12.0), th.text);
painter.line_segment([Pos2::new(rect.left(), y), Pos2::new(content.right(), y)], Stroke::new(0.5_f32, th.panel_stroke.linear_multiply(0.2)));
}
// Bars — paint via the SAME `layout` the tests assert, then ride an
// a11y/hit-testable node keyed on the stable bar id (FC-4/FC-5).
let laid = layout(&self.state.model, t0, t1, content);
let base_id = ui.id().with("timeline-bars");
let mut clicked: Option<String> = None;
for bl in &laid {
let bar = &self.state.model.lanes[bl.lane].bars[bl.bar];
let is_sel = self.state.selected.as_deref() == Some(bl.id.as_str());
let base = Self::status_color(bl.status, &th);
if bar.segments.is_empty() {
painter.rect_filled(bl.rect, CornerRadius::same(3), base);
} else {
// Segmented bar: split the width by normalised phase fractions.
let total = bar.segment_total().max(f32::EPSILON);
let mut x = bl.rect.left();
for s in &bar.segments {
let w = bl.rect.width() * (s.fraction / total);
let seg_rect = Rect::from_min_max(Pos2::new(x, bl.rect.top()), Pos2::new(x + w, bl.rect.bottom()));
painter.rect_filled(seg_rect, CornerRadius::ZERO, phase_color(&s.kind));
x += w;
}
}
let (sw, sc) = if is_sel { (2.0_f32, th.accent) } else { (1.0_f32, base.linear_multiply(1.4)) };
painter.rect_stroke(bl.rect, CornerRadius::same(3), Stroke::new(sw, sc), StrokeKind::Inside);
// Label inside the bar when it's wide enough.
if bl.rect.width() > 36.0 {
painter.text(bl.rect.left_center() + Vec2::new(4.0, 0.0), Align2::LEFT_CENTER, &bar.label, FontId::proportional(10.0), th.text);
}
let sem = a11y::Semantics::button(format!("{} · {}", bar.label, bl.status.label())).selected(is_sel);
let resp = a11y::node(ui, base_id, &bl.id, Sense::click(), bl.rect, sem);
if resp.clicked() {
clicked = Some(bl.id.clone());
}
if resp.hovered() {
let dur = bar.duration();
let mut tip = format!("{}\n{} · {:.0}–{:.0} ({:.0})", bar.label, bl.status.label(), bar.start, bar.end, dur);
for s in &bar.segments {
tip.push_str(&format!("\n {} {:.0}%", s.kind, 100.0 * s.fraction / bar.segment_total().max(f32::EPSILON)));
}
resp.on_hover_text(tip);
}
}
// Click toggles selection — emitted as a `Msg`, applied by `update` (so a
// headless `Msg::Select`/`ClearSelection` drives the identical transition).
if let Some(id) = clicked {
if self.state.selected.as_deref() == Some(id.as_str()) {
msgs.push(Msg::ClearSelection);
} else {
msgs.push(Msg::Select(id));
}
}
// Scroll-zoom the time window around the pointer (scalable cap).
let hover = ui.rect_contains_pointer(content);
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if hover && scroll.abs() > 0.5 {
let px = ui.input(|i| i.pointer.hover_pos()).unwrap_or(content.center());
let f = ((px.x - content.left()) / content.width().max(1.0)).clamp(0.0, 1.0) as f64;
let pivot = t0 + (t1 - t0) * f;
let factor = if scroll > 0.0 { 0.85 } else { 1.0 / 0.85 };
let span = (t1 - t0).max(f64::EPSILON) * factor;
let model_span = (self.state.model.t1 - self.state.model.t0).max(f64::EPSILON);
let span = span.min(model_span); // never zoom out past the model
let na = (pivot - f * span).max(self.state.model.t0);
let nb = na + span;
msgs.push(Msg::SetWindow(na, nb));
ui.ctx().request_repaint();
}
// Selected-bar detail line.
if let Some(b) = self.selected_bar() {
ui.separator();
ui.horizontal_wrapped(|ui| {
ui.strong(&b.label);
ui.colored_label(Self::status_color(b.status, &th), b.status.label());
ui.label(egui::RichText::new(format!("{:.0}–{:.0} ({:.0})", b.start, b.end, b.duration())).monospace().color(th.text_dim));
});
}
// ── render-lane emit: this pure-view path RAN ─────────────────────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-timeline::TimelineView::view",
"ui_render",
!self.state.model.is_empty(),
&format!("lanes={} bars={}", self.state.model.lanes.len(), self.state.model.bar_count()),
);
msgs
}
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for TimelineView {
type Model = TimelineState;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &TimelineState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
TimelineView::update(self, msg)
}
fn view(&self, ui: &mut Ui) -> Vec<Msg> {
TimelineView::view(self, ui)
}
}
// The bridge macro writes `impl Facet for TimelineView` from the `Elm` impl:
// `title`, the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), and
// `state_json = serde(state())` (the custom `TimelineState::serialize` above, which
// carries `bar_count`/`has_segments`). **Form 2** because the timeline additionally
// exposes caps, typed copy, a bar-scoped `selection_json`, a `kind` tag, and a
// portable selection/zoom subset — spliced into the same `impl Facet` block below.
facett_core::impl_facet_via_elm!(TimelineView, {
fn copy(&mut self) -> Option<String> {
self.copy_payload().map(|p| p.as_text())
}
/// Reads the [`Theme`](facett_core::Theme), a bar is click-**selectable**, the
/// canvas takes the host's size (**resizable**), scroll-**zooms** the time
/// axis (**scalable**), and the selection/lanes are **copyable**.
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().selectable().resizable().scalable().copyable()
}
fn selection_json(&self) -> serde_json::Value {
match self.selected_bar() {
Some(b) => serde_json::json!({ "bar": b.id, "label": b.label, "status": b.status.label() }),
None => serde_json::Value::Null,
}
}
fn kind(&self) -> &'static str {
"timeline"
}
/// The portable subset a same-kind sibling can adopt: the selection + zoom
/// window + legend toggle (NOT the model, which the host owns).
fn portable_state(&self) -> Option<serde_json::Value> {
Some(serde_json::json!({
"selected": self.state.selected,
"window": self.state.window.map(|(a, b)| [a, b]),
"show_legend": self.state.show_legend,
}))
}
fn load_state(&mut self, state: &serde_json::Value) -> bool {
let mut changed = false;
if let Some(sel) = state.get("selected") {
self.state.selected = sel.as_str().map(|s| s.to_string());
changed = true;
}
if let Some(w) = state.get("window") {
self.state.window = w.as_array().and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
changed = true;
}
if let Some(b) = state.get("show_legend").and_then(|v| v.as_bool()) {
self.state.show_legend = b;
changed = true;
}
changed
}
});
#[cfg(test)]
mod tests {
use super::*;
// `Elm` for `state()`; `Facet` for `state_json`/`selection_json`/`portable_state`.
use facett_core::{Elm, Facet};
#[test]
fn typed_copy_is_selected_bar_or_whole_swimlane_rows() {
use facett_core::clip::{ClipKind, CopySource};
let model = TimelineModel::new(0.0, 1000.0)
.with_lane(Lane::new("build").with_bar(Bar::new("compile", 0.0, 300.0, BarStatus::Done)))
.with_lane(Lane::new("test").with_bar(Bar::new("unit", 500.0, 900.0, BarStatus::Running)));
let mut v = TimelineView::new("t", model);
// No selection -> TSV of every bar.
let p = v.copy_payload().expect("bars copy");
assert_eq!(p.kind(), ClipKind::Rows);
let text = p.as_text();
assert!(text.starts_with("lane\tbar\tstatus\tstart\tend"), "{text}");
assert!(text.contains("\nbuild\tcompile\tdone\t0\t300"), "{text}");
// Select a bar -> narrow to that bar's line.
v.update(Msg::Select("unit".into()));
assert_eq!(v.state.selected.as_deref(), Some("unit"));
assert_eq!(v.copy_payload().unwrap().as_text(), "unit\trunning\t500\t900");
}
fn rect(w: f32, h: f32) -> Rect {
Rect::from_min_size(Pos2::ZERO, Vec2::new(w, h))
}
#[test]
fn layout_places_bars_at_correct_offsets() {
// window 0..1000, content 0..1000px → 1px == 1 wall-clock unit.
let model = TimelineModel::new(0.0, 1000.0)
.with_lane(Lane::new("a").with_bar(Bar::new("x", 0.0, 300.0, BarStatus::Done)))
.with_lane(Lane::new("b").with_bar(Bar::new("y", 500.0, 1000.0, BarStatus::Running)));
let content = rect(1000.0, 2.0 * LANE_H);
let laid = layout(&model, 0.0, 1000.0, content);
assert_eq!(laid.len(), 2, "two bars laid out");
// x: 0..300 → left 0, width 300. y in lane 0.
assert!((laid[0].rect.left() - 0.0).abs() < 0.5, "bar x left == 0");
assert!((laid[0].rect.width() - 300.0).abs() < 0.5, "bar x width == 300");
assert!(laid[0].rect.top() >= content.top() && laid[0].rect.bottom() <= content.top() + LANE_H);
// y: 500..1000 → left 500, width 500, in lane 1 (row below).
assert!((laid[1].rect.left() - 500.0).abs() < 0.5, "bar y left == 500");
assert!((laid[1].rect.width() - 500.0).abs() < 0.5, "bar y width == 500");
assert!(laid[1].rect.top() >= content.top() + LANE_H, "bar y sits in lane 1");
}
#[test]
fn zoom_window_rescales_offsets() {
let model = TimelineModel::new(0.0, 1000.0).with_lane(Lane::new("a").with_bar(Bar::new("x", 500.0, 600.0, BarStatus::Done)));
let content = rect(1000.0, LANE_H);
// Full window: 500..600 → left 500 width 100.
let full = layout(&model, 0.0, 1000.0, content);
assert!((full[0].rect.left() - 500.0).abs() < 0.5);
// Zoom to 400..700 (span 300) → frac (500-400)/300 = .333 → left ~333, width 100/300*1000≈333.
let zoom = layout(&model, 400.0, 700.0, content);
assert!((zoom[0].rect.left() - 333.3).abs() < 1.0, "zoom rescales start offset");
assert!((zoom[0].rect.width() - 333.3).abs() < 1.0, "zoom rescales width");
}
#[test]
fn msg_select_and_window_drive_state() {
let mut v = TimelineView::local();
assert!(v.state.selected.is_none());
v.update(Msg::Select("t2".into()));
assert_eq!(v.state.selected.as_deref(), Some("t2"), "selecting an existing bar sets it");
assert_eq!(v.selection_json()["bar"], "t2");
v.update(Msg::Select("nope".into()));
assert_eq!(v.state.selected.as_deref(), Some("t2"), "selecting an absent id is a no-op");
v.update(Msg::SetWindow(100.0, 200.0));
assert_eq!(v.window(), (100.0, 200.0));
assert_eq!(v.state.window, Some((100.0, 200.0)));
v.update(Msg::ResetWindow);
assert_eq!(v.state.window, None, "reset drops the zoom override");
}
// ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
// harness and assert the resulting `TimelineState` — no egui, no GPU. ──────
#[test]
fn harness_snapshot_drives_scrub_zoom_select() {
use facett_core::harness;
let mut v = TimelineView::local();
// A scrub/zoom/select sequence, applied purely via `Elm::update`.
let snap = harness::snapshot(
&mut v,
[
Msg::Select("t2".into()),
Msg::SetWindow(450.0, 960.0), // zoom onto the straggler task span
Msg::ToggleLegend, // legend on -> off
],
);
assert_eq!(snap.selected.as_deref(), Some("t2"), "selection is observable in the snapshot");
assert_eq!(snap.window, Some((450.0, 960.0)), "zoom window is observable");
assert!(!snap.show_legend, "the legend toggled off");
// The snapshot is a clone of the live state — same transition, headlessly.
assert_eq!(&snap, v.state());
}
#[test]
fn harness_setwindow_is_normalised_and_clearselection_resets() {
use facett_core::harness;
let mut v = TimelineView::local();
// Reversed bounds get ordered; ClearSelection wipes a prior selection.
let snap = harness::snapshot(
&mut v,
[Msg::Select("job0".into()), Msg::SetWindow(300.0, 120.0), Msg::ClearSelection],
);
assert_eq!(snap.selected, None, "ClearSelection empties the selection");
let (a, b) = snap.window.unwrap();
assert!(a < b && (a - 120.0).abs() < 1e-9, "reversed SetWindow bounds are ordered");
}
#[test]
fn drive_reports_no_effects_and_state_round_trips() {
use facett_core::harness;
let mut v = TimelineView::local();
// FC-8: the timeline does no I/O, so the effect stream is always empty.
let effects = harness::drive(&mut v, [Msg::Select("t1".into()), Msg::ToggleLegend]);
assert!(effects.is_empty(), "FC-8: timeline emits no Effects");
// FC-3: the observable Model round-trips through serde.
let json = serde_json::to_value(v.state()).unwrap();
assert_eq!(json["selected"], "t1");
assert_eq!(json["bar_count"].as_u64().unwrap(), v.model().bar_count() as u64);
let back: TimelineState = serde_json::from_value(json).unwrap();
assert_eq!(&back, v.state(), "serde(state) -> state round-trips");
}
#[test]
fn state_json_carries_bar_count_segments_and_finite_fractions() {
// Facet::state_json (macro-generated = serde(state)) exposes the derived
// `bar_count`/`has_segments` the render matrix reads.
let v = TimelineView::local();
let st = Facet::state_json(&v);
assert!(st["bar_count"].as_u64().unwrap() >= 6, "demo has executor/job/task bars");
assert_eq!(st["has_segments"], true, "the task lane carries phase segments");
// The raw model round-trips its lanes/bars/segments too.
let task_lane = st["model"]["lanes"].as_array().unwrap().last().unwrap();
let segs = task_lane["bars"][0]["segments"].as_array().unwrap();
assert!(segs.len() >= 5, "a task bar is split into phase segments");
// The render-proof fractions still come from ONE formula (state_json_windowed):
// every bar's start/width fraction is finite and in [0,1] over the window.
let (t0, t1) = v.window();
let win = v.model().state_json_windowed(t0, t1);
for lane in win["lanes"].as_array().unwrap() {
for b in lane["bars"].as_array().unwrap() {
let s = b["start_frac"].as_f64().unwrap();
let w = b["width_frac"].as_f64().unwrap();
assert!((0.0..=1.0).contains(&s) && (0.0..=1.0).contains(&w));
}
}
}
#[test]
fn portable_state_round_trips() {
let mut a = TimelineView::local();
a.update(Msg::Select("t1".into()));
a.update(Msg::SetWindow(50.0, 250.0));
let portable = a.portable_state().unwrap();
let mut b = TimelineView::local();
assert!(b.load_state(&portable));
assert_eq!(b.state.selected.as_deref(), Some("t1"));
assert_eq!(b.window(), (50.0, 250.0));
}
}