BREP_app 0.2.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
//! The SHARED schema-driven FORM VIEW — one function that draws a COMPLETE form
//! for a schema-described subject (a history feature, an assembly constraint, a
//! future PMI annotation) and returns what the user did.
//!
//! [`form::field_input`] is the shared per-FIELD renderer; this is the shared
//! per-FORM renderer above it. Before it existed, four call sites hand-rolled
//! their own chrome, grouping and layout around the same widget dispatcher — and
//! the docs screenshot rig's layout had already silently drifted away from the
//! app's. One form view means the layout is a ONE-place change.
//!
//! # The layout
//!
//! ```text
//!   E3  ⟠ Extrude                 ← title
//!   ─────────────────────────
//!   ⚠ <error message>             ← banner (only when the subject failed)
//!
//!   Profile                       ← a reference field: label, full-width
//!   [ ▣ Select sketch          ]    activation button, and the chosen entities
//!     • Sketch1            [✕]      listed BENEATH it — never behind a `[+]`
//!
//!   Distance                      ← every other field: label ABOVE a
//!   [ 25                       ]    full-width input
//!
//!   ▼ Boolean                     ← accordion, open by default
//!   ▶ Transform                   ← accordion, COLLAPSED by default
//!   ▶ Outputs                     ← read-only, collapsed by default
//!
//!   [      Return to tree      ]  ← the ONE bottom button
//! ```
//!
//! Section order is References → Parameters → the remaining groups in schema
//! order → Transform → the read-only trailing sections. References lead because
//! they are what the user picks and they are the tallest thing on the form;
//! Transform trails because it is the one the owner asked to collapse.
//!
//! # It renders; it does not mutate any engine
//!
//! The inputs are exactly `(ui, spec, &mut params)` — **no `EngineState`, no
//! runner, no document**. That is deliberate and load-bearing: the headless docs
//! generator (`examples/capture_dialogs.rs`) has none of those, so requiring one
//! would make "the docs image generator uses the same code as the UI"
//! impossible. Everything that needs an engine — activating the reference
//! picker, acting on a schema `button`, committing the edited params, leaving
//! the form — comes back OUT as a value in [`FormViewOut`] that the calling
//! panel acts on AFTER the draw. (A commit callback would have to capture
//! `&mut EngineState` while `params` is itself read out of that engine and the
//! panel already holds it for the frame — three overlapping mutable borrows.)
//!
//! # Transient view state
//!
//! Which accordions are open is pure view state, so it lives in egui MEMORY
//! keyed by (`hits_prefix`, `title`, section) rather than in a caller-owned set
//! — that is what keeps the required inputs at `(ui, spec, &mut params)` for the
//! headless caller.

use crate::form;
use brep_render::style::{FieldKind, FormField};
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;

/// Groups that are drawn as a COLLAPSED accordion by default. `Transform` is the
/// owner's explicit call (R6); the read-only trailing sections join it because
/// they report, they do not edit. Everything else opens by default — a section
/// the user must click to see is a section they will miss.
const COLLAPSED_BY_DEFAULT: &[&str] = &["Transform", "Outputs"];

/// Everything ABOUT the form that the consumer supplies. Borrowed; the form view
/// holds no state of its own beyond egui memory.
pub struct FormViewSpec<'a> {
    /// The form's heading — e.g. `"E3  ⟠ Extrude"`. Also the scope key for this
    /// form's transient view state and per-field widget ids, so it must be
    /// STABLE and UNIQUE per subject (a feature id / constraint id qualifies).
    pub title: &'a str,
    /// An optional second line under the title (a status, a type name…).
    pub subtitle: Option<&'a str>,
    /// The schema fields to render, in schema order.
    pub fields: &'a [FormField],
    /// A message banner drawn under the title — e.g. the feature's run error.
    pub banner: Option<(&'a str, egui::Color32)>,
    /// Read-only sections appended after the fields — e.g. `[("Outputs", …)]`.
    pub trailing: Option<&'a [(&'a str, Vec<String>)]>,
    /// The bottom button's label. Both of today's consumers say "Return to
    /// tree" — the history feature list and the constraint list are BOTH drawn
    /// by `panels::tree`, so the word is literal, not a metaphor (Q12). A
    /// consumer whose list is NOT a tree supplies its own wording here.
    pub exit_label: &'a str,
    /// Whether this consumer's subject lives in a ROLLED history — i.e. whether
    /// LEAVING the form also means "roll the model back to the tip".
    ///
    /// The form view holds no engine and therefore cannot roll anything itself
    /// (that is [`FormViewOut::roll_to_tip`], which the caller acts on). What
    /// this flag does is decide, in ONE place, whether an exit *carries* that
    /// intent — so no consumer has to hard-code "am I the history panel?" at its
    /// exit arm. The history panel passes `true`; assembly constraints have no
    /// rollback at all (the owner's call) and pass `false`, which is why their
    /// panel has no roll branch to get wrong.
    pub rollback: bool,
    /// Prefix for every published hit key. `""` for a consumer that shows ONE
    /// form at a time (history); a consumer that can show several at once MUST
    /// pass a prefix carrying the subject id or the rects collide silently.
    pub hits_prefix: &'a str,
}

