Skip to main content

brep_app/
form_view.rs

1//! The SHARED schema-driven FORM VIEW — one function that draws a COMPLETE form
2//! for a schema-described subject (a history feature, an assembly constraint, a
3//! future PMI annotation) and returns what the user did.
4//!
5//! [`form::field_input`] is the shared per-FIELD renderer; this is the shared
6//! per-FORM renderer above it. Before it existed, four call sites hand-rolled
7//! their own chrome, grouping and layout around the same widget dispatcher — and
8//! the docs screenshot rig's layout had already silently drifted away from the
9//! app's. One form view means the layout is a ONE-place change.
10//!
11//! # The layout
12//!
13//! ```text
14//!   E3  ⟠ Extrude                 ← title
15//!   ─────────────────────────
16//!   ⚠ <error message>             ← banner (only when the subject failed)
17//!
18//!   Profile                       ← a reference field: label, full-width
19//!   [ ▣ Select sketch          ]    activation button, and the chosen entities
20//!     • Sketch1            [✕]      listed BENEATH it — never behind a `[+]`
21//!
22//!   Distance                      ← every other field: label ABOVE a
23//!   [ 25                       ]    full-width input
24//!
25//!   ▼ Boolean                     ← accordion, open by default
26//!   ▶ Transform                   ← accordion, COLLAPSED by default
27//!   ▶ Outputs                     ← read-only, collapsed by default
28//!
29//!   [      Return to tree      ]  ← the ONE bottom button
30//! ```
31//!
32//! Section order is References → Parameters → the remaining groups in schema
33//! order → Transform → the read-only trailing sections. References lead because
34//! they are what the user picks and they are the tallest thing on the form;
35//! Transform trails because it is the one the owner asked to collapse.
36//!
37//! # It renders; it does not mutate any engine
38//!
39//! The inputs are exactly `(ui, spec, &mut params)` — **no `EngineState`, no
40//! runner, no document**. That is deliberate and load-bearing: the headless docs
41//! generator (`examples/capture_dialogs.rs`) has none of those, so requiring one
42//! would make "the docs image generator uses the same code as the UI"
43//! impossible. Everything that needs an engine — activating the reference
44//! picker, acting on a schema `button`, committing the edited params, leaving
45//! the form — comes back OUT as a value in [`FormViewOut`] that the calling
46//! panel acts on AFTER the draw. (A commit callback would have to capture
47//! `&mut EngineState` while `params` is itself read out of that engine and the
48//! panel already holds it for the frame — three overlapping mutable borrows.)
49//!
50//! # Transient view state
51//!
52//! Which accordions are open is pure view state, so it lives in egui MEMORY
53//! keyed by (`hits_prefix`, `title`, section) rather than in a caller-owned set
54//! — that is what keeps the required inputs at `(ui, spec, &mut params)` for the
55//! headless caller.
56
57use crate::form;
58use brep_render::style::{FieldKind, FormField};
59use eframe::egui;
60use serde_json::Value;
61use std::collections::HashMap;
62
63/// Groups that are drawn as a COLLAPSED accordion by default. `Transform` is the
64/// owner's explicit call (R6); the read-only trailing sections join it because
65/// they report, they do not edit. Everything else opens by default — a section
66/// the user must click to see is a section they will miss.
67const COLLAPSED_BY_DEFAULT: &[&str] = &["Transform", "Outputs"];
68
69/// Everything ABOUT the form that the consumer supplies. Borrowed; the form view
70/// holds no state of its own beyond egui memory.
71pub struct FormViewSpec<'a> {
72    /// The form's heading — e.g. `"E3  ⟠ Extrude"`. Also the scope key for this
73    /// form's transient view state and per-field widget ids, so it must be
74    /// STABLE and UNIQUE per subject (a feature id / constraint id qualifies).
75    pub title: &'a str,
76    /// An optional second line under the title (a status, a type name…).
77    pub subtitle: Option<&'a str>,
78    /// The schema fields to render, in schema order.
79    pub fields: &'a [FormField],
80    /// A message banner drawn under the title — e.g. the feature's run error.
81    pub banner: Option<(&'a str, egui::Color32)>,
82    /// Read-only sections appended after the fields — e.g. `[("Outputs", …)]`.
83    pub trailing: Option<&'a [(&'a str, Vec<String>)]>,
84    /// The bottom button's label. Both of today's consumers say "Return to
85    /// tree" — the history feature list and the constraint list are BOTH drawn
86    /// by `panels::tree`, so the word is literal, not a metaphor (Q12). A
87    /// consumer whose list is NOT a tree supplies its own wording here.
88    pub exit_label: &'a str,
89    /// Whether this consumer's subject lives in a ROLLED history — i.e. whether
90    /// LEAVING the form also means "roll the model back to the tip".
91    ///
92    /// The form view holds no engine and therefore cannot roll anything itself
93    /// (that is [`FormViewOut::roll_to_tip`], which the caller acts on). What
94    /// this flag does is decide, in ONE place, whether an exit *carries* that
95    /// intent — so no consumer has to hard-code "am I the history panel?" at its
96    /// exit arm. The history panel passes `true`; assembly constraints have no
97    /// rollback at all (the owner's call) and pass `false`, which is why their
98    /// panel has no roll branch to get wrong.
99    pub rollback: bool,
100    /// Prefix for every published hit key. `""` for a consumer that shows ONE
101    /// form at a time (history); a consumer that can show several at once MUST
102    /// pass a prefix carrying the subject id or the rects collide silently.
103    pub hits_prefix: &'a str,
104}
105
106/// A reference field's `Select` was pressed — the caller dispatches this to its
107/// own picker flavour (`begin_ref_select` / `begin_ref_select_for_constraint`).
108#[derive(Debug, Clone, PartialEq)]
109pub struct RefActivate {
110    /// The JSON key chain the picked selection writes back into.
111    pub path: Vec<String>,
112    /// The field's label, for the picker's prompt.
113    pub label: String,
114    /// The schema's entity-kind filter (`["solid"]`, `["face"]`…).
115    pub filter: Vec<String>,
116    /// Whether the field accepts more than one entity.
117    pub multiple: bool,
118    /// The names already chosen — the picker lights them up as the seed.
119    pub seed: Vec<String>,
120}
121
122/// What the user did in one drawn frame of the form.
123#[derive(Debug, Default, Clone, PartialEq)]
124pub struct FormViewOut {
125    /// A field wrote into `params` — the caller commits the whole buffer.
126    pub changed: bool,
127    /// A [`FieldKind::Button`] field was clicked, by its key (`"editSketch"`).
128    pub button_clicked: Option<String>,
129    /// A reference field's `Select` was pressed.
130    pub ref_activate: Option<RefActivate>,
131    /// The bottom exit button was pressed — the caller returns to its list.
132    pub exit_clicked: bool,
133    /// The exit ALSO means "roll the model to the tip" — set only when the exit
134    /// button was pressed AND the consumer declared [`FormViewSpec::rollback`].
135    /// Always `false` for a consumer with no rollback, so its panel never needs
136    /// the branch.
137    pub roll_to_tip: bool,
138}
139
140/// Draw ONE complete schema-driven form into `ui`, editing `params` live, and
141/// return what the user did. `hits`, when supplied, receives the widget screen
142/// rects the headed verifier drives:
143///
144/// | key | what |
145/// |---|---|
146/// | `form:feature` | the title row |
147/// | `form:section:{Group}` | an accordion header |
148/// | `form:return` | the bottom exit button |
149/// | `field:{path}` | a field's input widget |
150/// | `field:{path}#{variant}` | an OPEN enum's items |
151/// | `field:{path}#activate` / `#x{i}` | a reference's Select / remove buttons |
152///
153/// all with `spec.hits_prefix` prepended.
154/// Padding INSIDE the form's container — the gap between the frame edge and the
155/// first/last widget on every side.
156const FORM_MARGIN: i8 = 10;
157
158/// Gap OUTSIDE the container, between it and the pane edge, so the frame's own
159/// stroke is not flush against the dock border.
160const FORM_OUTER_MARGIN: i8 = 6;
161
162pub fn form_view(
163    ui: &mut egui::Ui,
164    spec: &FormViewSpec<'_>,
165    params: &mut Value,
166    mut hits: Option<&mut HashMap<String, egui::Rect>>,
167) -> FormViewOut {
168    let mut out = FormViewOut::default();
169
170    // The whole form sits inside ONE padded container, so the fields never run
171    // flush against the pane edge the way the inline tree rendering did. The
172    // margin is the frame's, not per-widget spacing: a single container keeps
173    // the label-above-full-width rhythm intact when the pane is resized, and
174    // gives the scroll region a consistent inset on every side.
175    egui::Frame::group(ui.style())
176        .inner_margin(egui::Margin::same(FORM_MARGIN))
177        .outer_margin(egui::Margin::same(FORM_OUTER_MARGIN))
178        .show(ui, |ui| {
179            form_body(ui, spec, params, &mut hits, &mut out);
180        });
181
182    out
183}
184
185/// The form's contents, drawn INSIDE the padded container opened by
186/// [`form_view`]. Split out so the container owns the margin in one place
187/// rather than every section adding its own edge spacing.
188fn form_body(
189    ui: &mut egui::Ui,
190    spec: &FormViewSpec<'_>,
191    params: &mut Value,
192    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
193    out: &mut FormViewOut,
194) {
195    // Fill the container's width so full-width inputs stay full width.
196    ui.set_width(ui.available_width());
197
198    // --- heading ----------------------------------------------------------
199    let head = ui.heading(spec.title);
200    publish(spec, hits, "form:feature", head.rect);
201    if let Some(subtitle) = spec.subtitle {
202        ui.label(egui::RichText::new(subtitle).weak());
203    }
204    ui.separator();
205
206    // --- banner: the subject's error, ABOVE the fields ---------------------
207    // (The history tree ALSO keeps this on the feature's row, so a failure is
208    // still visible while scanning the tree — see the panel.)
209    if let Some((text, color)) = spec.banner {
210        egui::Frame::group(ui.style())
211            .stroke(egui::Stroke::new(1.0, color))
212            .fill(color.gamma_multiply(0.12))
213            .show(ui, |ui| {
214                ui.set_width(ui.available_width());
215                ui.add(
216                    egui::Label::new(egui::RichText::new(text).color(color))
217                        .wrap_mode(egui::TextWrapMode::Wrap),
218                );
219            });
220        ui.add_space(4.0);
221    }
222
223    // --- partition the schema into direct params + grouped sections --------
224    let mut references: Vec<&FormField> = Vec::new();
225    let mut param_leaves: Vec<&FormField> = Vec::new();
226    let mut groups: Vec<(&str, Vec<&FormField>)> = Vec::new();
227    for f in spec.fields {
228        match f.group.as_str() {
229            "References" => references.push(f),
230            "Parameters" => {
231                if matches!(f.kind, FieldKind::Text { read_only: true }) {
232                    continue; // the subject id — already the form's title
233                }
234                param_leaves.push(f);
235            }
236            group => match groups.iter_mut().find(|(name, _)| *name == group) {
237                Some(existing) => existing.1.push(f),
238                None => groups.push((group, vec![f])),
239            },
240        }
241    }
242    // Transform trails the other groups: it is the one the owner named as the
243    // collapsed accordion, so it belongs at the bottom, not between two open
244    // sections.
245    groups.sort_by_key(|(name, _)| usize::from(*name == "Transform"));
246
247    // (1) references, then (2) plain parameters — both un-sectioned, because a
248    // section over the fields the user came here to edit is a click in the way.
249    for f in references.iter().chain(param_leaves.iter()) {
250        draw_field(ui, spec, f, params, hits, out);
251    }
252
253    // (3) the remaining groups, each an accordion.
254    for (name, fields) in &groups {
255        section(ui, spec, name, hits, |ui, hits| {
256            for f in fields {
257                draw_field(ui, spec, f, params, hits, out);
258            }
259        });
260    }
261
262    // (4) read-only trailing sections (Outputs…).
263    for (name, values) in spec.trailing.unwrap_or(&[]) {
264        section(ui, spec, name, hits, |ui, _hits| {
265            if values.is_empty() {
266                ui.label(egui::RichText::new("(none)").weak());
267            }
268            for value in values {
269                ui.label(format!("• {value}"));
270            }
271        });
272    }
273
274    // --- the ONE bottom button: return to the list -------------------------
275    // No OK/Cancel pair and no buffer: editing is LIVE (every change commits and
276    // re-runs) and UNDO is the revert mechanism, so there is nothing for a
277    // Cancel to roll back that undo does not already cover.
278    ui.add_space(10.0);
279    let exit = ui.add_sized(
280        [ui.available_width(), 26.0],
281        egui::Button::new(spec.exit_label),
282    );
283    publish(spec, hits, "form:return", exit.rect);
284    out.exit_clicked = exit.clicked();
285    // The exit's rollback half, gated by the consumer's declaration — see
286    // [`FormViewSpec::rollback`].
287    out.roll_to_tip = out.exit_clicked && spec.rollback;
288    // Breathing room UNDER the last thing in the form. Not decoration: a form
289    // taller than its pane scrolls, and the scroll stops when the content bottom
290    // meets the viewport bottom — so without this the exit button ends up flush
291    // against (and, once egui rounds its rect to physical pixels, a hair past)
292    // the pane's clip edge, which is exactly where a click stops landing.
293    ui.add_space(8.0);
294}
295
296/// Record one widget rect under `spec.hits_prefix`.
297fn publish(
298    spec: &FormViewSpec<'_>,
299    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
300    key: &str,
301    rect: egui::Rect,
302) {
303    if let Some(map) = hits.as_deref_mut() {
304        map.insert(format!("{}{key}", spec.hits_prefix), rect);
305    }
306}
307
308/// One collapsible section: a header row that publishes `form:section:{name}`
309/// and, when open, `body`. Open/collapsed lives in egui memory, keyed to this
310/// form's subject, so two features' Transform sections remember separately and
311/// the caller carries no state.
312fn section(
313    ui: &mut egui::Ui,
314    spec: &FormViewSpec<'_>,
315    name: &str,
316    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
317    body: impl FnOnce(&mut egui::Ui, &mut Option<&mut HashMap<String, egui::Rect>>),
318) {
319    ui.add_space(6.0);
320    let open = !COLLAPSED_BY_DEFAULT.contains(&name);
321    let response = egui::CollapsingHeader::new(egui::RichText::new(name).strong())
322        .id_salt(("form-view-section", spec.hits_prefix, spec.title, name))
323        .default_open(open)
324        .show(ui, |ui| body(ui, hits));
325    publish(
326        spec,
327        hits,
328        &format!("form:section:{name}"),
329        response.header_response.rect,
330    );
331}
332
333/// Draw ONE schema field: its label, then the input at FULL WIDTH beneath it.
334/// A `Reference` is the same shape — [`form::field_input`] draws its activation
335/// button and the chosen entities under this same label.
336fn draw_field(
337    ui: &mut egui::Ui,
338    spec: &FormViewSpec<'_>,
339    field: &FormField,
340    params: &mut Value,
341    hits: &mut Option<&mut HashMap<String, egui::Rect>>,
342    out: &mut FormViewOut,
343) {
344    let mut probe: HashMap<String, egui::Rect> = HashMap::new();
345    let mut rect = egui::Rect::NOTHING;
346    let mut clicked: Option<String> = None;
347    ui.add_space(4.0);
348    // A `Button` field IS its own label (the schema gives it one), so a label
349    // above it would say the same thing twice.
350    if !matches!(field.kind, FieldKind::Button { .. }) {
351        ui.label(&field.label);
352    }
353    // Scope the widget id-stack to THIS (subject, field) so a Scalar's
354    // per-location egui-memory edit buffer (and its TextEdit focus id) can't
355    // collide when two subjects share a param name — switching between two
356    // extrudes mid-edit must not hand one's half-typed `distance` to the other.
357    // `make_persistent_id` folds in the ui id-stack only.
358    ui.push_id((spec.title, field.key()), |ui| {
359        let (changed, r) = form::field_input(ui, field, params, Some(&mut probe), &mut clicked);
360        out.changed |= changed;
361        rect = r;
362    });
363    if let Some(map) = hits.as_deref_mut() {
364        let prefix = spec.hits_prefix;
365        map.insert(format!("{prefix}field:{}", field.path.join(".")), rect);
366        for (key, r) in probe {
367            map.insert(format!("{prefix}field:{key}"), r);
368        }
369    }
370    // `field_input` reports the two ACTION kinds through the same out-param, by
371    // field key; which intent it is comes from the field's own kind.
372    if clicked.is_some() {
373        match &field.kind {
374            FieldKind::Reference { filter, multiple } => {
375                out.ref_activate = Some(RefActivate {
376                    path: field.path.clone(),
377                    label: field.label.clone(),
378                    filter: filter.clone(),
379                    multiple: *multiple,
380                    seed: form::reference_names(form::value_at(params, &field.path)),
381                });
382            }
383            _ => out.button_clicked = clicked,
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use brep_render::style::FieldKind;
392    use serde_json::json;
393
394    fn field(path: &str, label: &str, group: &str, kind: FieldKind) -> FormField {
395        FormField {
396            path: path.split('.').map(str::to_string).collect(),
397            label: label.into(),
398            group: group.into(),
399            kind,
400        }
401    }
402
403    /// One extrude-shaped schema: a reference, two plain params, a Boolean group
404    /// and a Transform group — enough to exercise ordering and both accordion
405    /// defaults.
406    fn fields() -> Vec<FormField> {
407        vec![
408            field("id", "Id", "Parameters", FieldKind::Text { read_only: true }),
409            field("distance", "Distance", "Parameters", FieldKind::Scalar { step: 0.5 }),
410            field(
411                "transform.position",
412                "Position",
413                "Transform",
414                FieldKind::Vec3 { step: 0.1 },
415            ),
416            field(
417                "profile",
418                "Profile",
419                "References",
420                FieldKind::Reference { filter: vec!["sketch".into()], multiple: false },
421            ),
422            field(
423                "boolean.operation",
424                "Operation",
425                "Boolean",
426                FieldKind::Enum { variants: vec!["NONE".into(), "UNION".into()] },
427            ),
428            field("editSketch", "Edit Sketch", "Parameters", FieldKind::Button {
429                label: "Edit Sketch".into(),
430            }),
431        ]
432    }
433
434    /// Draw ONE headless frame of the form and return `(out, hits)`.
435    fn run(
436        ctx: &egui::Context,
437        params: &mut Value,
438        events: Vec<egui::Event>,
439    ) -> (FormViewOut, HashMap<String, egui::Rect>) {
440        run_with(ctx, params, events, true)
441    }
442
443    /// The same, with the consumer's [`FormViewSpec::rollback`] declaration.
444    fn run_with(
445        ctx: &egui::Context,
446        params: &mut Value,
447        events: Vec<egui::Event>,
448        rollback: bool,
449    ) -> (FormViewOut, HashMap<String, egui::Rect>) {
450        let fields = fields();
451        let outputs = vec!["Extrude1_solid".to_string()];
452        let trailing = [("Outputs", outputs)];
453        let spec = FormViewSpec {
454            title: "E3  Extrude",
455            subtitle: None,
456            fields: &fields,
457            banner: None,
458            trailing: Some(&trailing),
459            exit_label: "Return to tree",
460            rollback,
461            hits_prefix: "",
462        };
463        let raw = egui::RawInput {
464            screen_rect: Some(egui::Rect::from_min_size(
465                egui::pos2(0.0, 0.0),
466                egui::vec2(320.0, 700.0),
467            )),
468            events,
469            ..Default::default()
470        };
471        let mut hits = HashMap::new();
472        let mut out = FormViewOut::default();
473        let _ = ctx.run_ui(raw, |ui| {
474            out = form_view(ui, &spec, params, Some(&mut hits));
475        });
476        (out, hits)
477    }
478
479    fn click_at(pos: egui::Pos2) -> Vec<egui::Event> {
480        vec![
481            egui::Event::PointerMoved(pos),
482            egui::Event::PointerButton {
483                pos,
484                button: egui::PointerButton::Primary,
485                pressed: true,
486                modifiers: egui::Modifiers::default(),
487            },
488            egui::Event::PointerButton {
489                pos,
490                button: egui::PointerButton::Primary,
491                pressed: false,
492                modifiers: egui::Modifiers::default(),
493            },
494        ]
495    }
496
497    /// R6 + Q8: `Transform` is a COLLAPSED accordion (its fields are not drawn
498    /// until it is opened) while `Boolean` is open, and the read-only `Outputs`
499    /// section is collapsed too. The read-only `id` never appears — it is the
500    /// form's title.
501    #[test]
502    fn transform_collapses_by_default_and_boolean_does_not() {
503        let ctx = egui::Context::default();
504        let mut params = json!({ "id": "E3", "distance": 25.0 });
505        let (_, hits) = run(&ctx, &mut params, vec![]);
506
507        for key in ["form:section:Transform", "form:section:Boolean", "form:section:Outputs"] {
508            assert!(hits.contains_key(key), "{key} header: {:?}", hits.keys());
509        }
510        assert!(
511            !hits.contains_key("field:transform.position"),
512            "Transform's fields stay behind its collapsed accordion"
513        );
514        assert!(
515            hits.contains_key("field:boolean.operation"),
516            "Boolean's operation is visible without a click: {:?}",
517            hits.keys()
518        );
519        assert!(hits.contains_key("field:distance"), "plain params are un-sectioned");
520        assert!(!hits.contains_key("field:id"), "the read-only id is the title, not a field");
521    }
522
523    /// The form publishes its own chrome so a verifier can drive it: the title,
524    /// the ONE exit button, and the reference activation.
525    #[test]
526    fn the_form_publishes_its_chrome() {
527        let ctx = egui::Context::default();
528        let mut params = json!({ "id": "E3" });
529        let (_, hits) = run(&ctx, &mut params, vec![]);
530        for key in ["form:feature", "form:return", "field:profile#activate"] {
531            assert!(hits.contains_key(key), "{key}: {:?}", hits.keys());
532        }
533    }
534
535    /// A reference `Select` comes OUT as an intent carrying everything the picker
536    /// needs — the form view never enters picking mode itself, because it holds
537    /// no engine.
538    #[test]
539    fn pressing_select_returns_a_ref_activate_intent() {
540        let ctx = egui::Context::default();
541        let mut params = json!({ "id": "E3", "profile": "Sketch1" });
542        let (_, hits) = run(&ctx, &mut params, vec![]);
543        let at = hits["field:profile#activate"].center();
544
545        let (out, _) = run(&ctx, &mut params, click_at(at));
546        let activate = out.ref_activate.expect("Select surfaced an activation");
547        assert_eq!(activate.path, vec!["profile".to_string()]);
548        assert_eq!(activate.label, "Profile");
549        assert_eq!(activate.filter, vec!["sketch".to_string()]);
550        assert!(!activate.multiple);
551        assert_eq!(activate.seed, vec!["Sketch1".to_string()], "the picker seeds from the value");
552        assert_eq!(out.button_clicked, None, "a Select is not a schema button");
553        assert!(!out.changed, "activating the picker edits nothing");
554    }
555
556    /// A schema `button` comes out by key on the SAME channel, and the caller
557    /// tells the two apart by the field's kind — one out-param, two intents.
558    #[test]
559    fn pressing_a_schema_button_returns_its_key() {
560        let ctx = egui::Context::default();
561        let mut params = json!({ "id": "E3" });
562        let (_, hits) = run(&ctx, &mut params, vec![]);
563        let at = hits["field:editSketch"].center();
564
565        let (out, _) = run(&ctx, &mut params, click_at(at));
566        assert_eq!(out.button_clicked.as_deref(), Some("editSketch"));
567        assert_eq!(out.ref_activate, None);
568    }
569
570    /// The ONE bottom button reports itself and nothing else — there is no
571    /// Cancel to tell it apart from.
572    #[test]
573    fn the_exit_button_reports_itself() {
574        let ctx = egui::Context::default();
575        let mut params = json!({ "id": "E3" });
576        let (_, hits) = run(&ctx, &mut params, vec![]);
577        let at = hits["form:return"].center();
578
579        let (out, _) = run(&ctx, &mut params, click_at(at));
580        assert!(out.exit_clicked);
581        assert!(!out.changed);
582        assert_eq!(out.button_clicked, None);
583    }
584
585    /// The ROLLBACK declaration gates the exit's second half. A consumer whose
586    /// subject lives in a rolled history (the feature tree) gets `roll_to_tip`
587    /// with its exit; one that has no rollback at all (assembly constraints)
588    /// gets the SAME exit and never the roll — so its panel carries no branch.
589    #[test]
590    fn rollback_gates_the_roll_to_tip_intent() {
591        let ctx = egui::Context::default();
592        let mut params = json!({ "id": "E3" });
593        let (_, hits) = run_with(&ctx, &mut params, vec![], false);
594        let at = hits["form:return"].center();
595
596        let (rolling, _) = run_with(&ctx, &mut params, click_at(at), true);
597        assert!(rolling.exit_clicked && rolling.roll_to_tip, "a rollback consumer rolls on exit");
598
599        let (flat, _) = run_with(&ctx, &mut params, click_at(at), false);
600        assert!(flat.exit_clicked, "the exit itself is unconditional");
601        assert!(!flat.roll_to_tip, "a consumer with no rollback never carries the roll");
602    }
603
604    /// …and no roll is carried on a frame where the exit was NOT pressed, even
605    /// for a rollback consumer.
606    #[test]
607    fn no_exit_means_no_roll() {
608        let ctx = egui::Context::default();
609        let mut params = json!({ "id": "E3" });
610        let (out, _) = run_with(&ctx, &mut params, vec![], true);
611        assert!(!out.exit_clicked && !out.roll_to_tip);
612    }
613
614    /// R5: the label sits ABOVE its input, and the input fills the form's width.
615    /// Asserted geometrically off the published rect (the label is not a widget
616    /// the form publishes, so this checks the field spans the panel).
617    #[test]
618    fn inputs_fill_the_form_width() {
619        let ctx = egui::Context::default();
620        let mut params = json!({ "id": "E3", "distance": 25.0 });
621        let (_, hits) = run(&ctx, &mut params, vec![]);
622        let distance = hits["field:distance"];
623        assert!(
624            distance.width() > 200.0,
625            "a 320 pt panel gives a full-width field, not a 72 pt one: {}",
626            distance.width()
627        );
628    }
629}