Skip to main content

datagrout_panels_egui/
lib.rs

1//! Render [DataGrout](https://datagrout.ai) Smart Panels with
2//! [egui](https://github.com/emilk/egui).
3//!
4//! # Why immediate mode
5//!
6//! egui redraws from state every frame with no retained widget tree. A Smart
7//! Panel re-derives its `panel_source` from the rulebase on every load, with no
8//! retained DOM. They are the same architecture — so rendering one with the
9//! other costs no reconciliation, unlike a renderer that must diff panel facts
10//! into a persistent element tree.
11//!
12//! One function per kind, dispatched by [`render_panel`]. Every renderer is
13//! pure: it reads the panel and draws. Nothing here fetches, caches, or mutates.
14//!
15//! Form fields report interaction through [`PanelAction`] rather than acting on
16//! it, so the *caller* decides what a submit means. This crate has no transport
17//! and must not grow one.
18//!
19//! # Where the panels come from
20//!
21//! Panels are published to DataGrout with the gateway's `smart_panel.publish`
22//! tool and read back with `smart_panel.list`; that response becomes `Panel`
23//! values via [`datagrout_panels::Panel::all_from_list`]. Bring your own MCP
24//! client — neither crate has a transport. A hand-written list response works
25//! too, which is the quickest way to see this renderer draw something.
26//!
27//! ```no_run
28//! use datagrout_panels::Panel;
29//! use datagrout_panels_egui::render_panel;
30//!
31//! # fn demo(ctx: &egui::Context, list_response: serde_json::Value) {
32//! let panels = Panel::all_from_list(&list_response);
33//!
34//! egui::CentralPanel::default().show(ctx, |ui| {
35//!     for panel in &panels {
36//!         render_panel(ui, panel);
37//!     }
38//! });
39//! # }
40//! ```
41
42#![forbid(unsafe_code)]
43
44use egui::{Color32, RichText, Sense, Stroke, Ui, Vec2};
45use serde_json::Value;
46
47use datagrout_panels::{Panel, PanelKind};
48
49/// Something a viewer did that the host application must act on.
50///
51/// Returned rather than executed: dispatching a field's `panel_source` goal
52/// requires a transport, and this crate deliberately has none — the same panel
53/// must be renderable by a host that reaches its cell over MCP, over a local
54/// proxy, or not at all.
55#[derive(Debug, Clone, PartialEq)]
56pub enum PanelAction {
57    /// A `button` field or form submit fired.
58    Submit { panel_id: String, field_id: String },
59    /// A field's value changed. Cascade to dependents via `field_input` edges.
60    ValueChanged {
61        panel_id: String,
62        field_id: String,
63        value: String,
64    },
65}
66
67/// Mutable per-viewer form state. Kept by the caller across frames — immediate
68/// mode means the widgets themselves hold nothing.
69///
70/// Both maps are keyed by **field id**. That is safe because a field is itself
71/// a `panel/3` registration in the fact schema, so ids are unique within a
72/// namespace; one `FormState` shared across panels from *different* namespaces
73/// could collide, so give those their own.
74#[derive(Debug, Default, Clone)]
75pub struct FormState {
76    pub values: std::collections::BTreeMap<String, String>,
77    pub checks: std::collections::BTreeMap<String, bool>,
78}
79
80impl FormState {
81    /// The values to submit for `panel`, keyed by field id.
82    ///
83    /// This is the `{field_id => value}` map a DataGrout form submit expects.
84    /// On submit the gateway matches those ids — by normalized name — either to
85    /// the `+` inputs of a rule published with `reactor.expose` or to the
86    /// Prolog variables in the panel's own `panel_source` goal, then evaluates
87    /// it in the cell. So a host's whole job is to collect this and send it.
88    ///
89    /// Every non-button field appears. A field the viewer never touched
90    /// contributes its declared default (or an empty string) rather than being
91    /// omitted, so a server-side required-field check sees the full form.
92    /// Buttons carry no value; the one that fired arrives in
93    /// [`PanelAction::Submit`] instead.
94    pub fn submission(&self, panel: &Panel) -> std::collections::BTreeMap<String, String> {
95        panel
96            .fields
97            .iter()
98            .filter(|field| field.kind != PanelKind::Button)
99            .map(|field| {
100                let value = match field.kind {
101                    PanelKind::Checkbox => self
102                        .checks
103                        .get(&field.id)
104                        .copied()
105                        .unwrap_or_else(|| field.default_value().as_deref() == Some("true"))
106                        .to_string(),
107                    _ => self
108                        .values
109                        .get(&field.id)
110                        .cloned()
111                        .unwrap_or_else(|| field.default_value().unwrap_or_default()),
112                };
113                (field.id.clone(), value)
114            })
115            .collect()
116    }
117}
118
119/// Render a panel. Returns any actions the viewer triggered this frame.
120pub fn render_panel(ui: &mut Ui, panel: &Panel) -> Vec<PanelAction> {
121    let mut state = FormState::default();
122    render_panel_with_state(ui, panel, &mut state)
123}
124
125/// Render a panel with caller-held form state.
126pub fn render_panel_with_state(
127    ui: &mut Ui,
128    panel: &Panel,
129    state: &mut FormState,
130) -> Vec<PanelAction> {
131    let mut actions = Vec::new();
132
133    ui.vertical(|ui| {
134        ui.label(RichText::new(panel.title()).heading());
135        if let Some(desc) = panel.description() {
136            ui.label(RichText::new(desc).weak().small());
137        }
138        ui.add_space(4.0);
139
140        match &panel.kind {
141            PanelKind::Dashboard => actions.extend(dashboard(ui, panel, state)),
142            PanelKind::Metric => metric(ui, panel),
143            PanelKind::Gauge => gauge(ui, panel),
144            PanelKind::Table => table(ui, panel),
145            PanelKind::List => list(ui, panel),
146            PanelKind::LineChart | PanelKind::AreaChart => line_chart(ui, panel),
147            PanelKind::BarChart => bar_chart(ui, panel),
148            PanelKind::Heatmap => heatmap(ui, panel),
149            PanelKind::Markdown | PanelKind::Doc => {
150                // Deliberately plain: pulling a Markdown renderer in would add
151                // a dependency for one panel kind. Callers that want rich text
152                // can special-case Doc before calling here.
153                ui.label(panel.prop_str("body").unwrap_or_default());
154            }
155            k if k.is_form_kind() => actions.extend(form(ui, panel, state)),
156            other => placeholder(ui, other),
157        }
158    });
159
160    actions
161}
162
163// ── composite ───────────────────────────────────────────────────────────────
164
165/// A dashboard lays its children out in columns, each rendered as a full panel.
166///
167/// Children flow into whichever column is currently shortest, so a tall table
168/// in one column does not leave a hole under a short metric in the next — the
169/// masonry rule. Order is preserved within a column, and the first row still
170/// reads left to right.
171fn dashboard(ui: &mut Ui, panel: &Panel, state: &mut FormState) -> Vec<PanelAction> {
172    if panel.children.is_empty() {
173        ui.label(
174            RichText::new("no panels on this dashboard")
175                .weak()
176                .italics(),
177        );
178        return Vec::new();
179    }
180
181    // Two columns reads well at typical side-pane widths; a wider host can
182    // split children by `slot()` itself before calling here.
183    let columns = (panel.prop_f64("columns_per_row").unwrap_or(2.0).max(1.0) as usize)
184        .min(panel.children.len());
185    let mut actions = Vec::new();
186
187    ui.columns(columns, |cols| {
188        for child in &panel.children {
189            // Shortest column so far takes the next child; ties go left.
190            let target = (0..columns)
191                .min_by(|a, b| {
192                    cols[*a]
193                        .min_rect()
194                        .height()
195                        .total_cmp(&cols[*b].min_rect().height())
196                })
197                .unwrap_or(0);
198            let col = &mut cols[target];
199            col.group(|ui| {
200                ui.set_min_width(180.0);
201                actions.extend(render_panel_with_state(ui, child, state));
202            });
203            col.add_space(12.0);
204        }
205    });
206
207    actions
208}
209
210// ── display kinds ───────────────────────────────────────────────────────────
211
212/// A single big number.
213fn metric(ui: &mut Ui, panel: &Panel) {
214    let value = first_cell(panel)
215        .map(render_cell)
216        .unwrap_or_else(|| "—".to_string());
217    let unit = panel.prop_str("unit").unwrap_or_default();
218
219    ui.horizontal(|ui| {
220        ui.label(RichText::new(value).size(34.0).strong().monospace());
221        if !unit.is_empty() {
222            ui.label(RichText::new(unit).weak());
223        }
224    });
225}
226
227/// A bounded reading with a filled track. `props.min` / `props.max` bound it;
228/// without them the gauge degrades to a metric rather than inventing a scale.
229fn gauge(ui: &mut Ui, panel: &Panel) {
230    let Some(value) = first_cell(panel).and_then(as_f64) else {
231        return metric(ui, panel);
232    };
233    let min = panel.prop_f64("min").unwrap_or(0.0);
234    let max = panel.prop_f64("max").unwrap_or(100.0);
235    let frac = if (max - min).abs() < f64::EPSILON {
236        0.0
237    } else {
238        ((value - min) / (max - min)).clamp(0.0, 1.0) as f32
239    };
240
241    ui.label(
242        RichText::new(format!("{value:.3}"))
243            .size(28.0)
244            .strong()
245            .monospace(),
246    );
247
248    let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 10.0), Sense::hover());
249    let painter = ui.painter();
250    painter.rect_filled(rect, 3.0_f32, ui.visuals().extreme_bg_color);
251    let mut filled = rect;
252    filled.set_width(rect.width() * frac);
253    painter.rect_filled(filled, 3.0_f32, accent(ui));
254
255    ui.label(RichText::new(format!("{min} – {max}")).weak().small());
256}
257
258fn table(ui: &mut Ui, panel: &Panel) {
259    if panel.rows.is_empty() {
260        return empty(ui);
261    }
262    let headers = panel.columns();
263
264    egui::Grid::new(format!("dgp_table_{}_{}", panel.namespace, panel.id))
265        .striped(true)
266        .show(ui, |ui| {
267            if !headers.is_empty() {
268                for h in &headers {
269                    ui.label(RichText::new(h).strong().small());
270                }
271                ui.end_row();
272            }
273            // Panels are dashboards, not data dumps; DataGrout caps live
274            // source queries at 200 rows and this matches that ceiling.
275            for row in panel.rows.iter().take(200) {
276                for cell in row {
277                    ui.label(RichText::new(render_cell(cell)).monospace().small());
278                }
279                ui.end_row();
280            }
281        });
282}
283
284fn list(ui: &mut Ui, panel: &Panel) {
285    if panel.rows.is_empty() {
286        return empty(ui);
287    }
288    for row in panel.rows.iter().take(200) {
289        let text = row.iter().map(render_cell).collect::<Vec<_>>().join(" · ");
290        ui.label(format!("• {text}"));
291    }
292}
293
294/// A polyline over the last numeric column.
295///
296/// Suited to dashboard-scale series. A host with a high-rate live signal should
297/// draw that directly rather than routing it through a panel: panel rows are
298/// facts, and facts are the wrong granularity for a waveform.
299fn line_chart(ui: &mut Ui, panel: &Panel) {
300    let values = numeric_series(panel);
301    if values.is_empty() {
302        return empty(ui);
303    }
304
305    let height = panel.prop_f64("height").unwrap_or(120.0) as f32;
306    let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
307    let painter = ui.painter_at(rect);
308
309    let (min, max) = min_max(&values);
310    let range = if (max - min).abs() < 1e-12 {
311        1.0
312    } else {
313        max - min
314    };
315    let dx = if values.len() > 1 {
316        rect.width() / (values.len() - 1) as f32
317    } else {
318        rect.width()
319    };
320
321    let points: Vec<egui::Pos2> = values
322        .iter()
323        .enumerate()
324        .map(|(i, v)| {
325            let y = rect.bottom() - (((v - min) / range) as f32) * rect.height();
326            egui::pos2(rect.left() + i as f32 * dx, y)
327        })
328        .collect();
329
330    painter.add(egui::Shape::line(points, Stroke::new(1.5_f32, accent(ui))));
331    ui.label(
332        RichText::new(format!("{} pts · {min:.3} … {max:.3}", values.len()))
333            .weak()
334            .small(),
335    );
336}
337
338fn bar_chart(ui: &mut Ui, panel: &Panel) {
339    let values = numeric_series(panel);
340    if values.is_empty() {
341        return empty(ui);
342    }
343    let height = panel.prop_f64("height").unwrap_or(120.0) as f32;
344    let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
345    let painter = ui.painter_at(rect);
346
347    let (min, max) = min_max(&values);
348    let base = min.min(0.0);
349    let range = if (max - base).abs() < 1e-12 {
350        1.0
351    } else {
352        max - base
353    };
354    let bw = rect.width() / values.len() as f32;
355
356    for (i, v) in values.iter().enumerate() {
357        let h = (((v - base) / range) as f32) * rect.height();
358        let bar = egui::Rect::from_min_size(
359            egui::pos2(rect.left() + i as f32 * bw, rect.bottom() - h),
360            Vec2::new((bw - 2.0).max(1.0), h),
361        );
362        painter.rect_filled(bar, 1.0_f32, accent(ui));
363    }
364}
365
366/// Rows as intensity bands.
367fn heatmap(ui: &mut Ui, panel: &Panel) {
368    if panel.rows.is_empty() {
369        return empty(ui);
370    }
371    let cell = panel.prop_f64("cell").unwrap_or(8.0) as f32;
372    let cols = panel.rows.iter().map(Vec::len).max().unwrap_or(0);
373    if cols == 0 {
374        return empty(ui);
375    }
376
377    let size = Vec2::new(cols as f32 * cell, panel.rows.len() as f32 * cell);
378    let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
379    let painter = ui.painter_at(rect);
380
381    let all: Vec<f64> = panel.rows.iter().flatten().filter_map(as_f64).collect();
382    let (min, max) = min_max(&all);
383    let range = if (max - min).abs() < 1e-12 {
384        1.0
385    } else {
386        max - min
387    };
388
389    for (r, row) in panel.rows.iter().enumerate() {
390        for (c, v) in row.iter().enumerate() {
391            let Some(v) = as_f64(v) else { continue };
392            let t = ((v - min) / range).clamp(0.0, 1.0) as f32;
393            let px = egui::Rect::from_min_size(
394                egui::pos2(rect.left() + c as f32 * cell, rect.top() + r as f32 * cell),
395                Vec2::splat(cell),
396            );
397            painter.rect_filled(px, 0.0_f32, intensity(t));
398        }
399    }
400}
401
402// ── form kinds ──────────────────────────────────────────────────────────────
403
404fn form(ui: &mut Ui, panel: &Panel, state: &mut FormState) -> Vec<PanelAction> {
405    let mut actions = Vec::new();
406
407    for field in &panel.fields {
408        let label = field.label();
409        ui.horizontal(|ui| {
410            let text = if field.required() {
411                format!("{label} *")
412            } else {
413                label.clone()
414            };
415            ui.label(RichText::new(text).small());
416        });
417
418        let id = field.id.clone();
419        match field.kind {
420            PanelKind::Checkbox => {
421                let checked = state.checks.entry(id.clone()).or_default();
422                if ui.checkbox(checked, "").changed() {
423                    actions.push(PanelAction::ValueChanged {
424                        panel_id: panel.id.clone(),
425                        field_id: id,
426                        value: checked.to_string(),
427                    });
428                }
429            }
430            PanelKind::Button => {
431                if ui.button(&label).clicked() {
432                    actions.push(PanelAction::Submit {
433                        panel_id: panel.id.clone(),
434                        field_id: id,
435                    });
436                }
437            }
438            PanelKind::TextArea | PanelKind::RichText => {
439                let value = state
440                    .values
441                    .entry(id.clone())
442                    .or_insert_with(|| field.default_value().unwrap_or_default());
443                if ui.text_edit_multiline(value).changed() {
444                    actions.push(PanelAction::ValueChanged {
445                        panel_id: panel.id.clone(),
446                        field_id: id,
447                        value: value.clone(),
448                    });
449                }
450            }
451            _ => {
452                let value = state
453                    .values
454                    .entry(id.clone())
455                    .or_insert_with(|| field.default_value().unwrap_or_default());
456                let widget = egui::TextEdit::singleline(value).hint_text(field.placeholder());
457                if ui.add(widget).changed() {
458                    actions.push(PanelAction::ValueChanged {
459                        panel_id: panel.id.clone(),
460                        field_id: id,
461                        value: value.clone(),
462                    });
463                }
464            }
465        }
466        ui.add_space(6.0);
467    }
468
469    actions
470}
471
472// ── helpers ─────────────────────────────────────────────────────────────────
473
474fn placeholder(ui: &mut Ui, kind: &PanelKind) {
475    ui.label(
476        RichText::new(format!("(no renderer for {})", kind.as_str()))
477            .weak()
478            .italics(),
479    );
480}
481
482fn empty(ui: &mut Ui) {
483    ui.label(RichText::new("no data").weak().italics());
484}
485
486fn accent(ui: &Ui) -> Color32 {
487    ui.visuals().selection.bg_fill
488}
489
490/// Dark-to-bright ramp for heatmap cells. Perceptually crude but theme-neutral
491/// and dependency-free; swap for a real colormap if it starts carrying meaning.
492fn intensity(t: f32) -> Color32 {
493    let t = t.clamp(0.0, 1.0);
494    Color32::from_rgb(
495        (20.0 + 200.0 * t) as u8,
496        (30.0 + 120.0 * t) as u8,
497        (60.0 + 60.0 * (1.0 - t)) as u8,
498    )
499}
500
501fn first_cell(panel: &Panel) -> Option<&Value> {
502    panel.rows.first()?.last()
503}
504
505fn as_f64(v: &Value) -> Option<f64> {
506    match v {
507        Value::Number(n) => n.as_f64(),
508        Value::String(s) => s.trim().parse().ok(),
509        _ => None,
510    }
511}
512
513/// The last numeric cell of each row — chart rows are `[label, value]` by
514/// convention, so the value is on the right.
515fn numeric_series(panel: &Panel) -> Vec<f64> {
516    panel
517        .rows
518        .iter()
519        .filter_map(|row| row.iter().rev().find_map(as_f64))
520        .collect()
521}
522
523fn min_max(values: &[f64]) -> (f64, f64) {
524    values
525        .iter()
526        .fold((f64::MAX, f64::MIN), |(lo, hi), v| (lo.min(*v), hi.max(*v)))
527}
528
529fn render_cell(v: &Value) -> String {
530    match v {
531        Value::String(s) => s.clone(),
532        Value::Number(n) => n.to_string(),
533        Value::Bool(b) => b.to_string(),
534        Value::Null => String::new(),
535        other => other.to_string(),
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use datagrout_panels::{PanelFacts, Props};
543    use serde_json::json;
544
545    fn panel_with(kind: PanelKind, rows: Vec<Vec<Value>>) -> Panel {
546        Panel {
547            id: "t".into(),
548            kind,
549            namespace: "ns".into(),
550            props: Props::new(),
551            rows,
552            source: None,
553            fields: Vec::new(),
554            children: Vec::new(),
555            published: true,
556        }
557    }
558
559    /// Run one headless frame and render `panel` inside it.
560    ///
561    /// egui needs no window to lay out and paint into shapes, so every kind can
562    /// be exercised for real — panics, infinite loops, bad grid arithmetic —
563    /// without a display. Returns the actions the frame produced.
564    fn render_once(panel: &Panel) -> Vec<PanelAction> {
565        let ctx = egui::Context::default();
566        let mut actions = Vec::new();
567        let _ = ctx.run(egui::RawInput::default(), |ctx| {
568            egui::CentralPanel::default().show(ctx, |ui| {
569                actions = render_panel(ui, panel);
570            });
571        });
572        actions
573    }
574
575    #[test]
576    fn numeric_series_takes_the_rightmost_number() {
577        let p = panel_with(
578            PanelKind::LineChart,
579            vec![
580                vec![json!("Jan"), json!(4.0)],
581                vec![json!("Feb"), json!(6.0)],
582            ],
583        );
584        assert_eq!(numeric_series(&p), vec![4.0, 6.0]);
585    }
586
587    #[test]
588    fn numeric_series_skips_rows_with_no_number() {
589        let p = panel_with(
590            PanelKind::LineChart,
591            vec![
592                vec![json!("Jan"), json!("n/a")],
593                vec![json!("Feb"), json!(6.0)],
594            ],
595        );
596        assert_eq!(numeric_series(&p), vec![6.0]);
597    }
598
599    #[test]
600    fn min_max_of_a_flat_series_does_not_divide_by_zero() {
601        let (lo, hi) = min_max(&[2.0, 2.0]);
602        assert_eq!((lo, hi), (2.0, 2.0));
603    }
604
605    #[test]
606    fn every_display_kind_renders_with_and_without_rows() {
607        let kinds = [
608            PanelKind::BarChart,
609            PanelKind::LineChart,
610            PanelKind::AreaChart,
611            PanelKind::PieChart,
612            PanelKind::Scatter,
613            PanelKind::Table,
614            PanelKind::Metric,
615            PanelKind::Gauge,
616            PanelKind::Markdown,
617            PanelKind::List,
618            PanelKind::Heatmap,
619            PanelKind::Timeline,
620            PanelKind::Funnel,
621            PanelKind::Game,
622            PanelKind::Doc,
623            PanelKind::Dashboard,
624            PanelKind::Unknown("sonar".into()),
625        ];
626        let rows = vec![
627            vec![json!("a"), json!(1.5)],
628            vec![json!("b"), json!(-2.0)],
629            vec![json!("c"), json!("n/a")],
630        ];
631        for kind in kinds {
632            render_once(&panel_with(kind.clone(), Vec::new()));
633            render_once(&panel_with(kind, rows.clone()));
634        }
635    }
636
637    #[test]
638    fn a_flat_series_renders_without_dividing_by_zero() {
639        let p = panel_with(PanelKind::BarChart, vec![vec![json!(3)], vec![json!(3)]]);
640        render_once(&p);
641        let g = panel_with(PanelKind::Gauge, vec![vec![json!(50)]]);
642        render_once(&g);
643    }
644
645    #[test]
646    fn a_dashboard_renders_its_children() {
647        let facts = PanelFacts {
648            panels: vec![
649                json!({"Id": "board", "Kind": "dashboard", "Namespace": "ns"}),
650                json!({"Id": "a", "Kind": "metric", "Namespace": "ns"}),
651                json!({"Id": "b", "Kind": "table", "Namespace": "ns"}),
652                json!({"Id": "c", "Kind": "bar_chart", "Namespace": "ns"}),
653            ],
654            props: vec![
655                json!({"Id": "a", "Key": "parent", "Value": "board"}),
656                json!({"Id": "b", "Key": "parent", "Value": "board"}),
657                json!({"Id": "c", "Key": "parent", "Value": "board"}),
658                json!({"Id": "b", "Key": "columns", "Value": ["Name", "N"]}),
659            ],
660            data: vec![
661                json!({"Id": "a", "Rows": [["x", 7]]}),
662                json!({"Id": "b", "Rows": [["p", 1], ["q", 2]]}),
663            ],
664            ..Default::default()
665        };
666        let board = Panel::all_from_facts(&facts).remove(0);
667        assert_eq!(board.children.len(), 3);
668        // Three children in a two-column grid exercises the row-wrap path.
669        render_once(&board);
670    }
671
672    #[test]
673    fn a_form_renders_every_field_kind_and_seeds_defaults() {
674        let facts = PanelFacts {
675            panels: vec![
676                json!({"Id": "f", "Kind": "form", "Namespace": "ns"}),
677                json!({"Id": "name", "Kind": "text_input", "Namespace": "ns"}),
678                json!({"Id": "notes", "Kind": "textarea", "Namespace": "ns"}),
679                json!({"Id": "ok", "Kind": "checkbox", "Namespace": "ns"}),
680                json!({"Id": "go", "Kind": "button", "Namespace": "ns"}),
681            ],
682            props: vec![
683                json!({"Id": "name", "Key": "parent", "Value": "f"}),
684                json!({"Id": "name", "Key": "default", "Value": "Ada"}),
685                json!({"Id": "notes", "Key": "parent", "Value": "f"}),
686                json!({"Id": "ok", "Key": "parent", "Value": "f"}),
687                json!({"Id": "go", "Key": "parent", "Value": "f"}),
688            ],
689            ..Default::default()
690        };
691        let form = Panel::all_from_facts(&facts).remove(0);
692        assert_eq!(form.fields.len(), 4);
693
694        let ctx = egui::Context::default();
695        let mut state = FormState::default();
696        let _ = ctx.run(egui::RawInput::default(), |ctx| {
697            egui::CentralPanel::default().show(ctx, |ui| {
698                let actions = render_panel_with_state(ui, &form, &mut state);
699                // Nothing was clicked or typed in a headless frame.
700                assert!(actions.is_empty());
701            });
702        });
703        // A declared default seeds the field's state on first render.
704        assert_eq!(state.values.get("name").map(String::as_str), Some("Ada"));
705    }
706
707    #[test]
708    fn a_submission_carries_every_field_a_host_must_send() {
709        let facts = PanelFacts {
710            panels: vec![
711                json!({"Id": "f", "Kind": "form", "Namespace": "ns"}),
712                json!({"Id": "company_name", "Kind": "text_input", "Namespace": "ns"}),
713                json!({"Id": "notes", "Kind": "textarea", "Namespace": "ns"}),
714                json!({"Id": "urgent", "Kind": "checkbox", "Namespace": "ns"}),
715                json!({"Id": "submit_it", "Kind": "button", "Namespace": "ns"}),
716            ],
717            props: vec![
718                json!({"Id": "company_name", "Key": "parent", "Value": "f"}),
719                json!({"Id": "company_name", "Key": "default", "Value": "Acme"}),
720                json!({"Id": "notes", "Key": "parent", "Value": "f"}),
721                json!({"Id": "urgent", "Key": "parent", "Value": "f"}),
722                json!({"Id": "submit_it", "Key": "parent", "Value": "f"}),
723            ],
724            ..Default::default()
725        };
726        let form = Panel::all_from_facts(&facts).remove(0);
727
728        let mut state = FormState::default();
729        state.values.insert("notes".into(), "ship tuesday".into());
730        state.checks.insert("urgent".into(), true);
731
732        let submission = state.submission(&form);
733        // The button is not a value, and the untouched field falls back to its
734        // declared default rather than vanishing.
735        assert_eq!(
736            submission,
737            [
738                ("company_name", "Acme"),
739                ("notes", "ship tuesday"),
740                ("urgent", "true"),
741            ]
742            .into_iter()
743            .map(|(k, v)| (k.to_string(), v.to_string()))
744            .collect()
745        );
746    }
747
748    #[test]
749    fn tables_use_the_columns_prop_for_headers() {
750        let mut p = panel_with(PanelKind::Table, vec![vec![json!("INV-1"), json!(30)]]);
751        p.props.insert("columns".into(), json!(["Invoice", "Days"]));
752        assert_eq!(p.columns(), vec!["Invoice", "Days"]);
753        render_once(&p);
754    }
755}