/// A reference field's `Select` was pressed — the caller dispatches this to its
/// own picker flavour (`begin_ref_select` / `begin_ref_select_for_constraint`).
#[derive(Debug, Clone, PartialEq)]
pub struct RefActivate {
    /// The JSON key chain the picked selection writes back into.
    pub path: Vec<String>,
    /// The field's label, for the picker's prompt.
    pub label: String,
    /// The schema's entity-kind filter (`["solid"]`, `["face"]`…).
    pub filter: Vec<String>,
    /// Whether the field accepts more than one entity.
    pub multiple: bool,
    /// The names already chosen — the picker lights them up as the seed.
    pub seed: Vec<String>,
}

/// What the user did in one drawn frame of the form.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct FormViewOut {
    /// A field wrote into `params` — the caller commits the whole buffer.
    pub changed: bool,
    /// A [`FieldKind::Button`] field was clicked, by its key (`"editSketch"`).
    pub button_clicked: Option<String>,
    /// A reference field's `Select` was pressed.
    pub ref_activate: Option<RefActivate>,
    /// The bottom exit button was pressed — the caller returns to its list.
    pub exit_clicked: bool,
    /// The exit ALSO means "roll the model to the tip" — set only when the exit
    /// button was pressed AND the consumer declared [`FormViewSpec::rollback`].
    /// Always `false` for a consumer with no rollback, so its panel never needs
    /// the branch.
    pub roll_to_tip: bool,
}

/// Draw ONE complete schema-driven form into `ui`, editing `params` live, and
/// return what the user did. `hits`, when supplied, receives the widget screen
/// rects the headed verifier drives:
///
/// | key | what |
/// |---|---|
/// | `form:feature` | the title row |
/// | `form:section:{Group}` | an accordion header |
/// | `form:return` | the bottom exit button |
/// | `field:{path}` | a field's input widget |
/// | `field:{path}#{variant}` | an OPEN enum's items |
/// | `field:{path}#activate` / `#x{i}` | a reference's Select / remove buttons |
///
/// all with `spec.hits_prefix` prepended.
/// Padding INSIDE the form's container — the gap between the frame edge and the
/// first/last widget on every side.
const FORM_MARGIN: i8 = 10;

/// Gap OUTSIDE the container, between it and the pane edge, so the frame's own
/// stroke is not flush against the dock border.
const FORM_OUTER_MARGIN: i8 = 6;

pub fn form_view(
    ui: &mut egui::Ui,
    spec: &FormViewSpec<'_>,
    params: &mut Value,
    mut hits: Option<&mut HashMap<String, egui::Rect>>,
) -> FormViewOut {
    let mut out = FormViewOut::default();

    // The whole form sits inside ONE padded container, so the fields never run
    // flush against the pane edge the way the inline tree rendering did. The
    // margin is the frame's, not per-widget spacing: a single container keeps
    // the label-above-full-width rhythm intact when the pane is resized, and
    // gives the scroll region a consistent inset on every side.
    egui::Frame::group(ui.style())
        .inner_margin(egui::Margin::same(FORM_MARGIN))
        .outer_margin(egui::Margin::same(FORM_OUTER_MARGIN))
        .show(ui, |ui| {
            form_body(ui, spec, params, &mut hits, &mut out);
        });

    out
}

