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

nordisk/facett

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