/// The form's contents, drawn INSIDE the padded container opened by
/// [`form_view`]. Split out so the container owns the margin in one place
/// rather than every section adding its own edge spacing.
fn form_body(
    ui: &mut egui::Ui,
    spec: &FormViewSpec<'_>,
    params: &mut Value,
    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
    out: &mut FormViewOut,
) {
    // Fill the container's width so full-width inputs stay full width.
    ui.set_width(ui.available_width());

    // --- heading ----------------------------------------------------------
    let head = ui.heading(spec.title);
    publish(spec, hits, "form:feature", head.rect);
    if let Some(subtitle) = spec.subtitle {
        ui.label(egui::RichText::new(subtitle).weak());
    }
    ui.separator();

    // --- banner: the subject's error, ABOVE the fields ---------------------
    // (The history tree ALSO keeps this on the feature's row, so a failure is
    // still visible while scanning the tree — see the panel.)
    if let Some((text, color)) = spec.banner {
        egui::Frame::group(ui.style())
            .stroke(egui::Stroke::new(1.0, color))
            .fill(color.gamma_multiply(0.12))
            .show(ui, |ui| {
                ui.set_width(ui.available_width());
                ui.add(
                    egui::Label::new(egui::RichText::new(text).color(color))
                        .wrap_mode(egui::TextWrapMode::Wrap),
                );
            });
        ui.add_space(4.0);
    }

    // --- partition the schema into direct params + grouped sections --------
    let mut references: Vec<&FormField> = Vec::new();
    let mut param_leaves: Vec<&FormField> = Vec::new();
    let mut groups: Vec<(&str, Vec<&FormField>)> = Vec::new();
    for f in spec.fields {
        match f.group.as_str() {
            "References" => references.push(f),
            "Parameters" => {
                if matches!(f.kind, FieldKind::Text { read_only: true }) {
                    continue; // the subject id — already the form's title
                }
                param_leaves.push(f);
            }
            group => match groups.iter_mut().find(|(name, _)| *name == group) {
                Some(existing) => existing.1.push(f),
                None => groups.push((group, vec![f])),
            },
        }
    }
    // Transform trails the other groups: it is the one the owner named as the
    // collapsed accordion, so it belongs at the bottom, not between two open
    // sections.
    groups.sort_by_key(|(name, _)| usize::from(*name == "Transform"));

    // (1) references, then (2) plain parameters — both un-sectioned, because a
    // section over the fields the user came here to edit is a click in the way.
    for f in references.iter().chain(param_leaves.iter()) {
        draw_field(ui, spec, f, params, hits, out);
    }

    // (3) the remaining groups, each an accordion.
    for (name, fields) in &groups {
        section(ui, spec, name, hits, |ui, hits| {
            for f in fields {
                draw_field(ui, spec, f, params, hits, out);
            }
        });
    }

    // (4) read-only trailing sections (Outputs…).
    for (name, values) in spec.trailing.unwrap_or(&[]) {
        section(ui, spec, name, hits, |ui, _hits| {
            if values.is_empty() {
                ui.label(egui::RichText::new("(none)").weak());
            }
            for value in values {
                ui.label(format!("{value}"));
            }
        });
    }

    // --- the ONE bottom button: return to the list -------------------------
    // No OK/Cancel pair and no buffer: editing is LIVE (every change commits and
    // re-runs) and UNDO is the revert mechanism, so there is nothing for a
    // Cancel to roll back that undo does not already cover.
    ui.add_space(10.0);
    let exit = ui.add_sized(
        [ui.available_width(), 26.0],
        egui::Button::new(spec.exit_label),
    );
    publish(spec, hits, "form:return", exit.rect);
    out.exit_clicked = exit.clicked();
    // The exit's rollback half, gated by the consumer's declaration — see
    // [`FormViewSpec::rollback`].
    out.roll_to_tip = out.exit_clicked && spec.rollback;
    // Breathing room UNDER the last thing in the form. Not decoration: a form
    // taller than its pane scrolls, and the scroll stops when the content bottom
    // meets the viewport bottom — so without this the exit button ends up flush
    // against (and, once egui rounds its rect to physical pixels, a hair past)
    // the pane's clip edge, which is exactly where a click stops landing.
    ui.add_space(8.0);
}

/// Record one widget rect under `spec.hits_prefix`.
fn publish(
    spec: &FormViewSpec<'_>,
    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
    key: &str,
    rect: egui::Rect,
) {
    if let Some(map) = hits.as_deref_mut() {
        map.insert(format!("{}{key}", spec.hits_prefix), rect);
    }
}

/// One collapsible section: a header row that publishes `form:section:{name}`
/// and, when open, `body`. Open/collapsed lives in egui memory, keyed to this
/// form's subject, so two features' Transform sections remember separately and
/// the caller carries no state.
fn section(
    ui: &mut egui::Ui,
    spec: &FormViewSpec<'_>,
    name: &str,
    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
    body: impl FnOnce(&mut egui::Ui, &mut Option<&mut HashMap<String, egui::Rect>>),
) {
    ui.add_space(6.0);
    let open = !COLLAPSED_BY_DEFAULT.contains(&name);
    let response = egui::CollapsingHeader::new(egui::RichText::new(name).strong())
        .id_salt(("form-view-section", spec.hits_prefix, spec.title, name))
        .default_open(open)
        .show(ui, |ui| body(ui, hits));
    publish(
        spec,
        hits,
        &format!("form:section:{name}"),
        response.header_response.rect,
    );
}

/// Draw ONE schema field: its label, then the input at FULL WIDTH beneath it.
/// A `Reference` is the same shape — [`form::field_input`] draws its activation
/// button and the chosen entities under this same label.
fn draw_field(
    ui: &mut egui::Ui,
    spec: &FormViewSpec<'_>,
    field: &FormField,
    params: &mut Value,
    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
    out: &mut FormViewOut,
) {
    let mut probe: HashMap<String, egui::Rect> = HashMap::new();
    let mut rect = egui::Rect::NOTHING;
    let mut clicked: Option<String> = None;
    ui.add_space(4.0);
    // A `Button` field IS its own label (the schema gives it one), so a label
    // above it would say the same thing twice.
    if !matches!(field.kind, FieldKind::Button { .. }) {
        ui.label(&field.label);
    }
    // Scope the widget id-stack to THIS (subject, field) so a Scalar's
    // per-location egui-memory edit buffer (and its TextEdit focus id) can't
    // collide when two subjects share a param name — switching between two
    // extrudes mid-edit must not hand one's half-typed `distance` to the other.
    // `make_persistent_id` folds in the ui id-stack only.
    ui.push_id((spec.title, field.key()), |ui| {
        let (changed, r) = form::field_input(ui, field, params, Some(&mut probe), &mut clicked);
        out.changed |= changed;
        rect = r;
    });
    if let Some(map) = hits.as_deref_mut() {
        let prefix = spec.hits_prefix;
        map.insert(format!("{prefix}field:{}", field.path.join(".")), rect);
        for (key, r) in probe {
            map.insert(format!("{prefix}field:{key}"), r);
        }
    }
    // `field_input` reports the two ACTION kinds through the same out-param, by
    // field key; which intent it is comes from the field's own kind.
    if clicked.is_some() {
        match &field.kind {
            FieldKind::Reference { filter, multiple } => {
                out.ref_activate = Some(RefActivate {
                    path: field.path.clone(),
                    label: field.label.clone(),
                    filter: filter.clone(),
                    multiple: *multiple,
                    seed: form::reference_names(form::value_at(params, &field.path)),
                });
            }
            _ => out.button_clicked = clicked,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use brep_render::style::FieldKind;
    use serde_json::json;

    fn field(path: &str, label: &str, group: &str, kind: FieldKind) -> FormField {
        FormField {
            path: path.split('.').map(str::to_string).collect(),
            label: label.into(),
            group: group.into(),
            kind,
        }
    }

    /// One extrude-shaped schema: a reference, two plain params, a Boolean group
    /// and a Transform group — enough to exercise ordering and both accordion
    /// defaults.
    fn fields() -> Vec<FormField> {
        vec![
            field("id", "Id", "Parameters", FieldKind::Text { read_only: true }),
            field("distance", "Distance", "Parameters", FieldKind::Scalar { step: 0.5 }),
            field(
                "transform.position",
                "Position",
                "Transform",
                FieldKind::Vec3 { step: 0.1 },
            ),
            field(
                "profile",
                "Profile",
                "References",
                FieldKind::Reference { filter: vec!["sketch".into()], multiple: false },
            ),
            field(
                "boolean.operation",
                "Operation",
                "Boolean",
                FieldKind::Enum { variants: vec!["NONE".into(), "UNION".into()] },
            ),
            field("editSketch", "Edit Sketch", "Parameters", FieldKind::Button {
                label: "Edit Sketch".into(),
            }),
        ]
    }

    /// Draw ONE headless frame of the form and return `(out, hits)`.
    fn run(
        ctx: &egui::Context,
        params: &mut Value,
        events: Vec<egui::Event>,
    ) -> (FormViewOut, HashMap<String, egui::Rect>) {
        run_with(ctx, params, events, true)
    }

    /// The same, with the consumer's [`FormViewSpec::rollback`] declaration.
    fn run_with(
        ctx: &egui::Context,
        params: &mut Value,
        events: Vec<egui::Event>,
        rollback: bool,
    ) -> (FormViewOut, HashMap<String, egui::Rect>) {
        let fields = fields();
        let outputs = vec!["Extrude1_solid".to_string()];
        let trailing = [("Outputs", outputs)];
        let spec = FormViewSpec {
            title: "E3  Extrude",
            subtitle: None,
            fields: &fields,
            banner: None,
            trailing: Some(&trailing),
            exit_label: "Return to tree",
            rollback,
            hits_prefix: "",
        };
        let raw = egui::RawInput {
            screen_rect: Some(egui::Rect::from_min_size(
                egui::pos2(0.0, 0.0),
                egui::vec2(320.0, 700.0),
            )),
            events,
            ..Default::default()
        };
        let mut hits = HashMap::new();
        let mut out = FormViewOut::default();
        let _ = ctx.run_ui(raw, |ui| {
            out = form_view(ui, &spec, params, Some(&mut hits));
        });
        (out, hits)
    }

    fn click_at(pos: egui::Pos2) -> Vec<egui::Event> {
        vec![
            egui::Event::PointerMoved(pos),
            egui::Event::PointerButton {
                pos,
                button: egui::PointerButton::Primary,
                pressed: true,
                modifiers: egui::Modifiers::default(),
            },
            egui::Event::PointerButton {
                pos,
                button: egui::PointerButton::Primary,
                pressed: false,
                modifiers: egui::Modifiers::default(),
            },
        ]
    }

    /// R6 + Q8: `Transform` is a COLLAPSED accordion (its fields are not drawn
    /// until it is opened) while `Boolean` is open, and the read-only `Outputs`
    /// section is collapsed too. The read-only `id` never appears — it is the
    /// form's title.
    #[test]
    fn transform_collapses_by_default_and_boolean_does_not() {
        let ctx = egui::Context::default();
        let mut params = json!({ "id": "E3", "distance": 25.0 });
        let (_, hits) = run(&ctx, &mut params, vec![]);

        for key in ["form:section:Transform", "form:section:Boolean", "form:section:Outputs"] {
            assert!(hits.contains_key(key), "{key} header: {:?}", hits.keys());
        }
        assert!(
            !hits.contains_key("field:transform.position"),
            "Transform's fields stay behind its collapsed accordion"
        );
        assert!(
            hits.contains_key("field:boolean.operation"),
            "Boolean's operation is visible without a click: {:?}",
            hits.keys()
        );
        assert!(hits.contains_key("field:distance"), "plain params are un-sectioned");
        assert!(!hits.contains_key("field:id"), "the read-only id is the title, not a field");
    }

    /// The form publishes its own chrome so a verifier can drive it: the title,
    /// the ONE exit button, and the reference activation.
    #[test]
    fn the_form_publishes_its_chrome() {
        let ctx = egui::Context::default();
        let mut params = json!({ "id": "E3" });
        let (_, hits) = run(&ctx, &mut params, vec![]);
        for key in ["form:feature", "form:return", "field:profile#activate"] {
            assert!(hits.contains_key(key), "{key}: {:?}", hits.keys());
        }
    }

    /// A reference `Select` comes OUT as an intent carrying everything the picker
    /// needs — the form view never enters picking mode itself, because it holds
    /// no engine.
    #[test]
    fn pressing_select_returns_a_ref_activate_intent() {
        let ctx = egui::Context::default();
        let mut params = json!({ "id": "E3", "profile": "Sketch1" });
        let (_, hits) = run(&ctx, &mut params, vec![]);
        let at = hits["field:profile#activate"].center();

        let (out, _) = run(&ctx, &mut params, click_at(at));
        let activate = out.ref_activate.expect("Select surfaced an activation");
        assert_eq!(activate.path, vec!["profile".to_string()]);
        assert_eq!(activate.label, "Profile");
        assert_eq!(activate.filter, vec!["sketch".to_string()]);
        assert!(!activate.multiple);
        assert_eq!(activate.seed, vec!["Sketch1".to_string()], "the picker seeds from the value");
        assert_eq!(out.button_clicked, None, "a Select is not a schema button");
        assert!(!out.changed, "activating the picker edits nothing");
    }

    /// A schema `button` comes out by key on the SAME channel, and the caller
    /// tells the two apart by the field's kind — one out-param, two intents.
    #[test]
    fn pressing_a_schema_button_returns_its_key() {
        let ctx = egui::Context::default();
        let mut params = json!({ "id": "E3" });
        let (_, hits) = run(&ctx, &mut params, vec![]);
        let at = hits["field:editSketch"].center();

        let (out, _) = run(&ctx, &mut params, click_at(at));
        assert_eq!(out.button_clicked.as_deref(), Some("editSketch"));
        assert_eq!(out.ref_activate, None);
    }

    /// The ONE bottom button reports itself and nothing else — there is no
    /// Cancel to tell it apart from.
    #[test]
    fn the_exit_button_reports_itself() {
        let ctx = egui::Context::default();
        let mut params = json!({ "id": "E3" });
        let (_, hits) = run(&ctx, &mut params, vec![]);
        let at = hits["form:return"].center();

        let (out, _) = run(&ctx, &mut params, click_at(at));
        assert!(out.exit_clicked);
        assert!(!out.changed);
        assert_eq!(out.button_clicked, None);
    }

    /// The ROLLBACK declaration gates the exit's second half. A consumer whose
    /// subject lives in a rolled history (the feature tree) gets `roll_to_tip`
    /// with its exit; one that has no rollback at all (assembly constraints)
    /// gets the SAME exit and never the roll — so its panel carries no branch.
    #[test]
    fn rollback_gates_the_roll_to_tip_intent() {
        let ctx = egui::Context::default();
        let mut params = json!({ "id": "E3" });
        let (_, hits) = run_with(&ctx, &mut params, vec![], false);
        let at = hits["form:return"].center();

        let (rolling, _) = run_with(&ctx, &mut params, click_at(at), true);
        assert!(rolling.exit_clicked && rolling.roll_to_tip, "a rollback consumer rolls on exit");

        let (flat, _) = run_with(&ctx, &mut params, click_at(at), false);
        assert!(flat.exit_clicked, "the exit itself is unconditional");
        assert!(!flat.roll_to_tip, "a consumer with no rollback never carries the roll");
    }

    /// …and no roll is carried on a frame where the exit was NOT pressed, even
    /// for a rollback consumer.
    #[test]
    fn no_exit_means_no_roll() {
        let ctx = egui::Context::default();
        let mut params = json!({ "id": "E3" });
        let (out, _) = run_with(&ctx, &mut params, vec![], true);
        assert!(!out.exit_clicked && !out.roll_to_tip);
    }

    /// R5: the label sits ABOVE its input, and the input fills the form's width.
    /// Asserted geometrically off the published rect (the label is not a widget
    /// the form publishes, so this checks the field spans the panel).
    #[test]
    fn inputs_fill_the_form_width() {
        let ctx = egui::Context::default();
        let mut params = json!({ "id": "E3", "distance": 25.0 });
        let (_, hits) = run(&ctx, &mut params, vec![]);
        let distance = hits["field:distance"];
        assert!(
            distance.width() > 200.0,
            "a 320 pt panel gives a full-width field, not a 72 pt one: {}",
            distance.width()
        );
    }
}